From 6aaee68c42fee301a3144db5e1a2e8a311a465e5 Mon Sep 17 00:00:00 2001 From: Marlon Kranz Date: Sun, 13 Sep 2026 17:25:20 +0200 Subject: [PATCH 01/11] Refuse a result variable beside a class or delegate binding Three printer and serializer fixtures carried a result variable beside a class binding, the shape the validator and the engine now refuse. Each is rebound with an expression, keeping every value and attribute position the rows pin. BpmnParse.parseServiceTaskLike fails the deployment when a class or delegateExpression binding carries a result variable, and only builds the expression behaviour with it. The validator now reports the combination on the resultVariable key of a service, send, or decide task, quoting the engine's refusal and naming the expression binding as the fix. The importer refuses it through UnsupportedServiceTaskFormError, reading the older resultVariableName spelling as the engine does. The engine-attributes golden rebinds AssessBodywork with an expression and is refrozen, since it carried the exact shape the engine refuses. ADR-0043 records the decision and ADR-0014's refusal list gains the case. --- .../0014-honest-bpmn-import-contract.md | 3 + ...t-variable-beside-an-expression-binding.md | 87 +++++++++++++++++++ packages/language/README.md | 4 +- .../language/src/bpmn-script-validator.ts | 52 +++++++++++ packages/language/test/validating.test.ts | 51 +++++++++-- packages/transform/README.md | 2 +- packages/transform/src/xml-to-ir.ts | 34 ++++++++ packages/transform/test/ir-to-dsl.test.ts | 4 +- packages/transform/test/ir-to-xml.test.ts | 6 +- packages/transform/test/xml-to-ir.test.ts | 30 ++++++- tests/golden/engine-attributes.bpmn | 2 +- tests/golden/engine-attributes.bpmnscript | 2 +- 12 files changed, 260 insertions(+), 17 deletions(-) create mode 100644 docs/decisions/0043-result-variable-beside-an-expression-binding.md diff --git a/docs/decisions/0014-honest-bpmn-import-contract.md b/docs/decisions/0014-honest-bpmn-import-contract.md index c193628..2992f0d 100644 --- a/docs/decisions/0014-honest-bpmn-import-contract.md +++ b/docs/decisions/0014-honest-bpmn-import-contract.md @@ -45,6 +45,7 @@ Refused constructs throw a subclass of `UnsupportedConstructError` before any IR - an unsupported service-task execution form (pre-existing), an `operaton:type` outside `external`, `mail`, and `shell`, or a `mail`/`shell` type on a thrown message -> `UnsupportedServiceTaskFormError` (ADR-0042) A mail or shell task missing a field its behaviour's own parse requires or naming a field its behaviour class does not declare draws the same error. So does a shell task carrying a field as an expression, or spelling a flag outside `true`/`false` in any letter case. + A service, send, or business rule task without a decision reference that carries `operaton:resultVariable`, or the older `operaton:resultVariableName`, beside `operaton:class` or `operaton:delegateExpression` draws it too, since `BpmnParse.parseServiceTaskLike` fails the deployment (ADR-0043). - a sequence flow's condition expression or a conditional event definition's condition carrying a `language`, which Operaton hands to a script engine rather than evaluating as the UEL expression this tool writes -> `UnsupportedConditionExpressionError` - an `operaton:errorEventDefinition` on an external task carrying `errorRef` and no `expression`, which fails the deployment, or an `errorRef` naming no error root with a code -> `UnsupportedErrorMappingError` (ADR-0038) - a user task carrying a `bpmn:humanPerformer` beside `operaton:assignee`, or more than one `bpmn:humanPerformer`, both of which Operaton refuses to deploy -> `UnsupportedAssignmentError` (ADR-0039) @@ -137,6 +138,8 @@ Amended by ADR-0041, which carries an async, exclusive, or retry setting found o Amended by ADR-0042, which adds the `UnsupportedServiceTaskFormError` and extras-warning detail above for a mail or shell task. +Amended by ADR-0043, which adds a result variable beside a class or delegate expression binding to the `UnsupportedServiceTaskFormError` bullet above. + Related decisions: ADR-0006 (the shared IR, where `warnings` deliberately lives outside the IR, which stays serializable). ADR-0007 (the Operaton moddle extension fork, whose declared and undeclared elements determine warning-attribution precision). ADR-0003 (auto-layout for diagram interchange, the one kind of element this contract drops without a warning). diff --git a/docs/decisions/0043-result-variable-beside-an-expression-binding.md b/docs/decisions/0043-result-variable-beside-an-expression-binding.md new file mode 100644 index 0000000..1f662ef --- /dev/null +++ b/docs/decisions/0043-result-variable-beside-an-expression-binding.md @@ -0,0 +1,87 @@ +--- +status: accepted +date: 2026-09-13 +decision-makers: Marlon Kranz +--- + +# A result variable rides an expression binding, never a class or delegate one + +## Context and Problem Statement + +`resultVariable` names the process variable a service task's return value is stored in. +This surface accepted it on a `service`, `send`, or `decide` task beside any of the five bindings, and the importer carried `operaton:resultVariable` beside any binding into the IR. +Operaton does not. +`BpmnParse.parseServiceTaskLike` reads the attribute once, through `parseResultVariable`, which also accepts the older `resultVariableName` spelling. +The `class` branch and the `delegateExpression` branch each fail the deployment when it is set, with the message `'resultVariableName' not supported for elements using ''`, where the element is `serviceTask`, `sendTask`, or `businessRuleTask`. +Only the `expression` branch hands it to `ServiceTaskExpressionActivityBehavior`. +The `type` branches for `mail`, `shell`, and `external` never read it, so beside `topic:` and `type:` it stays accepted and ignored, as ADR-0042 already records. +A `decide` task with a `decision` key takes a separate path, `parseDmnBusinessRuleTask`, which reads the attribute into `DmnBusinessRuleTaskActivityBehavior`, so beside `decision:` it is read. +The golden `engine-attributes` fixture carried the exact shape the engine refuses, a class binding beside a result variable, and every suite was green. +What should the validator and the importer do with a result variable beside a class or delegate binding? + +## Decision Drivers + +- ADR-0014's contract: a shape the engine refuses to deploy is refused on import, never carried into a process that cannot start. +- The validator's standing rule, applied in ADR-0037 and ADR-0042, that a deployment refusal the engine's parser raises is reported in the editor first, with the fix named. +- One fact, one home: which binding a result variable rides is a fact about `parseServiceTaskLike`, and both sides should read it the same way. + +## Considered Options + +- Refuse it in the validator and on import +- Warn and drop it on import, and keep accepting it in a script +- Keep accepting it on both sides + +## Decision Outcome + +Chosen option: refuse it in the validator and on import, because the engine fails the deployment and ADR-0014 refuses what fails deployment. + +The validator reports an error on the `resultVariable` key of a `service`, `send`, or `decide` task whose one binding is `class` or `delegate`. +The message quotes the engine's own refusal text under the element name the engine uses, and names the fix: bind with `expression` to store the return value, or drop the setting. +It runs beside the `type` checks, only once exactly one binding is written; a task with two bindings has that conflict to fix first. +Beside `expression:` and `decision:` the setting stays clean, and beside `topic:` and `type:` it stays accepted and ignored. + +The importer throws `UnsupportedServiceTaskFormError` for a service, send, or business rule task without a `decisionRef` that carries `operaton:resultVariable`, or the older `operaton:resultVariableName`, beside `operaton:class` or `operaton:delegateExpression`. +The refusal quotes the same engine text. +Beside `operaton:expression` the attribute imports and prints as before. + +The `engine-attributes` golden pair rebinds its `AssessBodywork` task with an expression, keeps its `resultVariable`, and is refrozen; its `(node id, attribute, value)` contract is unchanged. + +### Consequences + +- Good, because a script that would fail deployment draws an error at the setting, with the engine's own reason and the binding that does store a return value. +- Good, because a document the engine refuses no longer imports, prints, and recompiles into a document the engine still refuses. +- Bad, because a script carrying the combination draws an error rather than a dropped setting, though it never deployed either way. +- Bad, because the engine's refusal text is quoted by the validator and by the importer separately, so a change in the engine's wording is two edits. + +### Confirmation + +The binding table in `packages/language/test/validating.test.ts` pins the refusal on `service`, `send`, and `decide` under both bindings, and the clean row for `expression`. +The refusal table in `packages/transform/test/xml-to-ir.test.ts` pins the import refusal for both attribute spellings. +The `engine-attributes` round-trip suite pins the refrozen artifact byte for byte and imports it without a warning. + +## Pros and Cons of the Options + +### Refuse it in the validator and on import + +- Good, because it is the only option under which every document this surface accepts deploys. +- Bad, because an author who wrote the combination gets an error rather than a quiet fix. + +### Warn and drop it on import, and keep accepting it in a script + +- Good, because an imported document keeps its binding and loses only the attribute the engine would have refused. +- Bad, because the printed script would then differ from the document in a way the author has to notice in a warning, and the same script written by hand would still fail to deploy. + +### Keep accepting it on both sides + +- Good, because nothing changes. +- Bad, because the surface would keep producing documents the engine refuses, against ADR-0014, and the golden fixture pinning that shape would keep proving nothing. + +## More Information + +The validator's refused-binding table sits beside the `type` binding tables in `packages/language/src/bpmn-script-validator.ts`; the importer's check sits in `readServiceTaskBinding` in `packages/transform/src/xml-to-ir.ts`. + +Amends ADR-0014, whose `UnsupportedServiceTaskFormError` bullet gains this case. + +Related decisions: ADR-0014 (the honest import contract behind the refusal). +ADR-0021 (the binding list this rule reads). +ADR-0042 (the `type` binding, whose parse-mirroring checks this one sits beside). diff --git a/packages/language/README.md b/packages/language/README.md index e074458..174ac5a 100644 --- a/packages/language/README.md +++ b/packages/language/README.md @@ -199,6 +199,8 @@ process gym-membership { A `service` task takes exactly one of five binding attributes. `class` is a Java delegate class name, `expression` maps to `operaton:expression`, `delegate` is the friendlier spelling of `operaton:delegateExpression`, `topic` hands the work to an external worker through `operaton:type="external"` and `operaton:topic`, and `type` selects a behaviour Operaton builds itself, see [Mail and shell tasks](#mail-and-shell-tasks). `resultVariable` names the process variable the invocation's return value is stored in, and is not itself a binding. +It is legal beside `expression:`, where the engine stores the return value, and beside `decision:` on a `decide` task, where it holds the decision result. +Beside `topic:` and `type:` it is accepted and ignored, and beside `class:` and `delegate:` it is refused, because the engine refuses to deploy that combination. #### Send, receive, and decision tasks @@ -791,7 +793,7 @@ Validation beyond syntax lives in `src/bpmn-script-validator.ts`, which is the s The categories it covers: - Variables: an undeclared reference (warning), a type mismatch against the declared `var`, a name declared twice. -- Tasks: a duplicate attribute key, a `service`, `send`, or `decide` task without exactly one binding attribute, a `mapDecisionResult` outside the four result mappings, a `script` task with an unsupported fence tag or an empty or unterminated body. +- Tasks: a duplicate attribute key, a `service`, `send`, or `decide` task without exactly one binding attribute, a `resultVariable` beside a `class` or `delegate` binding, which the engine refuses to deploy, a `mapDecisionResult` outside the four result mappings, a `script` task with an unsupported fence tag or an empty or unterminated body. An external task's extras add their own: a `taskPriority` or an `error ... when` mapping on a step not bound with `topic`, a mapping headed by a word other than `error` or missing `when`, a mapping naming an undeclared code, and a `taskPriority` or `jobPriority` that is neither an integer nor a `"${...}"` expression. A `service`, `send`, or `decide` task bound with `type` adds its own: a required field its type's own parse requires missing, a field name its behaviour class does not declare, and, on a shell task, a field carried as an expression or a flag other than `true`/`false`. - Settings: a key the element does not own, a value in a shape its lowering cannot read (a quoted `asyncBefore`, an unquoted `versionTag`), a `form` block on an element that renders none, and a process header carrying a key it does not own. diff --git a/packages/language/src/bpmn-script-validator.ts b/packages/language/src/bpmn-script-validator.ts index a535d66..b23eb8c 100644 --- a/packages/language/src/bpmn-script-validator.ts +++ b/packages/language/src/bpmn-script-validator.ts @@ -490,6 +490,34 @@ const BUILTIN_BEHAVIOUR_CLASS: Readonly> = { shell: 'ShellActivityBehavior', }; +/** + * The bindings `BpmnParse.parseServiceTaskLike` fails the deployment for when + * a result variable sits beside them, each with the attribute name its + * refusal quotes. The `expression` branch alone is built with the variable; + * the `type` branches never read it, and a `decision` reads it on its own path. + */ +const RESULT_VARIABLE_REFUSING_BINDINGS: Readonly> = { + class: 'class', + delegate: 'delegateExpression', +}; + +/** The element name the same refusal quotes, per kind that reaches that method. */ +const SERVICE_TASK_LIKE_ELEMENT: Readonly< + Record<(ServiceTask | SendTask | BusinessRuleTask)['$type'], string> +> = { + ServiceTask: 'serviceTask', + SendTask: 'sendTask', + BusinessRuleTask: 'businessRuleTask', +}; + +/** @param description Noun phrase with article, e.g. `'a service task'`. */ +const resultVariableBindingMessage = ( + description: string, + binding: string, + element: string, +) => + `${capitalize(description)} cannot carry 'resultVariable' beside '${binding}': the engine refuses to deploy it ('resultVariableName' not supported for ${element} elements using '${RESULT_VARIABLE_REFUSING_BINDINGS[binding]}'); bind with 'expression' to store the return value, or drop it.`; + /** @param subject The message's leading noun phrase (`"Service task 'Notify'"`). */ const missingBuiltinFieldMessage = ( subject: string, @@ -1850,6 +1878,7 @@ export class BpmnScriptValidator { ) ) { this.checkBuiltinBinding(task, subject, accept); + this.checkResultVariableBinding(task, accept); } }; @@ -1871,6 +1900,7 @@ export class BpmnScriptValidator { ) ) { this.checkBuiltinBinding(task, subject, accept); + this.checkResultVariableBinding(task, accept); } } this.checkBindingAttribute(task, accept); @@ -1937,6 +1967,28 @@ export class BpmnScriptValidator { } } + /** Asked, as {@link checkBuiltinBinding} is, only once exactly one binding is written. */ + private checkResultVariableBinding( + task: ServiceTask | SendTask | BusinessRuleTask, + accept: ValidationAcceptor, + ): void { + const settings = settingsOf(task.items); + const result = settings.find((a) => a.key === 'resultVariable'); + const binding = settings.find( + (a) => RESULT_VARIABLE_REFUSING_BINDINGS[a.key] !== undefined, + ); + if (result === undefined || binding === undefined) return; + accept( + 'error', + resultVariableBindingMessage( + attributeBlockRuleOf(task)!.description, + binding.key, + SERVICE_TASK_LIKE_ELEMENT[task.$type], + ), + { node: result, property: 'key' }, + ); + } + private checkDecisionResultMapping( task: BusinessRuleTask, accept: ValidationAcceptor, diff --git a/packages/language/test/validating.test.ts b/packages/language/test/validating.test.ts index 23fb3ae..0a01366 100644 --- a/packages/language/test/validating.test.ts +++ b/packages/language/test/validating.test.ts @@ -184,6 +184,13 @@ const bindingRequired = (subject: string, keys: string, alternative = '') => `${subject} must declare a ${keys} setting${alternative}.`; const bindingConflict = (subject: string, written: string, keys: string) => `${subject} declares more than one binding (${written}); exactly one of ${keys} is allowed.`; +const resultVariableBinding = ( + subject: string, + binding: string, + element: string, + attribute: string, +) => + `${subject} cannot carry 'resultVariable' beside '${binding}': the engine refuses to deploy it ('resultVariableName' not supported for ${element} elements using '${attribute}'); bind with 'expression' to store the return value, or drop it.`; const MAP_DECISION_RESULT = `Setting 'mapDecisionResult' must be 'singleEntry', 'singleResult', ` + `'collectEntries', or 'resultList'.`; @@ -904,11 +911,6 @@ checks('Validation - attribute keys and value shapes', [ `process p { user U(assignee: "demo", formKey: "f", candidateGroups: "approvers", candidateUsers: "ada", dueDate: "2026-09-01T09:00:00", followUpDate: "2026-08-30T09:00:00", priority: 10) }`, [], ], - [ - 'a service task accepts resultVariable beside its binding', - `process p { service V(class: com.example.X, resultVariable: "outcome") }`, - [], - ], [ 'a script task accepts resultVariable', `process p { script T(resultVariable: "total") ${FENCE}js\n1 + 1\n${FENCE} }`, @@ -1035,6 +1037,45 @@ checks('Validation - service, send, and decision bindings', [ `process p { service V(resultVariable: "r", asyncBefore: true) }`, [bindingRequired(`Service task 'V'`, SERVICE_BINDINGS)], ], + [ + 'a service task cannot carry resultVariable beside class: the engine refuses to deploy it', + `process p { service V(class: com.example.X, resultVariable: "outcome") }`, + [resultVariableBinding('A service task', 'class', 'serviceTask', 'class')], + ], + [ + 'a service task cannot carry resultVariable beside delegate either', + `process p { service V(delegate: "\${bean}", resultVariable: "outcome") }`, + [ + resultVariableBinding( + 'A service task', + 'delegate', + 'serviceTask', + 'delegateExpression', + ), + ], + ], + [ + 'a send task bound with class refuses resultVariable under its own element name', + `process p { send N(class: com.example.X, resultVariable: "outcome") }`, + [resultVariableBinding('A send task', 'class', 'sendTask', 'class')], + ], + [ + 'a decision step bound with delegate refuses resultVariable under its own element name', + `process p { decide D(delegate: "\${bean}", resultVariable: "outcome") }`, + [ + resultVariableBinding( + 'A decision step', + 'delegate', + 'businessRuleTask', + 'delegateExpression', + ), + ], + ], + [ + 'resultVariable beside an expression binding is where the engine stores the return value', + `process p { service V(expression: "\${bean.method(execution)}", resultVariable: "outcome") }`, + [], + ], [ 'a send task with no binding names the five attributes', `process p { send N { } }`, diff --git a/packages/transform/README.md b/packages/transform/README.md index f970b7e..1dd1a2d 100644 --- a/packages/transform/README.md +++ b/packages/transform/README.md @@ -160,7 +160,7 @@ Every class below extends `UnsupportedConstructError`, so catching that one clas | Class | Refused because | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `UnsupportedElementError` | An element kind outside the supported subset (`bpmn:adHocSubProcess`, `bpmn:complexGateway`, ...) | -| `UnsupportedServiceTaskFormError` | A service, send, or business rule task that carries no execution binding the engine would reach, that carries an `operaton:type` outside `external`, `mail`, and `shell`, or that carries an `` element beside its implementation attributes, which the Connect plugin runs in their place; a mail or shell task missing a field its behaviour's parse requires, naming a field its behaviour class does not declare, or carrying a shell field as an expression or a flag outside `true`/`false`; or a thrown message whose binding shape it cannot read, a `mail`/`shell` type included, since a thrown message has no member block to carry the fields either requires ([ADR-0042](../../docs/decisions/0042-mail-and-shell-as-a-type-binding.md)) | +| `UnsupportedServiceTaskFormError` | A service, send, or business rule task that carries no execution binding the engine would reach, that carries an `operaton:type` outside `external`, `mail`, and `shell`, or that carries an `` element beside its implementation attributes, which the Connect plugin runs in their place; a mail or shell task missing a field its behaviour's parse requires, naming a field its behaviour class does not declare, or carrying a shell field as an expression or a flag outside `true`/`false`; or a thrown message whose binding shape it cannot read, a `mail`/`shell` type included, since a thrown message has no member block to carry the fields either requires ([ADR-0042](../../docs/decisions/0042-mail-and-shell-as-a-type-binding.md)); or a service, send, or business rule task without a decision reference carrying `operaton:resultVariable`, or the older `operaton:resultVariableName`, beside `operaton:class` or `operaton:delegateExpression`, which `BpmnParse.parseServiceTaskLike` refuses to deploy ([ADR-0043](../../docs/decisions/0043-result-variable-beside-an-expression-binding.md)) | | `UnsupportedFormFieldTypeError` | A form field typed outside `string`/`long`/`boolean`/`date`/`enum` | | `UnsupportedFormFieldConstraintError` | A form field constraint the engine refuses to deploy or the script cannot spell: a name outside the seven the surface takes, since `FormValidators.createValidator` fails the deployment on any name it has not registered, a `validator` or a bound with no `config`, or a name repeated on one field | | `UnsupportedFormReferenceError` | A user task naming both a form key and a form reference, or a form reference with no binding or one outside `latest`, `deployment`, and `version` | diff --git a/packages/transform/src/xml-to-ir.ts b/packages/transform/src/xml-to-ir.ts index 272cf71..d94db8a 100644 --- a/packages/transform/src/xml-to-ir.ts +++ b/packages/transform/src/xml-to-ir.ts @@ -4398,6 +4398,12 @@ function readServiceTaskBinding( if (binding === undefined) { throw refusal(detectUnsupportedServiceTaskForm(el, 'task')); } + refuseResultVariableBeside( + binding, + el, + `${element ?? 'service'}Task`, + refusal, + ); const bound = withInjectedFields(binding, el, id, `'${id}'`, warnings); if (bound.kind === 'builtin') { @@ -4406,6 +4412,34 @@ function readServiceTaskBinding( return withExternalExtras(bound, el, id, warnings); } +/** + * `BpmnParse.parseServiceTaskLike` builds only the `expression` behaviour + * with the result variable and fails the deployment when a `class` or + * `delegateExpression` binding carries one; its `parseResultVariable` reads + * the older `resultVariableName` spelling too. The `type` branches never + * read it, and a `decisionRef` reads it on its own path. + * + * @param elementName The tag as the engine's refusal names it (`serviceTask`). + */ +function refuseResultVariableBeside( + binding: ServiceTaskBinding, + el: ModdleElement, + elementName: string, + refusal: (construct: string) => Error, +): void { + if (binding.kind !== 'class' && binding.kind !== 'delegateExpression') return; + const written = ['resultVariable', 'resultVariableName'].find( + (attr) => readNamespacedAttr(el, attr) !== undefined, + ); + if (written === undefined) return; + throw refusal( + `operaton:${binding.kind} with operaton:${written}, which Operaton ` + + `refuses to deploy: "'resultVariableName' not supported for ` + + `${elementName} elements using '${binding.kind}'" ` + + '(BpmnParse.parseServiceTaskLike)', + ); +} + /** * Refuse the field lists the engine's parse refuses, in its order: the shell * value shapes and flags come before the missing-field checks, and the diff --git a/packages/transform/test/ir-to-dsl.test.ts b/packages/transform/test/ir-to-dsl.test.ts index 1d371a3..ec3d1f8 100644 --- a/packages/transform/test/ir-to-dsl.test.ts +++ b/packages/transform/test/ir-to-dsl.test.ts @@ -3013,7 +3013,7 @@ describe('irToDsl: engine attributes', () => { { kind: 'serviceTask', id: 'V', - binding: classBinding('com.example.C'), + binding: exprBinding('${c.run(execution)}'), resultVariable: 'res', asyncBefore: true, }, @@ -3105,7 +3105,7 @@ describe('irToDsl: engine attributes', () => { ' }', ); expect(dsl).toContain( - 'service V(class: "com.example.C", resultVariable: "res", asyncBefore: true)', + 'service V(expression: "${c.run(execution)}", resultVariable: "res", asyncBefore: true)', ); expect(dsl).toContain( 'script Sc(resultVariable: "out", asyncAfter: true) ```javascript', diff --git a/packages/transform/test/ir-to-xml.test.ts b/packages/transform/test/ir-to-xml.test.ts index c23c649..6691bca 100644 --- a/packages/transform/test/ir-to-xml.test.ts +++ b/packages/transform/test/ir-to-xml.test.ts @@ -1866,7 +1866,7 @@ const engineSettingsIr: BpmnProcess = { { kind: 'serviceTask', id: 'Auto', - binding: classBinding('com.example.Auto'), + binding: exprBinding('${auto.run(execution)}'), resultVariable: 'outcome', }, { @@ -2809,7 +2809,7 @@ describe('irToXml: multi-instance loop characteristics', () => { around({ kind: 'serviceTask', id: 'Step', - binding: classBinding('com.example.Step'), + binding: exprBinding('${step.run(execution)}'), asyncBefore: true, asyncAfter: true, jobPriority: '20', @@ -2826,7 +2826,7 @@ describe('irToXml: multi-instance loop characteristics', () => { }), ); expect(extractNodeBlock(xml, 'Step')).toBe( - '\n' + + '\n' + ' \n' + ' R3/PT5M\n' + ' \n' + diff --git a/packages/transform/test/xml-to-ir.test.ts b/packages/transform/test/xml-to-ir.test.ts index eac07c5..2a02928 100644 --- a/packages/transform/test/xml-to-ir.test.ts +++ b/packages/transform/test/xml-to-ir.test.ts @@ -5849,7 +5849,7 @@ describe('xmlToIr: flat engine settings on every carrying node kind', () => { - @@ -6936,7 +6936,7 @@ describe('xmlToIr: a mail or shell task imports with its fields on the three tag }); }); -describe('xmlToIr: a mail or shell task the engine would refuse is refused with its rule', () => { +describe('xmlToIr: a service-like task the engine would refuse is refused with its rule', () => { const TO = field('name="to" stringValue="ops@example.com"'); const TEXT = field('name="text" stringValue="Report attached"'); const COMMAND = field('name="command" stringValue="echo"'); @@ -6951,7 +6951,8 @@ describe('xmlToIr: a mail or shell task the engine would refuse is refused with `operaton:type="mail" without ${missing} field, which Operaton refuses ` + `to deploy: "${rule}" (BpmnParse.validateFieldDeclarationsForEmail)`; - // Revert: delete refuseBuiltinShapes, and the first six rows import. + // Revert: delete refuseBuiltinShapes, and the first six rows import; delete + // the result-variable check in readServiceTaskBinding, and the last two do. it.each([ [ 'a mail task with cc and text and no to, since cc satisfies nothing', @@ -7026,6 +7027,29 @@ describe('xmlToIr: a mail or shell task the engine would refuse is refused with 'operaton:type="mail", which this surface carries on a service, send ' + 'or business rule task alone', ], + [ + 'a result variable beside a class, which only an expression binding is built with', + oneNodeDoc('serviceTask', { + attrs: + 'operaton:class="com.example.Svc" operaton:resultVariable="outcome"', + }), + 'Service task', + 'operaton:class with operaton:resultVariable, which Operaton refuses ' + + "to deploy: \"'resultVariableName' not supported for serviceTask " + + "elements using 'class'\" (BpmnParse.parseServiceTaskLike)", + ], + [ + 'the older resultVariableName spelling beside a delegate on a business rule task, read by the same parse', + oneNodeDoc('businessRuleTask', { + attrs: + 'operaton:delegateExpression="${rater}" operaton:resultVariableName="rating"', + }), + 'Business rule task', + 'operaton:delegateExpression with operaton:resultVariableName, which ' + + "Operaton refuses to deploy: \"'resultVariableName' not supported " + + "for businessRuleTask elements using 'delegateExpression'\" " + + '(BpmnParse.parseServiceTaskLike)', + ], ])('%s', async (_title, xml, subject, construct) => { const e = await expectRefusal( xmlToIr(xml), diff --git a/tests/golden/engine-attributes.bpmn b/tests/golden/engine-attributes.bpmn index 521fd2f..9393e20 100644 --- a/tests/golden/engine-attributes.bpmn +++ b/tests/golden/engine-attributes.bpmn @@ -40,7 +40,7 @@ Flow_GradeMechanics_Gateway_claim-settlement_3_1_join Flow_Gateway_claim-settlement_3_1_join_InspectionReported - + R3/PT5M diff --git a/tests/golden/engine-attributes.bpmnscript b/tests/golden/engine-attributes.bpmnscript index d4e03e2..3a46d99 100644 --- a/tests/golden/engine-attributes.bpmnscript +++ b/tests/golden/engine-attributes.bpmnscript @@ -34,7 +34,7 @@ process claim-settlement( { service AssessBodywork( label: "Assess the bodywork", - class: "com.example.claims.BodyworkDelegate", + expression: "${bodyworkAssessor.assess(execution)}", resultVariable: "bodyworkReport", asyncBefore: true, retryCycle: "R3/PT5M" From 00e081ee91f37aa5f80a4fa98dc0af774589d39c Mon Sep 17 00:00:00 2001 From: Marlon Kranz Date: Sat, 26 Sep 2026 14:17:02 +0200 Subject: [PATCH 02/11] Mirror Operaton's parse and deployment rules in the validator, importer and printer The validator and importer now refuse what BpmnParse refuses and read attributes where the engine reads them: expression and value shapes, timer and retry shapes, job settings, listeners, scope rules for handlers, starts, races and loops, one executable process per document. Adds the engine deploy sweep over every golden, fixture and example, and corrects ADR-0002. --- README.md | 94 +- .../0002-use-langium-as-language-workbench.md | 7 +- ...003-auto-layout-for-diagram-interchange.md | 19 + ...te-representation-between-ast-and-bpmn.md} | 0 .../0009-dominator-based-restructuring.md | 4 + .../0010-deterministic-structural-ids.md | 2 + .../0014-honest-bpmn-import-contract.md | 32 +- ...boundary-events-attached-to-an-activity.md | 2 +- ...xternal-tasks-as-a-service-task-binding.md | 2 +- ...22-engine-attributes-as-named-ir-fields.md | 2 + .../0023-listeners-on-the-attribute-block.md | 3 + ...4-event-triggers-on-start-end-and-throw.md | 2 + ...ring-inclusive-and-event-based-gateways.md | 22 +- ...027-repetition-on-the-authoring-surface.md | 1 + ...8-attempt-blocks-and-the-cancel-trigger.md | 2 +- .../0031-carry-bpmn-documentation.md | 2 + .../0033-call-activity-variable-mapping.md | 3 + ...0034-multiple-start-events-on-a-process.md | 2 + ...k-events-for-import-round-trip-symmetry.md | 2 +- ...field-constraints-values-and-properties.md | 3 + ...rnal-task-extras-ride-the-topic-binding.md | 3 + ...ment-and-report-the-quantity-attributes.md | 2 + ...41-per-run-job-settings-on-a-repetition.md | 2 + .../0042-mail-and-shell-as-a-type-binding.md | 4 +- ...t-variable-beside-an-expression-binding.md | 5 + ...es-read-and-printed-with-string-escapes.md | 65 + ...lback-only-where-the-engine-can-take-it.md | 64 + ...0046-listener-shapes-the-engine-deploys.md | 82 + ...ettings-on-the-element-the-engine-reads.md | 67 + ...gines-parse-time-rules-in-the-validator.md | 79 + .../0049-import-modeler-shaped-documents.md | 99 + ...minted-ids-and-respell-unspellable-ones.md | 103 + ...nthesize-variable-declarations-on-print.md | 94 + ...0052-loop-bodies-with-conditioned-exits.md | 91 + ...es-off-a-step-and-jumps-with-conditions.md | 106 + ...54-import-tiers-follow-the-engine-parse.md | 146 + docs/glossary.md | 2 +- examples/README.md | 4 +- .../processes/loan-approval.bpmnscript | 1 - .../processes/order-rework.bpmnscript | 2 +- packages/cli/README.md | 7 + packages/cli/src/build.ts | 90 +- packages/cli/src/main.ts | 2 + packages/cli/src/parse.ts | 102 +- packages/cli/src/util.ts | 94 +- packages/cli/test/build-parse.smoke.test.ts | 472 +- packages/cli/test/build.layout-guard.test.ts | 30 + .../cli/test/decompile-contract.e2e.test.ts | 83 +- packages/cli/test/helpers/actions.ts | 79 +- packages/cli/test/util.test.ts | 14 + .../bpmn-script.injection.tmLanguage.json | 2 +- .../src/extension/conversion-core.ts | 29 +- .../extension/src/extension/conversion.ts | 14 +- .../test/bundled-conversion.e2e.test.ts | 2 +- .../extension/test/conversion-core.test.ts | 32 +- .../extension/test/injection-grammar.test.ts | 2 +- packages/language/README.md | 125 +- .../language/src/bpmn-script-validator.ts | 1823 ++++++- .../src/bpmn-script-value-converter.ts | 29 +- packages/language/src/bpmn-script.langium | 18 +- packages/language/src/expression-render.ts | 40 +- packages/language/src/paren-items.ts | 7 +- .../language/src/variable-symbol-provider.ts | 13 +- packages/language/src/vocabulary.ts | 137 +- packages/language/test/completion.test.ts | 33 +- packages/language/test/helpers/block-hosts.ts | 8 +- packages/language/test/parsing.test.ts | 55 +- packages/language/test/validating.test.ts | 1675 +++++- .../language/test/vocabulary-sync.test.ts | 16 + packages/transform/README.md | 130 +- packages/transform/src/ast-to-ir.ts | 145 +- packages/transform/src/bpmn-moddle.d.ts | 14 + packages/transform/src/cfg-analysis.ts | 83 +- packages/transform/src/errors.ts | 80 +- packages/transform/src/index.ts | 4 + packages/transform/src/ir-to-dsl.ts | 1277 +++-- packages/transform/src/ir-to-xml.ts | 167 +- packages/transform/src/ir/types.ts | 79 +- packages/transform/src/juel.ts | 71 +- packages/transform/src/operaton-moddle.json | 23 + packages/transform/src/synthesize-ids.ts | 51 +- packages/transform/src/xml-to-ir.ts | 3227 ++++++++--- packages/transform/test/ast-to-ir.test.ts | 281 +- packages/transform/test/cfg-analysis.test.ts | 23 + packages/transform/test/helpers/bpmn-doc.ts | 2 +- packages/transform/test/ir-to-dsl.test.ts | 1802 +++++-- .../test/ir-to-xml.layout-guard.test.ts | 31 + packages/transform/test/ir-to-xml.test.ts | 174 +- packages/transform/test/juel.test.ts | 74 +- .../transform/test/operaton-moddle.test.ts | 94 +- .../transform/test/synthesize-ids.test.ts | 110 +- packages/transform/test/xml-to-ir.test.ts | 4762 ++++++++++++++--- tests/branch-and-race.round-trip.test.ts | 36 +- tests/call-activity.round-trip.test.ts | 5 +- tests/e2e/deploy-sweep.test.ts | 139 + tests/engine-attributes.round-trip.test.ts | 11 +- tests/event-handlers.round-trip.test.ts | 6 +- tests/event-triggers.round-trip.test.ts | 24 +- tests/examples.health.test.ts | 49 +- tests/fixtures/adapters/spring-boot.ts | 5 + tests/fixtures/error-start.bpmn | 3 +- tests/fixtures/index.ts | 2 +- tests/fixtures/invoice-receipt.bpmn | 261 + tests/fixtures/lanes-and-async.bpmn | 2 +- tests/fixtures/types.ts | 3 + tests/gateway-settings.round-trip.test.ts | 14 +- tests/golden/README.md | 47 +- tests/golden/branch-and-race.bpmn | 11 +- tests/golden/branch-and-race.bpmnscript | 5 +- tests/golden/engine-attributes.bpmn | 2 +- tests/golden/gateway-settings.bpmn | 13 +- tests/golden/gateway-settings.bpmnscript | 4 +- tests/golden/input-output.bpmn | 5 - tests/golden/input-output.bpmnscript | 2 - tests/golden/listeners.bpmn | 7 +- tests/golden/listeners.bpmnscript | 3 +- tests/golden/mail-and-shell.bpmn | 2 +- tests/golden/mail-and-shell.bpmnscript | 5 +- tests/golden/review-loop.bpmn | 153 + tests/golden/review-loop.bpmnscript | 36 + .../golden/structured-control-flow.bpmnscript | 3 + tests/goto-fallback.round-trip.test.ts | 199 +- tests/helpers/import-first.ts | 16 +- tests/helpers/normalize-ir.test.ts | 54 + tests/helpers/normalize-ir.ts | 39 +- tests/helpers/pipeline.ts | 22 + tests/input-output.round-trip.test.ts | 5 - tests/invoice-receipt.import.test.ts | 146 + tests/listeners.round-trip.test.ts | 6 +- tests/mail-and-shell.round-trip.test.ts | 8 +- tests/parse-performance.test.ts | 80 + tests/review-loop.round-trip.test.ts | 36 + tests/round-trip-constructs.test.ts | 337 ++ 133 files changed, 17537 insertions(+), 3233 deletions(-) rename docs/decisions/{0006-engine-agnostic-intermediate-representation.md => 0006-intermediate-representation-between-ast-and-bpmn.md} (100%) create mode 100644 docs/decisions/0044-raw-templates-read-and-printed-with-string-escapes.md create mode 100644 docs/decisions/0045-reserve-an-inclusive-fallback-only-where-the-engine-can-take-it.md create mode 100644 docs/decisions/0046-listener-shapes-the-engine-deploys.md create mode 100644 docs/decisions/0047-timer-job-settings-on-the-element-the-engine-reads.md create mode 100644 docs/decisions/0048-mirror-the-engines-parse-time-rules-in-the-validator.md create mode 100644 docs/decisions/0049-import-modeler-shaped-documents.md create mode 100644 docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md create mode 100644 docs/decisions/0051-synthesize-variable-declarations-on-print.md create mode 100644 docs/decisions/0052-loop-bodies-with-conditioned-exits.md create mode 100644 docs/decisions/0053-routes-off-a-step-and-jumps-with-conditions.md create mode 100644 docs/decisions/0054-import-tiers-follow-the-engine-parse.md create mode 100644 packages/cli/test/build.layout-guard.test.ts create mode 100644 packages/transform/test/ir-to-xml.layout-guard.test.ts create mode 100644 tests/e2e/deploy-sweep.test.ts create mode 100644 tests/fixtures/invoice-receipt.bpmn create mode 100644 tests/golden/review-loop.bpmn create mode 100644 tests/golden/review-loop.bpmnscript create mode 100644 tests/invoice-receipt.import.test.ts create mode 100644 tests/parse-performance.test.ts create mode 100644 tests/review-loop.round-trip.test.ts diff --git a/README.md b/README.md index cffd055..da8ff57 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ npx bpmns parse invoice-approval.bpmn -o invoice-approval.bpmnscript Exit codes are `0` for success, `1` for validation or parse errors, `2` for I/O errors. Both directions print non-fatal warnings to stderr without changing the exit code, so an undeclared variable reference on compile, or a dropped lane on import is warned about but still produces a file. +`parse` also re-validates the script it wrote and lists every error `build` would draw from it, still with exit `0`. +Both commands read a file from disk, never stdin, and refuse to write to the input path itself, or to overwrite an existing output unless you pass `--force`; see [packages/cli/README.md](packages/cli/README.md) for the exact rules. ## Using the VS Code extension @@ -89,7 +91,7 @@ VS Code opens a second window with the extension loaded, where `.bpmnscript` fil - syntax highlighting, including inside a `script` task's fenced body, which is highlighted in its own language - autocompletion and hover, from the same grammar that drives the compiler -- errors and warnings inline as you type, so a `goto` that can't reach its target or a `string` variable compared against a number is flagged before you ever run the compiler +- errors and warnings inline as you type, so a `goto` that can't reach its target, a `string` variable under an ordered comparison, or a `boolean` in arithmetic is flagged before you ever run the compiler - a **Convert** panel in the sidebar: compile the open file, jump to its counterpart when one exists, or pick a `.bpmn` from disk to decompile Compiling and decompiling are the same two operations as `bpmns build` and `bpmns parse` above, without leaving the editor. @@ -100,45 +102,46 @@ See [packages/extension/README.md](packages/extension/README.md) for how the pie One `process` block per file. Steps run top to bottom, so you never write a sequence flow; control flow is expressed with the structured statements you'd expect from a programming language. -Every step carries an id (`user ReviewInvoice`), which is what `goto` and boundary events refer to, and which becomes the BPMN element's name (`ReviewInvoice` -> "Review Invoice") unless a `label` setting gives one instead. -The event statements `on`, `throw`, `emit` and `await` are the exception: their id is a jump target only, so they take no `label`, and the diagram shows no name on them, except on the two ends of a link pair, which carry their link name. - -| BPMNscript | What it means | BPMN element | -| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | -| `process X { }` | the bpmn process, one per file | `bpmn:process` | -| `var x: number` | declare a variable so its uses get type-checked | none; authoring-time only | -| `start X` / `end X` | where the flow begins and ends | start / end event | -| `start X (...)` | start when an event arrives | start event with a trigger | -| `end X terminate` | stop every running path | terminate end event | -| `end X cancel` | give up the block of steps around this end | cancel end event | -| `form { x: number "Label" }` | part of start element, pre-fills variables | form fields on the start event | -| `user X(assignee: "...")` | a step a person performs | user task | -| `service X(class: "...")` | a step the system performs, in the engine | service task | -| `service X(topic: "...")` | a step an external worker picks up by topic | service task, external type | -| `service X(type: "shell")` | a step the engine runs itself, a shell command or a mail | service task, built-in type | -| `script X` + a fenced body | an inline script, in JS, Groovy, Python, Ruby or FEEL | script task | -| `step X` | a step nothing in the engine automates | task | -| `send X(class: "...")` | a step that sends something out | send task | -| `receive X(message: "...")` | wait here until that message arrives | receive task | -| `decide X(decision: "...")` | a step a decision table answers | business rule task | -| `if` / `else if` / `else` | a decision | exclusive gateway | -| `while` / `do ... while` | a loop | exclusive gateway loop | -| `parallel { { } { } }` | branches that run at the same time | parallel gateway fork and join | -| `parallel { if (c) { } else { } }` | the branches whose condition holds run, or the `else` branch when none does | inclusive gateway fork and join | -| `subprocess X { }` | a group of steps as one unit | embedded sub-process | -| `attempt X { }` | a group of steps that can be given up as one | transaction sub-process | -| `call X(process: "other")` | start another process and wait for it | call activity | -| `goto X` | jump to a named step in the same container | sequence flow | -| `on { }` | catch an event anywhere in this body | event sub-process | -| `on Host: { }` | catch an event only while `Host` runs | boundary event | -| `await (...)` | stop here until the event arrives | intermediate catch event | -| `await { (...) { } (...) { } }` | wait on several triggers, continue down whichever fires first | event-based gateway, a catch event per branch, exclusive join | -| `throw` / `emit ` | raise an event, ending the path or continuing | throw event | -| `emit link("X")` / `await link("X")` | jump to the catch of the same name, a diagram's off-page connector | intermediate throw and catch event, with no flow between them | -| `for each x in c` | run a step once per item, or a set number of times | multi-instance marker | -| `asyncBefore: true` | engine settings: async, exclusive, priority, retries, or, with a `run` prefix, on each run of a repetition | `operaton:` attribute/element | -| `input x = "..."` | data into a step's execution, and `output` back out | `operaton:inputOutput` | -| `on create(class: "...")` | code on a step's lifecycle, not a caught event | execution / task listener | +Every step carries an id (`user ReviewInvoice`), which is what `goto` and boundary events refer to. +A step, a call and a sub-process take their diagram name from that id (`ReviewInvoice` -> "Review Invoice") unless a `label` setting gives one instead; a `start` and an `end` are named by `label` alone, and their id is never humanized into a name. +The event statements `on`, `throw`, `emit` and `await` are the exception: their id is a jump target only, so they take no `label` and show no name at all, except on the two ends of a link pair, which carry their link name. + +| BPMNscript | What it means | BPMN element | +| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `process X { }` | the bpmn process, one per file | `bpmn:process` | +| `var x: number` | declare a variable so its uses get type-checked | none; the importer declares `var x: any` for every variable the printed script reads and nothing else declares | +| `start X` / `end X` | where the flow begins and ends | start / end event | +| `start X (...)` | start when an event arrives | start event with a trigger | +| `end X terminate` | stop every running path | terminate end event | +| `end X cancel` | give up the block of steps around this end | cancel end event | +| `form { x: number "Label" }` | part of start element, pre-fills variables | form fields on the start event | +| `user X(assignee: "...")` | a step a person performs | user task | +| `service X(class: "...")` | a step the system performs, in the engine | service task | +| `service X(topic: "...")` | a step an external worker picks up by topic | service task, external type | +| `service X(type: "shell")` | a step the engine runs itself, a shell command or a mail | service task, built-in type | +| `script X` + a fenced body | an inline script, in JS, Groovy, Python, Ruby or FEEL | script task | +| `step X` | a step nothing in the engine automates | task | +| `send X(class: "...")` | a step that sends something out | send task | +| `receive X(message: "...")` | wait here until that message arrives | receive task | +| `decide X(decision: "...")` | a step a decision table answers | business rule task | +| `if` / `else if` / `else` | a decision | exclusive gateway | +| `while` / `do ... while` | a loop | exclusive gateway loop | +| `parallel { { } { } }` | branches that run at the same time | parallel gateway fork and join | +| `parallel { if (c) { } else { } }` | the branches whose condition holds run, or the `else` branch when none does | inclusive gateway fork and join | +| `subprocess X { }` | a group of steps as one unit | embedded sub-process | +| `attempt X { }` | a group of steps that can be given up as one | transaction sub-process | +| `call X(process: "other")` | start another process and wait for it | call activity | +| `goto X` | jump to a named step in the same container | sequence flow | +| `on { }` | catch an event anywhere in this body | event sub-process | +| `on Host: { }` | catch an event only while `Host` runs | boundary event | +| `await (...)` | stop here until the event arrives | intermediate catch event | +| `await { (...) { } (...) { } }` | wait on several triggers, continue down whichever fires first | event-based gateway, a catch event per branch, exclusive join | +| `throw` / `emit ` | raise an event, ending the path or continuing | throw event | +| `emit link("X")` / `await link("X")` | jump to the catch of the same name, a diagram's off-page connector | intermediate throw and catch event, with no flow between them | +| `for each x in c` | run a step once per item, or a set number of times | multi-instance marker | +| `asyncBefore: true` | engine settings: async, exclusive, priority, retries, or, with a `run` prefix, on each run of a repetition | `operaton:` attribute/element | +| `input x = "..."` | data into a step's execution, and `output` back out | `operaton:inputOutput` | +| `on create(class: "...")` | code on a step's lifecycle, not a caught event | execution / task listener | The `for` row is a modifier rather than a statement: every activity in the table takes it between the id and the parens, meaning `user`, `service`, `script`, `step`, `send`, `receive`, `decide`, `subprocess`, `attempt`, and `call`. The last three rows are members of an element rather than statements: an engine setting joins the rest in the `( )`, a parameter and a listener go in the `{ }`, and not every element takes every one. @@ -248,14 +251,19 @@ The end-to-end suite deploys several of that directory's processes to an Operato BPMN is a much larger language than this DSL, so a `.bpmn` file can hold more than a `.bpmnscript` has a form for. A decompile deals with that in three ways. -- A construct the DSL cannot express (a collaboration, an ad hoc subprocess, a compensation boundary event, an event definition the language doesn't model) is refused with an error and nothing is written. -- Content the intermediate representation doesn't carry (a lane, a text annotation, a listener or an input/output parameter found on a gateway, a manual task, a standard loop, a data object) is dropped with a warning naming each item, and so is an `isExecutable="false"`, which imports as executable whatever the source said. +- A construct the DSL cannot express (an ad hoc subprocess, a compensation boundary event, an event definition the language doesn't model) is refused with an error and nothing is written, and so is a shape Operaton refuses to deploy, such as an exclusive gateway with two unconditioned routes or an `operaton:inputOutput` on a gateway. +- Content the intermediate representation doesn't carry (a pool, a lane, a text annotation, a listener found on a gateway, a manual task, a standard loop, a data object) is dropped with a warning naming each item, and so is an `isExecutable="false"`, which imports as executable whatever the source said. The exceptions are attributes the importer doesn't read. - What the print hop cannot carry into the script is warned about too, and those warnings are the ones to read before building the output. A gateway's name merely drops, since the script derives its splits and merges from block structure and no statement is left to carry one. A fork whose branches all carry conditions and that named no fallback changes what a recompiled document runs, because the printed block falls through where the model stops with a stuck execution. A route with no `goto` target to jump to is left out entirely, replaced by a `// unstructured region: hand-repair required` comment naming the element it led into. An `else` beside a branch that runs whatever the conditions do prints a fallback nothing can reach, and the validator then rejects the script `bpmns parse` just wrote. + An id the script cannot spell is respelled and the change is reported, since the engine keys history, a migration plan and a start-before-activity on that id, so a rebuilt document does not run as the same process. + A jump into a step reached from outside the `await` or `parallel` branch it printed in is still written and reported, and the validator then refuses it once `bpmns parse` reads the script back, because a branch's steps run only when the whole block is reached. + A split or wait left with no route out is dropped from the script outright, and the warning names what the engine does in its place, from ending the run the same way to refusing to deploy the model at all. + +A branch that only rejoins the block's tail may print after the block instead, reached by a `goto`, and the block's join may be replaced by direct edges into that tail; the set of steps and their order along each path is unchanged, so this restructuring prints with no warning at all. What each hop reports, item by item, is the import contract in [packages/transform/README.md](packages/transform/README.md#the-import-contract) and `PrintWarningCategory` with the warnings built beside it in [packages/transform/src/ir-to-dsl.ts](packages/transform/src/ir-to-dsl.ts). [ADR-0009](docs/decisions/0009-dominator-based-restructuring.md) covers which shapes degrade and why. @@ -297,7 +305,7 @@ flowchart LR A source file is parsed into an AST, converted into the IR (a small set of plain TypeScript objects in `packages/transform/src/ir/types.ts` that describe a process without reference to any specific engine), and written out from there. Compiling is `.bpmnscript` -> AST -> IR -> `.bpmn`; decompiling is `.bpmn` -> IR -> `.bpmnscript`. -The IR stays vendor-neutral: the engine's bindings, execution settings, input/output parameters and lifecycle listeners are all plain-named IR fields, and `operaton:` is applied only where `irToXml` builds the moddle element from a local [moddle extension](packages/transform/src/operaton-moddle.json), which keeps the engine's specifics out of the core data model ([ADR-0006](docs/decisions/0006-engine-agnostic-intermediate-representation.md), [ADR-0022](docs/decisions/0022-engine-attributes-as-named-ir-fields.md), [ADR-0023](docs/decisions/0023-listeners-on-the-attribute-block.md)). +The IR carries Operaton's semantics under names with no vendor prefix: the engine's bindings, execution settings, input/output parameters and lifecycle listeners are all plain-named IR fields, and `operaton:` is applied only where `irToXml` builds the moddle element from a local [moddle extension](packages/transform/src/operaton-moddle.json) ([ADR-0006](docs/decisions/0006-intermediate-representation-between-ast-and-bpmn.md), [ADR-0022](docs/decisions/0022-engine-attributes-as-named-ir-fields.md), [ADR-0023](docs/decisions/0023-listeners-on-the-attribute-block.md)). | Library | Role | | --------------------------------------------------------------- | --------------------------------------------------- | diff --git a/docs/decisions/0002-use-langium-as-language-workbench.md b/docs/decisions/0002-use-langium-as-language-workbench.md index 8c9b1ab..cc81098 100644 --- a/docs/decisions/0002-use-langium-as-language-workbench.md +++ b/docs/decisions/0002-use-langium-as-language-workbench.md @@ -13,7 +13,7 @@ Which language workbench should be used to implement the DSL infrastructure? ## Decision Drivers -- VS Code must be the primary IDE target, with first-class extension support +- The primary IDE target had to be Visual Studio Code or IntelliJ, the author's two familiar, high-adoption editors, with first-class extension support - IDE features (syntax highlighting, autocomplete, jump-to-definition, inline errors) are a core value proposition of a textual DSL - A 15-week thesis timeline requires fast bootstrapping and minimal boilerplate - A post-thesis browser-based playground should be architecturally feasible @@ -30,7 +30,9 @@ Which language workbench should be used to implement the DSL infrastructure? ## Decision Outcome -Chosen option: "Langium", because it is the only workbench that natively targets VS Code with TypeScript, generates an LSP server and VS Code extension from a grammar definition, and supports browser deployment for a future playground, all within a timeline-feasible learning curve. +Chosen option: "Langium", primarily for its own ergonomics: a smaller, more approachable toolchain than Xtext's, and native access to the existing TypeScript BPMN tooling this project builds on: `bpmn-moddle` to read and write BPMN 2.0 XML, and `bpmn-auto-layout` to generate the Diagram Interchange (DI) data a textual syntax has no coordinate system to produce by hand (see ADR-0003). +The IDE target followed from this choice rather than preceding it: Langium's TypeScript toolchain generates an LSP server and VS Code extension directly from the grammar, which made Visual Studio Code the easier of the two viable targets to reach, whereas an IntelliJ plugin for Xtext would not have benefited from that same generation path. +Browser deployment for a future playground was a secondary factor, all within a timeline-feasible learning curve. ### Consequences @@ -51,6 +53,7 @@ Chosen option: "Langium", because it is the only workbench that natively targets - Good, because it generates a full LSP server and VS Code extension from a single grammar file - Good, because it uses TypeScript throughout: the same language for grammar processing, validation, code generation, and the VS Code extension +- Good, because it shares the TypeScript/npm ecosystem with `bpmn-moddle` and `bpmn-auto-layout`, the libraries this project uses to read/write and lay out BPMN 2.0 XML, with no cross-language bridge needed to reach them - Good, because it natively supports browser deployment via web workers and Monaco Editor - Good, because it provides built-in scoping, cross-reference resolution, and validation infrastructure - Good, because it is actively developed (v4.2, February 2026) with TypeFox as primary maintainer diff --git a/docs/decisions/0003-auto-layout-for-diagram-interchange.md b/docs/decisions/0003-auto-layout-for-diagram-interchange.md index cce4917..f709216 100644 --- a/docs/decisions/0003-auto-layout-for-diagram-interchange.md +++ b/docs/decisions/0003-auto-layout-for-diagram-interchange.md @@ -33,3 +33,22 @@ Chosen option: "Auto-layout on export, discard DI on import", because a textual - Good, because `bpmn-auto-layout` (the bpmn.io layout library) produces consistent, readable diagrams - Bad, because manually arranged layouts in imported BPMN files are lost after a round-trip - Neutral, because users who need precise layout control can adjust the generated BPMN in a graphical editor after export + +## More Information + +When `bpmn-auto-layout`'s grid solver throws on a graph it cannot place, `irToXml` raises `LayoutError`. +It carries the document serialized just before the layout call, with no `bpmndi:` diagram. +The CLI and the extension catch it, write that diagram-less document, and warn the user instead of losing the output. +The engine deploys it unchanged: `BpmnParse.parseDiagramInterchangeElements` reads a diagram only when the document's `BPMNDiagram` list is non-empty. +A document with none skips diagram parsing and deploys on its process content alone. + +The layout as it stands has two known defects, each measured on the current tree. +The measurement walks every flow node for a shape, every sequence flow for an edge, and every edge segment for an activity shape it crosses that is neither its source nor its target. +First, a `goto` edge into a sub-process is routed across the sub-process's own children instead of around them. +The example `order-handling`'s `Flow_MarkAutoApproved_Payment` crosses both the `Payment` sub-process's start event and its first task. +The golden `boundary-events`' `Flow_MarkAddressVerified_PackGoods` crosses the `PackGoods` sub-process's start event and its first task the same way. + +Second, two boundary events on one host are placed along the host's lower edge with no spread of their own. +In the golden `boundary-events`, the two events on `CheckAddress` sit only a few pixels apart on a host narrower than their combined width. +The two on `PackGoods` are both centered on the host's bottom edge with no vertical offset between them. +A border-termination pass for the first defect and a boundary-spread pass for the second are both considered options, not committed work. diff --git a/docs/decisions/0006-engine-agnostic-intermediate-representation.md b/docs/decisions/0006-intermediate-representation-between-ast-and-bpmn.md similarity index 100% rename from docs/decisions/0006-engine-agnostic-intermediate-representation.md rename to docs/decisions/0006-intermediate-representation-between-ast-and-bpmn.md diff --git a/docs/decisions/0009-dominator-based-restructuring.md b/docs/decisions/0009-dominator-based-restructuring.md index 6b43c7b..070c533 100644 --- a/docs/decisions/0009-dominator-based-restructuring.md +++ b/docs/decisions/0009-dominator-based-restructuring.md @@ -80,5 +80,9 @@ The CFG analysis utility lives at `packages/transform/src/cfg-analysis.ts` and e Amended by ADR-0036, which lets a bare authored terminal print inline in a guard clause when the split's own route is its only incoming flow, rather than staying a `goto`. +Amended by ADR-0052, which continues a split whose every route is conditioned at the enclosing construct's stop node when a route stays inside it, so only the leaving routes print as jumps. + +Amended by ADR-0053, which prints a statement with more than one route out as the fork block the engine runs it as, keeps each route's condition on its jump where a fork or race degrades, under a marker line naming the split, and reports a split with no route out, a split routing back into itself, and a jump across a branch border. + RPST decomposition is left for later. It would recover more structured patterns, but the dominator-based catalog with a `goto` fallback already covers the current scope, so the added machinery is not yet justified. diff --git a/docs/decisions/0010-deterministic-structural-ids.md b/docs/decisions/0010-deterministic-structural-ids.md index 3b45ad3..f066d42 100644 --- a/docs/decisions/0010-deterministic-structural-ids.md +++ b/docs/decisions/0010-deterministic-structural-ids.md @@ -92,3 +92,5 @@ The round-trip normalizer (`tests/helpers/normalize-ir.ts`) uses the id patterns The id templates are frozen, because they are consumed by `astToIr`, `irToDsl`, and the round-trip normalizer. Any change requires updating all three consumers and regenerating the `invoice-approval-generated.bpmn` golden file. The frozen contract is documented in the header of `packages/transform/src/synthesize-ids.ts`. + +Amended by ADR-0050, which narrows the reserved `StartEvent_*` and `EndEvent_*` patterns to the exact `StartEvent_` and `EndEvent_` the desugarer mints, and respells an id the script cannot spell on print. diff --git a/docs/decisions/0014-honest-bpmn-import-contract.md b/docs/decisions/0014-honest-bpmn-import-contract.md index 2992f0d..75ccc63 100644 --- a/docs/decisions/0014-honest-bpmn-import-contract.md +++ b/docs/decisions/0014-honest-bpmn-import-contract.md @@ -35,19 +35,19 @@ Refused constructs throw a subclass of `UnsupportedConstructError` before any IR - an event definition of a kind the event's position does not accept -> `UnsupportedEventDefinitionError` - a multi-instance repetition the engine refuses to deploy, one this tool cannot write back unchanged, or one on an event handler, which its trigger enters rather than repeats -> `UnsupportedLoopCharacteristicsError` -- a collaboration (pools/message flows) -> `UnsupportedCollaborationError` +- two or more executable processes -> `UnsupportedCollaborationError`; a pool or a message flow warns, and a document shape the engine refuses before it reads an element draws `UnsupportedDocumentError` (ADR-0049) - an event of a supported kind carrying a shape the surface cannot express, such as an error throw with no code -> `UnsupportedEventFeatureError` - a call activity with no `calledElement`, with a version binding the surface cannot pin, with a `calledElementTenantId`, which decides which tenant's copy of the called process runs, or with an `operaton:in`/`out` mapping in a shape the surface cannot spell -> `UnsupportedCallActivityError` - an `operaton:formField` of a type the form surface does not map -> `UnsupportedFormFieldTypeError` - an `operaton:constraint` the engine refuses to deploy or the surface cannot spell: an unregistered name, a `validator` or a bound with no `config`, or a name repeated on one field -> `UnsupportedFormFieldConstraintError` (ADR-0037) -- Operaton extension content the IR's discriminated unions cannot represent, such as a parameter carrying body text and a nested value at once -> `UnsupportedExtensionFormError` +- Operaton extension content in a shape the surface cannot write or the engine refuses to deploy, as ADR-0054 lists it -> `UnsupportedExtensionFormError` - an unsupported flow-element kind (pre-existing) -> `UnsupportedElementError` - an unsupported service-task execution form (pre-existing), an `operaton:type` outside `external`, `mail`, and `shell`, or a `mail`/`shell` type on a thrown message -> `UnsupportedServiceTaskFormError` (ADR-0042) A mail or shell task missing a field its behaviour's own parse requires or naming a field its behaviour class does not declare draws the same error. So does a shell task carrying a field as an expression, or spelling a flag outside `true`/`false` in any letter case. A service, send, or business rule task without a decision reference that carries `operaton:resultVariable`, or the older `operaton:resultVariableName`, beside `operaton:class` or `operaton:delegateExpression` draws it too, since `BpmnParse.parseServiceTaskLike` fails the deployment (ADR-0043). - a sequence flow's condition expression or a conditional event definition's condition carrying a `language`, which Operaton hands to a script engine rather than evaluating as the UEL expression this tool writes -> `UnsupportedConditionExpressionError` -- an `operaton:errorEventDefinition` on an external task carrying `errorRef` and no `expression`, which fails the deployment, or an `errorRef` naming no error root with a code -> `UnsupportedErrorMappingError` (ADR-0038) +- an `operaton:errorEventDefinition` on an external task carrying `errorRef` and no `expression`, which fails the deployment, or an `errorRef` naming an error root with no code -> `UnsupportedErrorMappingError` (ADR-0038; a definition with no `errorRef` is skipped and a dangling one imports as its text since ADR-0054) - a user task carrying a `bpmn:humanPerformer` beside `operaton:assignee`, or more than one `bpmn:humanPerformer`, both of which Operaton refuses to deploy -> `UnsupportedAssignmentError` (ADR-0039) - an event-based gateway carrying `operaton:asyncAfter`, which `BpmnParse.parseEventBasedGateway` refuses to deploy -> `UnsupportedEventFeatureError` (ADR-0040) @@ -65,7 +65,7 @@ Warned constructs are returned in a `warnings: ImportWarning[]` array alongside That is a bound on a type its validator refuses, a bound whose `config` is not an integer, and a literal enum default naming no value. A repeated enum value id is kept once, as the engine keeps it, and the warning names the rewrite (ADR-0037). - lanes -- a `name` on an event handler, a boundary event, a throw, an emit, or an await, since none of those has a label slot in this tool's surface; and a `name` on a start or an end whose id carries a synthesized-id prefix, which no script can spell back, so the statement that would have carried the label is left out whole +- a `name` on an event handler, a boundary event, a throw, an emit, or an await, since none of those has a label slot in this tool's surface; and a `name` on a start or an end whose id is the one the compiler mints for its container (ADR-0050), which no script can spell back, so the statement that would have carried the label is left out whole - a `bpmn:Message` or `bpmn:Signal` root that nothing in the imported process references, a receive task's `messageRef` included; and an error or escalation root carrying no code, which nothing can key it by. An error or escalation root carrying a code imports as the declaration ADR-0030 gives it, whether or not anything raises it. - `bpmn:documentation` on the definitions root, an event definition, an external task's `operaton:errorEventDefinition`, an Error, Escalation, Message, or Signal root, a multi-instance loop characteristics element, a sequence flow, or one of the three intermediate events with no label slot, since none of those has a node in the IR to hold it. @@ -76,10 +76,10 @@ Warned constructs are returned in a `warnings: ImportWarning[]` array alongside - an attribute written without a namespace that BPMN does not declare - a BPMN-declared attribute the engine itself reads nothing of, an `instantiate="true"` or an `eventGatewayType` other than `Exclusive` on a wait with several branches, or a `startQuantity` or `completionQuantity` other than `1` on an activity (ADR-0039), so the imported process runs exactly as the source document does. Each is reported only where the document set it away from the value it reads back as when nothing is written, which is why an exported gateway carrying `eventGatewayType="Exclusive"` warns about nothing. -- a `default` on a step, which the engine does read: it names the route Operaton takes when no other route out of the step is taken, and this tool carries a fallback on a split alone, so the imported process loses that route and does not run as the source document does. +- a `default` on a step naming a flow that does not leave the step, which `BpmnActivityBehavior.handleNoTransitions` finds nothing to take for; one naming a route out of the step is carried since ADR-0054 and printed as the fallback of the step's block (ADR-0053). - a `bpmn:standardLoopCharacteristics` or a `bpmn:loopCondition` on any host, which Operaton parses and never dispatches on, so the step imports and runs once exactly as the source document runs it - a `bpmn:dataObject`, a `bpmn:dataObjectReference`, or a `bpmn:dataStoreReference`, which Operaton replaces with its own variable mechanism and never reads -- `isExecutable="false"` on the process, which imports as an executable process regardless +- `isExecutable="false"` on the process, which imports as an executable process regardless; an absent one warns the same way (ADR-0049) - a `bpmn:manualTask`, which imports as a plain step, since `ManualTaskActivityBehavior` is a pass-through identical to a plain task. The warning names the rewrite rather than the drop, because the engine stores the tag it parsed as the activity type and history reports `task` where the document said `manualTask`. @@ -89,7 +89,7 @@ The lane structure goes the same way, reported lane by lane, with anything hung What is dropped without a warning is the diagram interchange data ADR-0003 settles, and an attribute the transform does not read. One shape of that is an attribute in a foreign namespace written directly on a mapped BPMN element, which is where an editor parks its own bookkeeping. The same attribute on an extension child the IR reads is reported, and so is an attribute written in no namespace at all that BPMN does not declare, since no editor writes there. -The other is a BPMN attribute the transform neither reads nor reports, such as a sequence flow's `name` or a process's `processType` or `isClosed`. +The other is a BPMN attribute the transform neither reads nor reports, such as a process's `processType` or `isClosed`; a sequence flow's `name` warns since ADR-0054. Returning `{ ir, warnings }` makes the warnings channel unignorable at the type level: every call site must destructure or explicitly discard `warnings`, so a caller cannot silently drop the diagnostics this decision exists to guarantee. The alternatives, an optional collector parameter (`xmlToIr(xml, sink?)`) or a second function (`xmlToIrWithWarnings`), leave the channel easy to skip. @@ -108,7 +108,7 @@ ADR-0025 is the decision that extended this contract to the print hop. - Good, because every caller (the CLI, the VS Code extension, the round-trip test suite) now surfaces both the refusal and the warning channel instead of only one or neither. - Good, because the shared `UnsupportedConstructError` base keeps consumer classification to one `instanceof` check as new refusal categories are added. -- Bad, because some warned items on the import hop do bear on what runs, against the boundary this decision draws, among them a dropped `operaton:field`, which leaves the bound class without a value it was injected with, a dropped `default` on a step, which takes away the route the engine falls back to, and `isExecutable="false"`, which imports as an executable process. +- Bad, because some warned items on the import hop do bear on what runs, against the boundary this decision draws, among them a dropped `operaton:field`, which leaves the bound class without a value it was injected with, and `isExecutable="false"`, which imports as an executable process; a step's `default`, once among them, is carried since ADR-0054. Refusing any of them would reject files that otherwise import cleanly, and carrying `isExecutable` through would mean an IR field, a serializer path, and a DSL surface for a flag this tool has no use for, so they are reported instead. - Bad, because every call site of `xmlToIr` had to migrate from `const ir = await xmlToIr(xml)` to `const { ir } = await xmlToIr(xml)`, a one-time, mechanical, but repo-wide edit. - Bad, because a handful of undeclared `operaton:` extension elements cannot be tied by `bpmn-moddle` to a specific owning element; their warnings are attributed to the process id rather than the precise element. @@ -132,7 +132,7 @@ Amended by ADR-0038, which adds the error mapping refusal and the warning on an Amended by ADR-0039, which adds the assignment refusal, narrows the resource-role drop to the roles the engine never reads, adds the quantity attributes to the warned list, and removes `startQuantity` from the unreported examples. Amended by ADR-0040, which adds the event-based gateway refusal above and carries a gateway's five job settings instead of warning about them. -A listener or an input/output parameter found on a gateway still warns, since no gateway kind reads either. +A listener found on a gateway still warns, and the warning names `BpmnParse.parseExecutionListenersOnScope`, which every gateway parser runs it through, since the surface has no position for it; an `operaton:inputOutput` there refuses, as `checkActivityInputOutputSupported` refuses to deploy it (ADR-0054). Amended by ADR-0041, which carries an async, exclusive, or retry setting found on a repetition's `multiInstanceLoopCharacteristics` element instead of refusing it, narrowing the `UnsupportedLoopCharacteristicsError` bullet above. @@ -140,6 +140,20 @@ Amended by ADR-0042, which adds the `UnsupportedServiceTaskFormError` and extras Amended by ADR-0043, which adds a result variable beside a class or delegate expression binding to the `UnsupportedServiceTaskFormError` bullet above. +Amended by ADR-0049, which narrows the `UnsupportedCollaborationError` bullet to two or more executable processes, warns per pool and message flow, adds `UnsupportedDocumentError` for the document shapes the engine refuses before it reads an element, reads the camunda namespace as the operaton one, and warns on an absent `isExecutable` as on a false one. + +Amended by ADR-0050, under which the label bullet's minted id is the exact `StartEvent_` or `EndEvent_` rather than a prefix, any other start or end prints under its id, an id the script cannot spell is respelled on print with a `renamedId` warning, and a form field id or an input/output parameter name the script cannot spell refuses. + +Amended by ADR-0051, under which the print hop declares `var : any` for every variable the printed script reads bare and nothing else declares, so a decompiled script builds without an undeclared-variable warning. + +Amended by ADR-0052, under which a split inside a loop body whose every route is conditioned walks the route that stays inside the body inline and prints the leaving routes alone as jumps. + +Amended by ADR-0053, under which a step's routes print as the fork block the engine runs them as, with the step's `default` as the `else`, a degraded split keeps its conditions on its jumps under a marker line, and an empty gateway, a self-loop, and a jump across a branch border each draw a print warning. + +Amended by ADR-0054, which moves the tiers above to where the engine's parse draws them: what `BpmnParse` fails the deployment on refuses naming the method, and what it deploys and this surface cannot spell warns naming the reader. +Under it the exclusive gateway shapes refuse as `UnsupportedGatewayShapeError`, and so do a condition typed through `xsi:type` as anything but `tFormalExpression`, a flow into or out of an event handler, a second plain or timer start, a repeated extension block, an io mapping on a start, a gateway, or a handler, and a priority that is not a number. +Listeners repeat per event, a step's `default` is carried, a thrown message's result variable refuses beside a class, delegate, or expression binding, and a dangling `errorRef` imports as its text. + Related decisions: ADR-0006 (the shared IR, where `warnings` deliberately lives outside the IR, which stays serializable). ADR-0007 (the Operaton moddle extension fork, whose declared and undeclared elements determine warning-attribution precision). ADR-0003 (auto-layout for diagram interchange, the one kind of element this contract drops without a warning). diff --git a/docs/decisions/0019-boundary-events-attached-to-an-activity.md b/docs/decisions/0019-boundary-events-attached-to-an-activity.md index 3681e0c..d7c58d1 100644 --- a/docs/decisions/0019-boundary-events-attached-to-an-activity.md +++ b/docs/decisions/0019-boundary-events-attached-to-an-activity.md @@ -137,7 +137,7 @@ The round-trip normalizer compensates on the comparison side instead: its canoni ### Consequences - Good, because the colon resolves the host/trigger ambiguity with no reserved word, no residual parser-generator warning, and no change to ADR-0016's soft-trigger-word design. -- Good, because the six-trigger scope admits only what Operaton's own parser accepts, so a boundary event the validator passes always deploys. +- Good, because the six-trigger scope admits only what Operaton's own parser accepts, so a boundary event the validator passes carries a trigger the engine deploys. The converse does not hold: a compensation boundary is refused on ADR-0018's attachment-mechanism grounds rather than on an engine refusal, and Operaton would deploy the document that refusal rejects. - Good, because excluding compensation costs nothing new to build: the existing `on compensation` undo block already covers the granularity a boundary compensation event would have reached, and the import-side refusal that keeps `isForCompensation` honest needed no new mechanism, only a new call site for the existing one. - Good, because the self-contained body is uniform across interrupting and non-interrupting boundaries, so there is exactly one rule to learn ("the body ends where it ends; rejoin with `goto`") instead of one rule per cancellation mode. diff --git a/docs/decisions/0021-external-tasks-as-a-service-task-binding.md b/docs/decisions/0021-external-tasks-as-a-service-task-binding.md index 9c40b24..08e10d9 100644 --- a/docs/decisions/0021-external-tasks-as-a-service-task-binding.md +++ b/docs/decisions/0021-external-tasks-as-a-service-task-binding.md @@ -70,7 +70,7 @@ A dedicated keyword would flag that operational fork at the point an author writ ## More Information The validator's zero-binding and more-than-one-binding messages name all four keys (`class`, `expression`, `delegate`, `topic`), so the fourth binding is discoverable from the error text alone. -Related decisions: ADR-0006 (engine-agnostic intermediate representation) already modeled `external` as a `ServiceTaskBinding` variant rather than its own IR node; this decision brings the DSL surface in line with that model. +Related decisions: ADR-0006 (the IR between the AST and BPMN XML, which carries Operaton's semantics under vendor-free names) already modeled `external` as a `ServiceTaskBinding` variant rather than its own IR node; this decision brings the DSL surface in line with that model. ADR-0013 (target users without BPMN knowledge and minimize boilerplate) is the standing rule this decision applies. Amended by ADR-0038, under which the `topic` binding carries the three things the engine reads beside the topic and under no other binding: a `taskPriority` setting, `property` lines, and `error when ` mappings. diff --git a/docs/decisions/0022-engine-attributes-as-named-ir-fields.md b/docs/decisions/0022-engine-attributes-as-named-ir-fields.md index 50a6598..137c8ce 100644 --- a/docs/decisions/0022-engine-attributes-as-named-ir-fields.md +++ b/docs/decisions/0022-engine-attributes-as-named-ir-fields.md @@ -72,3 +72,5 @@ The import side records what is carried and what is still dropped in `packages/t Related decisions: ADR-0006 (IR shape and naming rule, applied here), ADR-0007 (the `operaton:` moddle extension), ADR-0008 and ADR-0010 (block-structured grammar and synthesized ids, which set the gateway limit), ADR-0023 (DSL spelling for listeners). Amended by ADR-0040, which gives the four gateway kinds the `JobSettings` mixin, authored on the statement head, so the gateway limit above holds for listeners alone. + +Amended by ADR-0047, under which a timer carrier's `exclusive` is written on the `bpmn:timerEventDefinition` as well as on the node, since `BpmnParse.parseTimer` locks the timer job from the definition. diff --git a/docs/decisions/0023-listeners-on-the-attribute-block.md b/docs/decisions/0023-listeners-on-the-attribute-block.md index 040fff5..56bb119 100644 --- a/docs/decisions/0023-listeners-on-the-attribute-block.md +++ b/docs/decisions/0023-listeners-on-the-attribute-block.md @@ -83,5 +83,8 @@ A host-less handler lowers to two elements instead, a `bpmn:subProcess` and a ne The intermediate representation carries a listener as an event plus a four-way tagged binding mirroring `ServiceTaskBinding`. A `timeout` task listener also carries the timer the event layer already models. Amended by ADR-0032, which gives a listener's own brace block a field member alongside its bindings, because a field is none of the form, parameter, or nested-listener shapes this decision ruled out. +Amended by ADR-0047, under which a host-less timer handler's `jobPriority`, `retryCycle` and `exclusive` go on the trigger start event rather than the sub-process, since the timer job is that start's, and the printer lifts them back into the head while the start prints no statement. +Amended by ADR-0046, which spells the assignment event `assignment`, the word `BpmnParse.parseTaskListeners` reads, and writes every `timeout` listener with the id `BpmnParse.parseTimeoutTaskListener` requires. +Amended by ADR-0054, which drops the one-listener-per-event rule: `CoreModelElement.addListenerToMap` and `TaskDefinition.addTaskListener` both append rather than replace, so several listeners on one event run in the order they are written, and `TaskDefinition.addTimeoutTaskListener` keys each `timeout` listener by its id, so several of them each fire on their own timer. Related decisions: ADR-0016 (soft words), ADR-0017 (the timer particle clause), ADR-0019 (the hosted form), ADR-0021 (the exactly-one binding rule), ADR-0022 (engine attributes as named IR fields), and ADR-0014 (the import contract this surface honors). diff --git a/docs/decisions/0024-event-triggers-on-start-end-and-throw.md b/docs/decisions/0024-event-triggers-on-start-end-and-throw.md index 67c0f64..f550f93 100644 --- a/docs/decisions/0024-event-triggers-on-start-end-and-throw.md +++ b/docs/decisions/0024-event-triggers-on-start-end-and-throw.md @@ -145,6 +145,8 @@ ADR-0026 (task kinds on the authoring surface) supersedes the message-throw impl Amended by ADR-0034, which lifts the one-start-per-container rule for a process body and leaves it standing for a subprocess, a transaction, and an event handler. +Amended by ADR-0044, which lifts the lexical refusal of a message or signal name opening with `${`; the refusal of an expression on a message start stays, for the engine's reason. + Related decisions: ADR-0016 (soft trigger words and the `throw`/`emit` terminality rule). ADR-0017 (the payload surfaces the four start triggers reuse, including the timer mapping). ADR-0014 (the honest import contract behind every refusal here). diff --git a/docs/decisions/0025-authoring-inclusive-and-event-based-gateways.md b/docs/decisions/0025-authoring-inclusive-and-event-based-gateways.md index ca62b85..0ae5416 100644 --- a/docs/decisions/0025-authoring-inclusive-and-event-based-gateways.md +++ b/docs/decisions/0025-authoring-inclusive-and-event-based-gateways.md @@ -66,11 +66,11 @@ process expense-approval { } ``` -Operaton takes every non-default flow that has no condition or a true one (`InclusiveGatewayActivityBehavior.java:64-68`), and adds the default only when that set comes out empty (lines 71-83). +Operaton takes every non-default flow that has no condition or a true one, and adds the default only when that set comes out empty (`InclusiveGatewayActivityBehavior.execute`). The default flow is always emitted and never asked for. With an `else` it points at that branch; without one it goes straight to the join, the rule `lowerIf` already applies at the same position (`packages/transform/src/ast-to-ir.ts`). -That is a lowering rule rather than a validator rule because `parseInclusiveGateway` validates nothing (`BpmnParse.java:2105-2117`). -An all-conditional gateway without a default deploys, runs, then throws a stuck execution when nothing matches (`InclusiveGatewayActivityBehavior.java:71-75`). +That is a lowering rule rather than a validator rule because `BpmnParse.parseInclusiveGateway` validates nothing. +An all-conditional gateway without a default deploys, runs, then throws a stuck execution when nothing matches (`InclusiveGatewayActivityBehavior.execute`). A branch carrying no condition is in the taken set every time, which is why an `else` written beside one is rejected: the set never comes out empty and the fallback behind the `else` never runs. The race is a multi-branch `await`. @@ -87,11 +87,11 @@ process order-shipping { A branch is a trigger header in the payload grammar `on` and `await` share, its settings, then the body. Two branches are the minimum, enforced by the grammar as `ParallelStatement` already does (`bpmn-script.langium`). -The triggers are the four `await` already takes, which is also everything Operaton accepts in this position (`BpmnParse.java:1549-1594`). +The triggers are the four `await` already takes, which is also everything Operaton accepts in this position (`BpmnParse.parseIntermediateCatchEvent`). Reusing the word costs nothing, because `await {` cannot parse today: the `IntermediateCatchEvent` shape takes a trigger word after the keyword (`bpmn-script.langium`). The race falls through to a synthesized exclusive join, reusing `if`'s join and its pruning when every branch terminates (`pruneUnreachableJoin`, `ast-to-ir.ts`). -Operaton gives every catch event behind an event-based gateway the start behavior `CANCEL_EVENT_SCOPE` (`BpmnParse.java:1564-1566`), so exactly one branch of a race ever runs. +Operaton gives every catch event behind an event-based gateway the start behavior `CANCEL_EVENT_SCOPE` (`BpmnParse.parseIntermediateCatchEvent`), so exactly one branch of a race ever runs. An inclusive pair reuses `Gateway__fork` and `Gateway__join` unchanged, since exactly one `parallel` statement sits at any structural coordinate. The race adds one template, `Gateway__race`, whose segment word joins the reserved-name pattern. @@ -108,9 +108,9 @@ Both gateways move from a blanket refusal to a carry. An inclusive gateway carries with no exceptions, reading `name` and `default`, because Operaton refuses nothing about it. An event-based gateway carries, refusing three shapes, each following a refusal in Operaton's own parser: -- an outgoing flow whose target is not a `bpmn:intermediateCatchEvent` (`BpmnParse.java:2162`) -- a downstream catch carrying a link definition (`BpmnParse.java:1583-1585`), which `mapIntermediateCatchEvent` turns away wherever a link appears, so the gateway needs no rule of its own for it -- a downstream catch reached by more than one path (`BpmnParse.java:4371-4377`), counted over every path in +- an outgoing flow whose target is not a `bpmn:intermediateCatchEvent` (`BpmnParse.parseEventBasedGateway`) +- a downstream catch carrying a link definition (`BpmnParse.parseIntermediateCatchEvent`), which `mapIntermediateCatchEvent` turns away wherever a link appears, so the gateway needs no rule of its own for it +- a downstream catch reached by more than one path (`BpmnParse.parseSequenceFlow`), counted over every path in The last is wider than the engine's rule: Operaton lets a path through where it leaves an event-based gateway, so it takes a catch reached only by such paths, and this refuses that too. Neither that shape nor the one Operaton refuses is authorable, so the extra width costs no script anything. @@ -125,7 +125,7 @@ Since ADR-0040 a multi-branch `await` head takes the gateway's job settings, so - Bad, because a conditioned `parallel` reads as one construct but compiles to either of two BPMN elements, so a BPMN-literate reader must read every branch. - Bad, because `irToDsl` changes its return shape, touching every call site, and the drop that motivated it costs the reader a gateway's name rather than anything the process does. - Bad, because the label comes back on a BPMN-to-BPMN pass but never on a BPMN-to-DSL one, so a decompiled and recompiled document loses every gateway label. -- Bad, because a conditional branch of a race can win without waiting (`EventBasedGatewayActivityBehavior.java:30-46`), which no part of the surface shows. +- Bad, because a conditional branch of a race can win without waiting (`EventBasedGatewayActivityBehavior.execute`), which no part of the surface shows. - Bad, because an inclusive fork closed on an exclusive merge, the common hand-drawn shape, degrades to `goto`s a reader repairs by hand. ### Confirmation @@ -159,7 +159,7 @@ A Docker-gated end-to-end test shows the losing race branch canceled and the inc Related decisions: ADR-0008 and ADR-0013 set the surface and its two rules, ADR-0009 gains an inclusive pattern and a race pattern, and ADR-0010 gains one id template. ADR-0014 supplies the import contract extended here to the print hop, ADR-0016 the soft trigger words, and ADR-0017 the payload surface a race branch header reuses. ADR-0019 supplies the live-parser method, and ADR-0020 the keyword and trigger scope the race inherits. -Operaton behavior was read from `BpmnParse.java`, `InclusiveGatewayActivityBehavior.java`, and `EventBasedGatewayActivityBehavior.java` in the `operaton/operaton` repository, with line numbers as of the time of writing. +Operaton behavior was read from `BpmnParse.java`, `InclusiveGatewayActivityBehavior.java`, and `EventBasedGatewayActivityBehavior.java` in the `operaton/operaton` repository. The documented restrictions are from . The dataset behind Compagnucci, Corradini, Fornari and Re (BISE 66(1), 2024, DOI 10.1007/s12599-023-00818-7) has an event-based gateway in roughly 12 percent of its 38,863 models and an inclusive gateway in roughly 6 percent. @@ -167,3 +167,5 @@ Amended by ADR-0035, which admits a link definition on an intermediate catch and The event-based gateway's second refusal now rests on the rule that no sequence flow may enter a link catch, which the importer enforces for every flow and so for a branch of a wait as well. Amended by ADR-0040, which gives every gateway synthesized here a settings parens on its statement head, so a multi-branch `await` now refuses `asyncAfter` by validation rather than by having nothing to write it on. + +Amended by ADR-0045, which reserves the inclusive fork's default flow only beside an `else` or when every branch is conditioned; beside an unheaded branch, which the engine takes every time, none is written. diff --git a/docs/decisions/0027-repetition-on-the-authoring-surface.md b/docs/decisions/0027-repetition-on-the-authoring-surface.md index 8d9b558..80d2794 100644 --- a/docs/decisions/0027-repetition-on-the-authoring-surface.md +++ b/docs/decisions/0027-repetition-on-the-authoring-surface.md @@ -129,6 +129,7 @@ A completion condition ends a repetition after two runs, a count drives a servic ## More Information Amended by ADR-0041, which gives the clause four `run`-prefixed settings that write onto the `multiInstanceLoopCharacteristics` element itself and import an async, exclusive, or retry setting found there instead of refusing it. +Amended by ADR-0051, under which the print declares every variable it reads bare as `var : any`, so a collection iterated by name is one case of that rule rather than the only one. Related decisions: ADR-0010 (the synthesized ids a wrapping form would have had to invent). ADR-0013 (the rule that a keyword names what the author means, and the reason `sequential: true` is not one). diff --git a/docs/decisions/0028-attempt-blocks-and-the-cancel-trigger.md b/docs/decisions/0028-attempt-blocks-and-the-cancel-trigger.md index 6b28919..df91ec2 100644 --- a/docs/decisions/0028-attempt-blocks-and-the-cancel-trigger.md +++ b/docs/decisions/0028-attempt-blocks-and-the-cancel-trigger.md @@ -74,7 +74,7 @@ A handler on a block whose body holds no cancel end warns for the same reason, s `CancelEndEventActivityBehavior.execute` throws the compensation synchronously, and only its `doLeave` hands the run to the handler. - Good, because a block written with the new head and carrying no cancel anywhere is an ordinary block of work to the engine, which is what lets an imported transaction round-trip unchanged. - Good, because such a block may carry its own `on compensation`, so an enclosing scope can undo it in turn. - Operaton's `hasCompensationEventSubprocess` asks only whether the handler is a sub-process scope triggered by an event, never what tag the block it undoes carries. + Operaton's `CompensationUtil.hasCompensationEventSubprocess` asks only whether the handler is a sub-process scope triggered by an event, never what tag the block it undoes carries. - Neutral, because `method` and `protocol` are reported on import and never written back, since `parseTransaction` reads no attribute of its own and the imported process runs exactly as the source document does. - Bad, because one more ordinary English word stops being available as an identifier anywhere in a file. diff --git a/docs/decisions/0031-carry-bpmn-documentation.md b/docs/decisions/0031-carry-bpmn-documentation.md index 9747d6e..62704fa 100644 --- a/docs/decisions/0031-carry-bpmn-documentation.md +++ b/docs/decisions/0031-carry-bpmn-documentation.md @@ -73,5 +73,7 @@ A parameterized sweep on the import hop and one on the print hop each name every Amends ADR-0014, whose warned-construct list read `bpmn:documentation` on any element; the bullet now names the narrower set this decision leaves warned. +Amended by ADR-0044, under which the raw reader resolves the string escapes as a literal's reader does, so `quote()` escapes the same five characters as `quoteLiteral()` and the split between the two rests on the leading opener alone. + Related decisions: ADR-0029 (the bracket shape that keeps documentation a setting rather than a construct). ADR-0030 (the declared-name rule that puts an error or escalation code on the same quoting side as documentation). diff --git a/docs/decisions/0033-call-activity-variable-mapping.md b/docs/decisions/0033-call-activity-variable-mapping.md index 788fbb4..b34160f 100644 --- a/docs/decisions/0033-call-activity-variable-mapping.md +++ b/docs/decisions/0033-call-activity-variable-mapping.md @@ -152,6 +152,9 @@ Amended by ADR-0042, which adds `type` on a task to the binding words ADR-0032's Amends ADR-0014, whose refused-construct list read "a call activity naming a resolution shape the surface cannot write back"; the bullet now names the four shapes this decision leaves refused. +Amended by ADR-0054, under which a `calledElementBinding` word outside the four `BpmnParse.parseBinding` matches imports as `latest`, as `BaseCallableElement.isLatestBinding` reads it, and an `operaton:in`/`out` naming two shapes keeps the one `parseCallableElementProvider` reads first and warns on the rest. +Only `variables` outside `all`, an empty `source`, a source with no `target`, and a mapping of no recognized shape still refuse. + Related decisions: ADR-0029 (the bracket shape that keeps a variable mapping a setting in the parens). ADR-0032 (the field rule this spelling keeps keyed on a word alone). ADR-0022 (engine attributes as named IR fields, the precedent `CallVariableMapper` extends). diff --git a/docs/decisions/0034-multiple-start-events-on-a-process.md b/docs/decisions/0034-multiple-start-events-on-a-process.md index e8797b8..3b7d6ba 100644 --- a/docs/decisions/0034-multiple-start-events-on-a-process.md +++ b/docs/decisions/0034-multiple-start-events-on-a-process.md @@ -102,5 +102,7 @@ The validator rows in `packages/language/test/validating.test.ts` pin every posi Amends ADR-0024, whose Decision Outcome stated one start per container; that now holds for a subprocess, a transaction, and an event handler only. +Amended by ADR-0048, which mirrors `BpmnParse.selectInitial` after all: a second plain or timer start is an error, and a second message, signal, or condition start repeating an earlier one's payload is refused beside it. + Related decisions: ADR-0019 (every start wired to the virtual entry, and the synthesized-gateway corner this decision inherits). ADR-0014 (the honest import contract under which the process-level refusal is lifted). diff --git a/docs/decisions/0035-link-events-for-import-round-trip-symmetry.md b/docs/decisions/0035-link-events-for-import-round-trip-symmetry.md index 82ec762..8d442b4 100644 --- a/docs/decisions/0035-link-events-for-import-round-trip-symmetry.md +++ b/docs/decisions/0035-link-events-for-import-round-trip-symmetry.md @@ -170,4 +170,4 @@ ADR-0019 (boundary events, whose `attachedToRef` is the precedent for a node wit ADR-0025 (the race, whose branch headers refuse `link` because the engine refuses a link catch after an event-based gateway). ADR-0030 (declared codes, which a link name is not). -Engine behaviour was read from `BpmnParse.java`, `ScopeImpl.java`, `IntermediateCatchLinkEventActivityBehavior.java`, and `HistoryParseListener.java` in operaton-engine 2.1.0, with line numbers as of that version: `eventLinkTargets` and `eventLinkSources` at `BpmnParse.java:342-343`; `parseScope` calling `parseActivities` before `parseIntermediateCatchEvents` at 767-768; `parseIntermediateCatchEvent` at 1547-1600, with the event-based gateway refusal at 1582, the async continuation at 1559, and the listeners at 1594; `parseIntermediateLinkEventCatchBehavior` at 1603-1623, with the duplicate refusal at 1612 and the name warning at 1617; `parseIntermediateThrowEvent`'s early return at 1651-1657; `parseSequenceFlow`'s rewiring at 4331-4344, its invalid-source error at 4366, and the ordinary transition at 4392; `ScopeImpl.findActivityAtLevelOfSubprocess` at 91-102; `IntermediateCatchLinkEventActivityBehavior.execute` at 29-32; and `HistoryParseListener.parseIntermediateCatchEvent` at 202-207. +Engine behaviour was read from `BpmnParse.java`, `ScopeImpl.java`, `IntermediateCatchLinkEventActivityBehavior.java`, and `HistoryParseListener.java` in operaton-engine 2.1.0: the fields `eventLinkTargets` and `eventLinkSources` on `BpmnParse`; `BpmnParse.parseScope`, which calls `parseActivities` before `parseIntermediateCatchEvents`; `BpmnParse.parseIntermediateCatchEvent`, which carries the event-based gateway refusal, the async continuation, and the listeners; `BpmnParse.parseIntermediateLinkEventCatchBehavior`, which carries the duplicate refusal and the name warning; `BpmnParse.parseIntermediateThrowEvent`'s early return for a link; `BpmnParse.parseSequenceFlow`'s link rewiring, its invalid-source error, and its ordinary transition; `ScopeImpl.findActivityAtLevelOfSubprocess`; `IntermediateCatchLinkEventActivityBehavior.execute`; and `HistoryParseListener.parseIntermediateCatchEvent`. diff --git a/docs/decisions/0037-form-field-constraints-values-and-properties.md b/docs/decisions/0037-form-field-constraints-values-and-properties.md index cfbd363..b169ba7 100644 --- a/docs/decisions/0037-form-field-constraints-values-and-properties.md +++ b/docs/decisions/0037-form-field-constraints-values-and-properties.md @@ -127,5 +127,8 @@ Amends ADR-0014 (the refusal list gains `UnsupportedFormFieldConstraintError`, t Related decisions: ADR-0007 (the moddle fork, which gains the `Properties`, `Property`, `Validation`, and `Constraint` types). ADR-0022 (engine attributes as named IR fields, the typing rule the constraint name union follows). +Amended by ADR-0054, under which a literal default the field's type cannot convert (a `number` that is not an integer, a `boolean` outside `true`/`false`, an ISO date on a field naming no pattern) joins the enum default among the shapes carried as written with a warning. +The repeated-name refusal says that `DefaultFormHandler.parseValidation` deploys both. + Amended by ADR-0038, under which a `property` line is shared with an external task. There it reaches the wire as `operaton:property name=`, on a form field as `operaton:property id=`, since the engine reads the two by different attributes. diff --git a/docs/decisions/0038-external-task-extras-ride-the-topic-binding.md b/docs/decisions/0038-external-task-extras-ride-the-topic-binding.md index f20809f..bff08b7 100644 --- a/docs/decisions/0038-external-task-extras-ride-the-topic-binding.md +++ b/docs/decisions/0038-external-task-extras-ride-the-topic-binding.md @@ -125,5 +125,8 @@ Amends ADR-0037 (`property` is shared with an external task, keyed differently o Amended by ADR-0042, under which a built-in mail or shell `type` binding joins the bindings the extras are refused on in a script and warned about on import, since `parseExternalServiceTask` alone reads them. +Amended by ADR-0054, under which a definition with no `errorRef` is skipped with a warning, as `parseOperatonErrorEventDefinitions` skips it, and one whose `errorRef` names no root imports as the code spelled by the text, as every engine reader takes it. +A thrown message's extras warn naming `parseExternalServiceTask` or `parseServiceTaskLike` as their reader. + Related decisions: ADR-0007 (the moddle fork, which gains `taskPriority` on `ServiceTaskLike` and a concrete `ErrorEventDefinition` type beside the trait carrying the catch-side variables). ADR-0016 (derived root elements, which a mapping's code now feeds). diff --git a/docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md b/docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md index aac8b90..daec385 100644 --- a/docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md +++ b/docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md @@ -77,4 +77,6 @@ The import tables in `packages/transform/test/xml-to-ir.test.ts` pin the merge, Amends ADR-0014 (the refusal list gains `UnsupportedAssignmentError`; the warned list narrows the resource-assignment drop to the roles the engine never reads and gains the quantity attributes; `startQuantity` leaves the unreported list). +Amended by ADR-0054, under which an `operaton:potentialStarter` on the process is read the way this decision reads a `bpmn:potentialOwner`: its formal expression is split as `BpmnParse.parseStartAuthorization` splits it, onto `candidateStarterUsers` and `candidateStarterGroups` after the attributes' own, with one warning per element naming the rewrite. + Related decisions: ADR-0022 (engine attributes as named IR fields, where `assignee`, `candidateUsers`, and `candidateGroups` live). diff --git a/docs/decisions/0041-per-run-job-settings-on-a-repetition.md b/docs/decisions/0041-per-run-job-settings-on-a-repetition.md index d36da91..91d769f 100644 --- a/docs/decisions/0041-per-run-job-settings-on-a-repetition.md +++ b/docs/decisions/0041-per-run-job-settings-on-a-repetition.md @@ -106,6 +106,8 @@ The moddle declaration is in `packages/transform/src/operaton-moddle.json`. Amends ADR-0027, closing the gap the language README's Repetition section named. Amends ADR-0014, whose `UnsupportedLoopCharacteristicsError` bullet no longer refuses an async or retry setting on the repetition element itself. +Amended by ADR-0048, which adds the validator's job-setting pairing warning and states plainly that a repeated step's own `jobPriority` prices the per-run job, never the whole-loop one, correcting the "one job around the whole repetition" reading above. + Related decisions: ADR-0010 (the deterministic ids a repeated activity keeps). ADR-0022 (engine attributes as named IR fields, the convention this decision extends onto a second element). ADR-0029 (the one bracket shape reused here). diff --git a/docs/decisions/0042-mail-and-shell-as-a-type-binding.md b/docs/decisions/0042-mail-and-shell-as-a-type-binding.md index f6b7468..ac507ec 100644 --- a/docs/decisions/0042-mail-and-shell-as-a-type-binding.md +++ b/docs/decisions/0042-mail-and-shell-as-a-type-binding.md @@ -112,7 +112,9 @@ Amends ADR-0021, whose "exactly one of the four" binding count grows to five. Amends ADR-0032, whose field-binding rule grows from `class`/`delegate` to `class`/`delegate`/`type`. Amends ADR-0014, which narrows the `type` refusal to a value outside `external`, `mail`, and `shell`, and whose warned list gains a mail or shell task's shadowed attributes. -Related decisions: ADR-0006 (the engine-agnostic IR whose tagged union this decision extends). +Amended by ADR-0048, under which `resultVariable` beside `type:` draws a warning that nothing writes the variable, rather than being accepted and ignored. + +Related decisions: ADR-0006 (the IR between the AST and BPMN XML, which carries Operaton's semantics under vendor-free names, whose tagged union this decision extends). ADR-0013 (the minimal keyword surface a fifth key, rather than two more keywords, follows). ADR-0014 (the honest import contract behind every refusal above). ADR-0021 (folding an external topic into `service` as a fourth binding, the precedent this decision repeats for a fifth). diff --git a/docs/decisions/0043-result-variable-beside-an-expression-binding.md b/docs/decisions/0043-result-variable-beside-an-expression-binding.md index 1f662ef..d3e311b 100644 --- a/docs/decisions/0043-result-variable-beside-an-expression-binding.md +++ b/docs/decisions/0043-result-variable-beside-an-expression-binding.md @@ -82,6 +82,11 @@ The validator's refused-binding table sits beside the `type` binding tables in ` Amends ADR-0014, whose `UnsupportedServiceTaskFormError` bullet gains this case. +Amended by ADR-0048, under which `resultVariable` beside `topic:` or `type:` draws a warning that nothing writes the variable, rather than staying accepted and ignored. + +Amended by ADR-0054, under which the importer reads `operaton:resultVariableName` as `resultVariable` with a warning naming the respelling, and carries the attribute beside an external or built-in `type` binding with a warning that the binding never writes it. +On a thrown message it refuses the attribute beside `operaton:expression` too, since `parseServiceTaskLike` stores the expression's value under it and the script's throw has no slot for it. + Related decisions: ADR-0014 (the honest import contract behind the refusal). ADR-0021 (the binding list this rule reads). ADR-0042 (the `type` binding, whose parse-mirroring checks this one sits beside). diff --git a/docs/decisions/0044-raw-templates-read-and-printed-with-string-escapes.md b/docs/decisions/0044-raw-templates-read-and-printed-with-string-escapes.md new file mode 100644 index 0000000..4bb0079 --- /dev/null +++ b/docs/decisions/0044-raw-templates-read-and-printed-with-string-escapes.md @@ -0,0 +1,65 @@ +--- +status: accepted +date: 2026-09-14 +decision-makers: Marlon Kranz +--- + +# Read and print a raw template with the string escapes, either opener kept + +## Context and Problem Statement + +A quoted string whose body opens with `${` or `#{` is a raw template, the escape hatch for an expression outside the JUEL subset the grammar spells bare. +Langium's `DefaultValueConverter` converts only the terminal named `STRING`, so the `RAW_TEMPLATE` token reached the compiler with its quotes and escapes intact, and the reader that consumed it stripped the quotes and nothing else. +The printer's `quote()` escaped a quote and a backslash on the way out. +The two were not inverses: `"${fn(\"a\")}"` compiled to `${fn(\"a\")}`, which JUEL's `Scanner.nextEval` refuses as an invalid character, since a backslash has no meaning outside a string literal there, and every pass through the printer added one more backslash. +`renderRawFallback`, which prints an out-of-subset condition or cardinality on import, escaped nothing, so a body with a quote printed source the grammar could not lex. +Only `${` opened a raw template, so a `#{...}` body from a Camunda-authored document lexed as a literal and compiled to `${"#{x}"}`, a string the engine never evaluates, and the printer rewrote a leading `#{` to `${` wherever it could. +A raw template nested under an operator rendered as `${${x}}`, which `JuelExpressionManager.createValueExpression` rejects at deployment. +The importer refused a message or signal name opening with `${`, on the grounds that a quoted name reads back as an expression, although `BpmnParse.parseMessages` and `BpmnParse.parseSignals` evaluate every name through `createExpression` and the shape deploys. +How should a raw template be read, printed and nested, and which opener should it keep? + +## Decision Drivers + +- The printer's quoting and the reader's unquoting have to be exact inverses, or every round trip changes the text. +- Operaton reads `${` and `#{` the same way, in `Scanner.isEvalStart` and everywhere above it, so the surface should carry either without rewriting. +- A message name the engine evaluates is a shape ADR-0014 carries rather than refuses. + +## Considered Options + +- Read a raw template with the string escapes, accept either opener and print it back as written +- Keep the verbatim read, unescape at each consumption point, and rewrite `#{` to `${` on the way in +- Keep the verbatim read and stop the printer from escaping + +## Decision Outcome + +Chosen option: read a raw template with the string escapes, accept either opener and print it back as written, because it makes one escaper the inverse of one reader and leaves the engine's spelling alone. + +`BpmnScriptValueConverter` converts a `RAW_TEMPLATE` token with `ValueConverter.convertString`, the routine `STRING` goes through, so `RawExpr.raw` holds the unquoted body with `\"`, `\\`, `\n`, `\r` and `\t` resolved. +One escaper in `packages/transform/src/juel.ts` maps those five back, and both `quote()` and `renderRawFallback` print through it. +The terminal opens on `${` or `#{`; `renderExpression` returns a top-level raw template verbatim, and `renderRawFallback` prints a raw body with the opener it was read with. +Nothing rewrites `#` to `$` except at a `bpmn:loopCardinality`, a `bpmn:completionCondition`, a `bpmn:conditionExpression`, a `bpmn:condition`, or an `operaton:errorEventDefinition` expression: a `#{...}` body the importer can spell as bare DSL prints bare there, the compiler writes it inside `${...}`, and the import reports that. +A raw template nested under an operator splices its body in parentheses, `!"${x}"` rendering `${!(x)}`, since JUEL has no `${` token inside an expression. +A composite raw template such as `"${a} and ${b}"` has no one body to splice, and the validator refuses it under an operator. +A prose value that happens to open with either opener, a label, a map key, a form value, prints with a backslash before it, `"\${..."`, which lexes as `STRING` and reads back byte for byte. +The importer carries a message or signal name opening with `${` as written; a message start keeps its refusal, since the engine rejects an expression there. + +### Consequences + +- Good, because printing and re-parsing a raw template gives back the same text, with a quote, a backslash or a line break inside. +- Good, because a Camunda-authored `#{...}` expression imports, compiles and deploys unchanged, in every position the surface has. +- Good, because an expression name on a message end, emit, handler start or signal imports instead of refusing a document the engine deploys. +- Bad, because a backslash inside a raw template is an escape, so `\"` reaches the engine as one character; an author who wants the engine to see a backslash writes two. +- Bad, because a structured `#{...}` body in a condition or cardinality comes back inside `${...}`; the import warns about that one rewrite. + +### Confirmation + +The `renderExpression` table in `packages/language/test/parsing.test.ts` pins the converter's read, both openers and the nested splice. +`packages/transform/test/juel.test.ts` re-parses every printed raw fallback through the grammar and asserts the body it reads back. +`packages/transform/test/ir-to-dsl.test.ts` pins the escaped map key, the one-line io value, the `#{` priority and form default, and `packages/transform/test/xml-to-ir.test.ts` pins the expression names that import and the five body positions that report a rewrite. + +## More Information + +Amends ADR-0024, whose lexical refusal of a name opening with `${` is lifted; a message start keeps the refusal ADR-0024 records for the engine's reason. +Amends ADR-0031, whose account of the raw reader stripping quotes without unescaping no longer holds; the split between `quote()` and `quoteLiteral()` it decided stands, with `quoteLiteral()` escaping either opener. + +Related decisions: ADR-0014 (the import contract that carries what deploys). diff --git a/docs/decisions/0045-reserve-an-inclusive-fallback-only-where-the-engine-can-take-it.md b/docs/decisions/0045-reserve-an-inclusive-fallback-only-where-the-engine-can-take-it.md new file mode 100644 index 0000000..d97e5f8 --- /dev/null +++ b/docs/decisions/0045-reserve-an-inclusive-fallback-only-where-the-engine-can-take-it.md @@ -0,0 +1,64 @@ +--- +status: accepted +date: 2026-09-14 +decision-makers: Marlon Kranz +--- + +# Reserve an inclusive fallback only where the engine can take it + +## Context and Problem Statement + +`lowerParallel` reserved a default flow on every inclusive fork and, with no `else` written, ran it straight to the join. +`InclusiveGatewayActivityBehavior.execute` takes every non-default flow that has no condition or a true one, and adds the default only when that set comes out empty. +A branch with no head is in the set every time, so beside one the fallback can never be taken. +The printer leaves a fallback that runs straight into the merge out, so the compiled document came back without it, and once the split degraded to jumps the fallback printed as an `else`, which the validator refuses beside an unheaded branch. +The same lowering had a second gap. +`lowerWhile` wired its back-edge without the exit id the body reserved, so a `while` whose body ended in a loop left the inner gateway declaring a default flow no flow carried, and `irToXml` refused the document. +Which forks should name a fallback, and how does a reserved exit reach the back-edge? + +## Decision Drivers + +- The compiler should write no flow the engine can never take. +- The tool's own output should print back as source the validator accepts. +- A valid program must compile. + +## Considered Options + +- Reserve the fallback only beside an `else` or when every branch is conditioned, and carry the reserved exit id on every fall-through, the back-edge included +- Keep reserving the fallback on every inclusive fork and teach the printer to drop the `else` it prints for a dead one +- Elide a join that exactly one flow reaches and that carries no setting, retargeting the flow to the join's successor + +## Decision Outcome + +Chosen option: reserve the fallback only beside an `else` or when every branch is conditioned, and carry the reserved exit id on every fall-through, because it removes the dead flow where it is written and the crash with one argument. + +`lowerParallel` reserves `Flow__default` when the fork is inclusive and either an `else` branch is written or every branch carries a condition. +With an `else` the default points at that branch. +With every branch conditioned it runs straight to the join, since such a fork with no default deploys and then throws a stuck execution the first time no condition holds. +Beside an unheaded branch no default is written and the fork carries no `default` attribute. +The validator's refusal of an `else` beside an unheaded branch stands, for the same reason: the branch it would fall back from always runs. +`lowerWhile` adds its back-edge with `body.exitFlowId`, as `lowerDoWhile` already did, so a body ending in a loop keeps that loop's declared default on the flow the enclosing head takes. + +A join that exactly one flow reaches stays written. +`ExclusiveGatewayActivityBehavior.doLeave` and the parallel and inclusive behaviours run it as a pass-through, and `BpmnParse.validateExclusiveGateway` accepts its single unconditioned outgoing flow. +The printer recovers a fork whose branch terminates, and a guard clause at the tail of a loop body, from that merge: without it, `parallel { { user A } { end X } } user C` degrades to jumps and `while (x) { if (c) { end R } }` prints as `while (x) { end R }`. +The third option waits until the printer structures those shapes without the merge. + +### Consequences + +- Good, because a compiled inclusive fork beside an unheaded branch prints back without a warning and re-parses without an error. +- Good, because a `while` whose body ends in a loop compiles. +- Bad, because a join that exactly one flow reaches stays in the document, where bpmnlint reports it as superfluous and the auto-layout draws the default edge into it across the terminating branch's end event. + +### Confirmation + +`packages/transform/test/ast-to-ir.test.ts` pins the four fallback shapes in one table over `parallel { if (a > 1) { ... } ... }`, and the nested `while` with the reserved id on its back-edge through `irToXml`. +`packages/transform/test/ir-to-dsl.test.ts` pins that a modeled fallback running straight into the merge comes back as the model without that edge. +The frozen pairs `tests/golden/branch-and-race.{bpmnscript,bpmn}` and `tests/golden/gateway-settings.{bpmnscript,bpmn}` each hold an inclusive fork beside an unheaded branch with no `default` attribute. + +## More Information + +Amends ADR-0025, whose rule that the default flow is always emitted no longer holds; the `else` refusal it records stands. +Operaton behaviour was read from `InclusiveGatewayActivityBehavior.execute`, `ExclusiveGatewayActivityBehavior.doLeave` and `BpmnParse.validateExclusiveGateway`. + +Related decisions: ADR-0009 (dominator-based restructuring, which is what needs the merge), ADR-0024 (a terminating branch does not rejoin). diff --git a/docs/decisions/0046-listener-shapes-the-engine-deploys.md b/docs/decisions/0046-listener-shapes-the-engine-deploys.md new file mode 100644 index 0000000..0a9fc73 --- /dev/null +++ b/docs/decisions/0046-listener-shapes-the-engine-deploys.md @@ -0,0 +1,82 @@ +--- +status: accepted +date: 2026-09-14 +decision-makers: Marlon Kranz +--- + +# Listener shapes the engine deploys + +## Context and Problem Statement + +Deploying the `listeners` and `input-output` golden artifacts to Operaton failed on three shapes the tool wrote and the surface called legal. +`BpmnParse.parseTaskListeners` accepts the event words `TaskListener.EVENTNAME_CREATE`, `EVENTNAME_ASSIGNMENT`, `EVENTNAME_COMPLETE`, `EVENTNAME_UPDATE`, `EVENTNAME_DELETE` and `EVENTNAME_TIMEOUT`, and the assignment word is `assignment`, where the language spelled `assign` and wrote it through to `event="assign"`. +`BpmnParse.parseTimeoutTaskListener` reads the listener's `id` and adds an error when it is null, since the id is baked into the timer job's handler configuration and keys `TaskDefinition.addTimeoutTaskListener`. +The tool wrote no id, and the importer warned one away. +`BpmnParse.checkActivityInputOutputSupported` adds an error for an `operaton:inputOutput` on a `bpmn:subProcess` whose `triggeredByEvent` is `true`, where a host-less `on` handler's `input` and `output` lines lowered exactly there. +Each shape also blocked the import side: a real file carrying `event="assignment"` was refused, and a deployable file with a timeout listener rebuilt to one the engine refuses. +Which spelling, which id and which member set does the surface commit to, so that what it writes deploys and what deploys imports? + +## Decision Drivers + +- A program the validator accepts must deploy, and a document that deploys must import and rebuild to one that still deploys. +- One fact about the language lives once: the event words, the parameter hosts and the id scheme are read off one table each. +- No new key on the surface for something the engine derives, and no second spelling to document. + +## Considered Options + +- Spell the event as the engine does, mint the timeout listener's id on export, and refuse parameters on a host-less handler +- Keep `assign` on the surface and map it to `assignment` on the wire in both directions +- Give the timeout listener an `id` key and keep the parameter grant, moving the mapping onto the trigger start event + +## Decision Outcome + +Chosen option: spell the event as the engine does, mint the id on export, and refuse the parameters, because each rule then follows from one engine method and adds nothing to the surface. + +The task listener event is `assignment`, the word in `TASK_LISTENER_EVENTS`, and completion, the validator's list and the importer's refusal all read that table. +Every `timeout` task listener is written with the id `_timeout_`, `n` counting the task's timeout listeners from one in document order. +An id the document already holds is stepped past the way the synthesized root element ids are. +The importer consumes a task listener's `id` without a warning: on a timeout listener the export re-mints it, and on any other event `BpmnParse.parseTaskListener` never reads it. +An `on` handler takes no `input` or `output` line in either form. +The hosted form lowers to a boundary event and the host-less form to an event sub-process, and the engine refuses a mapping on either (`BpmnParse.parseBoundaryEvents`, `BpmnParse.checkActivityInputOutputSupported`). +The attribute block rule for a handler therefore grants no parameters, and the validator's existing parameter-host message names the kinds that take one. + +### Consequences + +- Good, because the two golden artifacts, and every program the validator accepts, deploy. +- Good, because a Modeler-authored file with an assignment listener or an id-carrying timeout listener imports without a refusal or a warning. +- Bad, because a timeout listener's authored id is replaced by the minted one on a round trip, so a document that names its timeout listeners rebuilds under the tool's names. +- Bad, because a value a host-less handler's body needs has to be mapped on a step inside the body rather than on the handler. + +### Confirmation + +`packages/language/test/validating.test.ts` pins `on assignment` accepted and `on assign` refused with the list, and a parameter on either handler form refused with the parameter-host message. +`packages/transform/test/ir-to-xml.test.ts` pins two timeout listeners on one task written as `_timeout_1` and `_timeout_2`, and the second stepping past a task already holding its id. +`packages/transform/test/xml-to-ir.test.ts` pins `event="assignment"` imported, `event="assign"` refused, and a listener id consumed without a warning. +The frozen pairs `tests/golden/listeners.{bpmnscript,bpmn}` and `tests/golden/input-output.{bpmnscript,bpmn}` carry the three shapes and import without a warning. + +## Pros and Cons of the Options + +### Spell the event as the engine does, mint the id, refuse the parameters + +- Good, because the surface has one spelling per fact and the wire carries it as written. +- Good, because the id needs no key: the engine needs one, the author never reads it, and the task id makes it readable. +- Bad, because `assignment` is the longer word, and the validator's list is what teaches it. + +### Keep `assign` and map it to `assignment` on the wire + +- Good, because the shorter word stays. +- Bad, because one event word would then have two spellings, one in the language and one in every diagram and engine log, and both would need documenting. +- Bad, because the mapping would be the one place `TASK_LISTENER_EVENTS` is not the whole truth about an event word. + +### An `id` key on the timeout listener, and the mapping on the trigger start event + +- Good, because an authored id survives a round trip. +- Bad, because `BpmnParse.parseStartEvents` calls `ensureNoIoMappingDefined` on every start event, so the nested start cannot take the mapping either. +- Bad, because the key would be the only listener setting the engine derives rather than reads, and every timeout listener would have to spell it. + +## More Information + +Amends ADR-0023, whose event list spells `assign` and whose timeout listener carries no id. +Operaton behaviour was read from `BpmnParse.parseTaskListeners`, `BpmnParse.parseTaskListener`, `BpmnParse.parseTimeoutTaskListener`, `BpmnParse.checkActivityInputOutputSupported`, `BpmnParse.ensureNoIoMappingDefined` and `TaskListener`. + +Related decisions: ADR-0010 (deterministic structural ids), ADR-0014 (the import contract), ADR-0023 (listeners on the attribute block). diff --git a/docs/decisions/0047-timer-job-settings-on-the-element-the-engine-reads.md b/docs/decisions/0047-timer-job-settings-on-the-element-the-engine-reads.md new file mode 100644 index 0000000..fcd7742 --- /dev/null +++ b/docs/decisions/0047-timer-job-settings-on-the-element-the-engine-reads.md @@ -0,0 +1,67 @@ +--- +status: accepted +date: 2026-09-14 +decision-makers: Marlon Kranz +--- + +# Timer job settings on the element the engine reads + +## Context and Problem Statement + +`BpmnParse.parseTimer` builds the job a timer creates from two places: `operaton:exclusive` on the `bpmn:timerEventDefinition` element, and the priority stored on the activity that carries the definition. +`DefaultFailedJobParseListener.parseStartEvent`, `parseBoundaryEvent` and `parseIntermediateCatchEvent` read the retry cycle off the same event element when its type is a timer. +`operaton:exclusive` on the event tag is read by `BpmnParse.parseAsynchronousContinuation` alone, which configures the async continuation job, and with no async flag there is no such job. +The tool wrote `exclusive: false` on the event tag and never on the definition, so every timer job it deployed locked exclusively whatever the script said. +On import the definition's attribute was dropped under a warning claiming the tool keeps that setting. +A host-less `on timer(...)` handler wrote `jobPriority`, `retryCycle` and `exclusive` on the event sub-process it lowers to. +Nothing that creates a timer job looks there, since the timer activity is the start event nested inside it. +Which element should each timer job setting be written on, and how does the surface keep spelling the setting once? + +## Decision Drivers + +- A setting the script writes must reach the job it names. +- The surface keeps one `exclusive` key per statement rather than one per job the element may create. +- A document the tool wrote must import back to the same script, and a foreign document must import without losing what the engine reads. + +## Considered Options + +- Write each setting on the element the engine reads it from, and lift a synthesized start's settings back into the handler head on print +- Warn on a host-less timer handler's job settings and point the author at an explicit `start` inside the body +- Give the surface a second key for the timer job's lock, beside the one for the continuation job + +## Decision Outcome + +Chosen option: write each setting on the element the engine reads it from, because it makes the documented spelling run as written with no new key and no warning. + +A node carrying a timer writes its `exclusive` on the `bpmn:timerEventDefinition` as well as on the event tag: the definition's copy locks the timer job, the tag's copy the continuation job an async flag creates. +The moddle descriptor declares the attribute on the definition, so it parses as a typed boolean and the default `true` is omitted on write. +On import the definition's value is taken and the tag's stands in where the definition carries none, so a document the tool wrote before this decision imports unchanged. +Where both are written and differ, the import keeps the definition's and warns once, naming which job each governs, since the surface has one key for both. + +A host-less `on timer(...)` lowers `jobPriority`, `retryCycle` and `exclusive` onto the trigger start event it synthesizes, and `asyncBefore` and `asyncAfter` onto the event sub-process, whose continuation job they create. +The printer lifts the three back into the `on timer` head as long as the start prints no statement of its own. +When it does print, they stay on the `start` line, so an authored start inside the body keeps its settings where it wrote them. +On import, a timer-started event sub-process's own `exclusive`, `jobPriority` or `retryCycle` is dropped with a warning, since that copy reaches only its continuation job and the head's spelling is the start's. +Every other host-less handler keeps writing its settings on the event sub-process, as before. + +### Consequences + +- Good, because `exclusive: false` on a timer start, a hosted timer, an `await timer` and a host-less timer handler now configures the job the timer creates. +- Good, because a host-less `on timer` with `jobPriority` or `retryCycle` deploys a job that carries them. +- Bad, because a timer carrier with an async flag writes `operaton:exclusive` twice, and a document that sets the two apart imports with one of them changed and a warning. + +### Confirmation + +`packages/transform/test/operaton-moddle.test.ts` pins the typed attribute on the definition and its omission at the default. +`packages/transform/test/ir-to-xml.test.ts` pins the definition and the tag both carrying the lock on a boundary timer and an await, and neither when none is written. +`packages/transform/test/xml-to-ir.test.ts` pins the definition's value on a boundary and an event sub-process start, the tag's as the fallback, the one warning when the two differ, and the warning under which a timer-started event sub-process's own timer job key is dropped. +`packages/transform/test/ast-to-ir.test.ts` and `packages/transform/test/ir-to-dsl.test.ts` pin the three settings on the synthesized start, the async flags on the sub-process, the lifted head, and the authored start keeping its own line. +The frozen pair `tests/golden/engine-attributes.{bpmnscript,bpmn}` carries the lock on the hosted timer's definition. + +## More Information + +Amends ADR-0022, whose rule that a flow node's settings are written on that node now excepts the timer job's lock, which is written on the definition too. +Amends ADR-0023, whose rule that a host-less handler's attributes go on the sub-process now excepts a timer handler's three timer job settings. +Operaton behaviour was read from `BpmnParse.parseTimer`, `BpmnParse.parseAsynchronousContinuation`, `BpmnParse.createActivityOnScope` and `DefaultFailedJobParseListener.parseStartEvent`. + +Related decisions: ADR-0017 (the timer clause), ADR-0019 (the hosted form), ADR-0040 (engine settings on synthesized gateways). diff --git a/docs/decisions/0048-mirror-the-engines-parse-time-rules-in-the-validator.md b/docs/decisions/0048-mirror-the-engines-parse-time-rules-in-the-validator.md new file mode 100644 index 0000000..19a72aa --- /dev/null +++ b/docs/decisions/0048-mirror-the-engines-parse-time-rules-in-the-validator.md @@ -0,0 +1,79 @@ +--- +status: accepted +date: 2026-09-14 +decision-makers: Marlon Kranz +--- + +# Mirror the engine's parse-time rules in the validator + +## Context and Problem Statement + +`BpmnParse` refuses a set of documents at deployment that this surface compiled without a word. +`addEventSubscriptionDeclaration` keeps one message or signal subscription per name on one scope, and one conditional start per condition text on the process. +A handler written inside a block subscribes on that block, since the event sub-process it lowers to takes the block as its event scope (`parseEventDefinitionForSubprocess`), and a handler attached to the block subscribes on the block too (`parseBoundaryEvents`), so the two collide on one name. +Every branch of an `await` block subscribes on the gateway (`parseIntermediateCatchEvent`), so two branches on one name collide as well. +`addEscalationEventDefinition` refuses an escalation catch without a code beside one with a code when both are boundary events on one host or both event sub-processes in one scope. +`selectInitial` refuses a second plain or timer start on a process, a rule ADR-0034 chose not to mirror. +The validator keyed a handler by its host name and grouped handlers by syntactic container, so none of these collisions drew a diagnostic, and the deployment failed with an engine message naming a generated id. + +Three more facts sit beside those, silent rather than refused. +`parseScopeStartEvent` reads neither `operaton:initiator` nor a start form off a start that is not the process's own, so both compiled onto a nested start and did nothing. +`DefaultFailedJobParseListener.parseActivity` stores a retry cycle only for an activity with an async flag, and `parseAsynchronousContinuation` folds `exclusive` into the async flags it sets. +`createActivityOnScope` stores a job priority that only a job declaration reads. +Each of the three keys therefore configures nothing without the job its async flag creates. +`parseServiceTaskLike` hands `resultVariable` to an `expression` binding alone, so the setting beside `topic` or `type` writes no variable. + +Which of the engine's parse-time rules should the validator repeat, and how should a setting the engine never reads be reported? + +## Decision Drivers + +- A document the validator passes should deploy, and a refusal should reach the author on the line that caused it rather than as an engine message naming a generated id. +- A setting that configures nothing is a mistake the author cannot see on the page, since the document deploys and runs. +- Every mirrored rule cites the engine method it repeats, so a reader can check it against the engine's source when the engine moves. + +## Considered Options + +- Mirror each parse-time refusal in the validator and warn on each setting the engine never reads +- Leave the refusals to the engine and document them +- Mirror the refusals and refuse the unread settings too + +## Decision Outcome + +Chosen option: mirror each parse-time refusal in the validator and warn on each setting the engine never reads. +A refusal the engine makes is a fact the page can state on the right line, and a setting that does nothing leaves the process running, which is a warning's severity. + +The validator groups handlers by the scope the engine subscribes them on: the host of a boundary event, or the container an event sub-process sits in. +Within one scope a message or signal name is refused twice among boundary and event sub-process catches, so a handler inside `Sub` and one attached to `Sub` collide; a process start of the same name is keyed apart (`hasMultipleEventDefinitionsWithSameName` compares the start flag too) and sits beside either. +A boundary on a repeated host subscribes on the multi-instance body, one scope above the host's own (`getMultiInstanceScope`), so a name inside such a host stays free. +An escalation, error, or cancel catch is compared against catches of its own kind alone, and an escalation catch-all beside a coded catch of its kind is refused. +The `on error` case stays refused as this surface's own rule, and its message says so: `addErrorEventDefinition` sorts the definitions and the runtime takes the first match. +Two branches of one `await` block on one message or signal name are refused. +On a process, a second plain or timer start, a second message or signal start of one name, and a second condition start of one rendered condition text are refused; the two warnings ADR-0034 introduced stay. +A start inside a subprocess, an attempt block, or a handler body refuses `initiator` and a `form` block. + +`retryCycle`, `exclusive`, or `jobPriority` written without `asyncBefore` or `asyncAfter` of the same spelling family draws a warning saying it configures no job; `join*` pairs with `join*` and `run*` with `run*`. +On a repeated step the step's own `retryCycle` and `exclusive` pair with `asyncBefore` and `asyncAfter`, the whole-loop job, while `runRetryCycle`, `runExclusive`, and `jobPriority` pair with `runAsyncBefore` and `runAsyncAfter`, the per-run jobs. +`jobPriority` sits with the per-run keys because `parseActivity` swaps the scope to the multi-instance body before `createActivityOnScope` stores the priority, so it lands on the inner activity and prices each run's job. +The body itself is created without one, and its job takes the process's priority. +A timer carrier is exempt for all three, since the timer job reads them off the event element (ADR-0047). +`resultVariable` beside `topic` or `type` draws the same kind of warning, since nothing writes the variable. + +### Consequences + +- Good, because a script that compiles now deploys where these rules are concerned, and the message names the scope and the engine method. +- Good, because a setting that configures nothing is reported at the key that carries it. +- Bad, because the validator restates engine rules that can move under a new engine version; each rule cites the method it mirrors so the drift can be checked. +- Neutral, because a script written before this decision with a nested start form, an unpaired job setting, or a `resultVariable` beside `topic` now draws a diagnostic where it drew none. + +### Confirmation + +`packages/language/test/validating.test.ts` holds a row per rule: a handler inside a block beside a boundary on it per trigger, the repeated-host exemption, the escalation pairs both ways and the legal mix, two race branches on one name, each duplicate start kind, `initiator` and a form on a start in each of the three containers, each job setting key with and without its flag on each carrier, and `resultVariable` beside each binding. + +## More Information + +Amends ADR-0034, which declined to mirror `selectInitial`; a second plain or timer start is now an error. +Amends ADR-0041, whose "no `runJobPriority`" rule stands while the reason moves: `jobPriority` on a repeated step prices each run's job rather than the whole loop's. +Amends ADR-0042 and ADR-0043, under which `resultVariable` beside `topic` or `type` was accepted and ignored; it now warns. +Operaton behaviour was read from `BpmnParse.addEventSubscriptionDeclaration`, `parseEventDefinitionForSubprocess`, `parseBoundaryEvents`, `getMultiInstanceScope`, `parseIntermediateCatchEvent`, `addEscalationEventDefinition`, `addErrorEventDefinition`, `parseBoundaryCancelEventDefinition`, `selectInitial`, `parseScopeStartEvent`, `parseProcessDefinitionStartEvent`, `parseStartFormHandlers`, `parseAsynchronousContinuation`, `parseActivity`, `createActivityOnScope`, `parseServiceTaskLike`, and `DefaultFailedJobParseListener.parseActivity`. + +Related decisions: ADR-0019 (boundary events), ADR-0023 (host-less handlers as event sub-processes), ADR-0025 (the `await` block), ADR-0034 (several starts on a process), ADR-0040 (job settings on gateways), ADR-0047 (timer job settings on the element the engine reads). diff --git a/docs/decisions/0049-import-modeler-shaped-documents.md b/docs/decisions/0049-import-modeler-shaped-documents.md new file mode 100644 index 0000000..d8b2f73 --- /dev/null +++ b/docs/decisions/0049-import-modeler-shaped-documents.md @@ -0,0 +1,99 @@ +--- +status: accepted +date: 2026-09-14 +decision-makers: Marlon Kranz +--- + +# Import Modeler-shaped documents as the engine deploys them + +## Context and Problem Statement + +Camunda Modeler writes a `bpmn:collaboration` with one participant for any diagram that has a pool or a lane, an `isExecutable="false"` process for every empty pool it draws, and `xmlns:camunda` on every export. +The Operaton invoice example and four of the MIWG reference files have exactly this shape, and this tool refused all of them as "multiple linked processes". +Operaton does not read the document that way. +`BpmnParse.parseRootElement` runs `parseProcessDefinitions`, which deploys every process whose `isExecutable` attribute parses as true, and then `parseCollaboration`, which records each participant's `processRef` so that `parseBPMNShape` can attach the pool's bounds to the process. +No method in `BpmnParse` reads a `bpmn:messageFlow`. +A collaboration is diagram data to the engine, and the one question that decides what runs is how many processes deploy. +An absent `isExecutable` deploys nothing: `parseProcessDefinitions` defaults it to `!deployment.isNew()`, which is false for every deployment of a new resource, while this tool imported such a process as executable and its README said the engine reads it as executable too. +The same audit found document shapes the engine refuses before `BpmnParse` sees an element. +`BpmnParse` sets `BPMN20.xsd` as the schema and `Parse.execute` runs a validating SAX parse, so a duplicate `xs:ID` fails. +`Parser.setXxeProcessing` sets `disallow-doctype-decl` unless XXE processing is enabled, so a `` fails. +This tool saw neither: moddle-xml drops an element whose id repeats or falls outside its ASCII pattern with a warning, which the importer reported as extra engine configuration or as a dangling reference on an innocent flow. +saxen skips a DOCTYPE and keeps an undeclared entity as literal text. +`BpmnParse.parseImports` fails the deployment on every `bpmn:import` but a WSDL one, and `parseSignals` fails it on two signal roots of one name. +`isSequential` and `triggeredByEvent` are typed `xsd:boolean`, so `Parse.execute`'s validating parse admits only `true`, `false`, `1` or `0` and fails the deployment on any other word, where moddle reads `yes` as false. +What should the importer do with a document whose shape the engine reads differently from this tool? + +## Decision Drivers + +- ADR-0014's contract: what the engine refuses to deploy is refused on import, what it deploys and this surface cannot spell is dropped with a warning, and nothing changes silently. +- The Modeler's default output has to import, since it is most real input. +- One fact about the engine lives in one place, in the message or the comment beside the check, naming the method that reads it. + +## Considered Options + +- Import the one executable process, warn per pool and message flow, refuse the document shapes the engine refuses +- Keep refusing every collaboration and every multi-process document +- Write the pool back on export + +## Decision Outcome + +Chosen option: import the one executable process, warn per pool and message flow, refuse the document shapes the engine refuses, because it is the only option under which the importer accepts what the engine deploys and nothing else. + +`selectProcess` imports the one process marked `isExecutable="true"` and pushes one warning per other process, naming it and that `parseProcessDefinitions` does not deploy it either. +Two or more executable processes refuse with `UnsupportedCollaborationError`, which names that one shape alone. +Several processes of which none is executable refuse with `UnsupportedDocumentError`, since the engine deploys none of them. +A lone process without the attribute imports with a warning that says the engine skips it in a new deployment and that this tool writes it back as executable. +`warnCollaborationDrops` pushes one warning per participant, saying whether it names the imported process, a skipped one, or none, and one per message flow with its ends; both say the process runs identically and the document written back has no pool. +Lanes keep their existing warnings. +An empty process refuses with `UnsupportedEventFeatureError`, naming that `BpmnParse.parseStartEvents` fails the deployment on a process or subprocess with no start event. + +The camunda namespace is read as the operaton one by replacing the URI in the document text before `moddle.fromXML`, with one warning per document. +`BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` falls back to the camunda URI only when the operaton lookup finds nothing, so the engine reads a `camunda:taskListener` exactly as an `operaton:taskListener`. +moddle-xml binds a prefix to a package by URI, not by spelling, so after the swap every `camunda:` element arrives typed `operaton:*` and every `camunda:` attribute reads through the one `operaton:` reader. +A second moddle package would key every consumption table twice, and a prefix rewrite could hit a prefix spelled inside attribute text; the URI cannot appear there. + +`refuseDocumentShapes` runs on the raw text before parsing, on a copy with comments and CDATA sections cut out, since a script body may hold `&name;` or ``, an entity XML does not predefine, and an `isSequential` or `triggeredByEvent` value outside `true` and `false`, naming `Parse.execute`'s validating parse against `BPMN20.xsd` as the reason. +After parsing, a moddle warning for a duplicate or an illegal id refuses with the id named and the rename as the remedy, before any reference could dangle on it. +A `bpmn:import` refuses naming its type, and two signal roots of one name refuse naming both. +The DOCTYPE, entity, boolean, id, and import refusals are one `UnsupportedDocumentError`, so the CLI exits 1 for them like for every other refusal. + +### Consequences + +- Good, because the Modeler's default collaboration file, the Operaton invoice example, and the MIWG files with one executable process import. +- Good, because a file the engine refuses at schema validation or at the DOCTYPE no longer imports, prints, and rebuilds into a different document that deploys. +- Good, because a process the engine would not deploy is named in a warning rather than turned into one that runs. +- Bad, because the document written back has no pool, so a file that opened in the Modeler as a pooled diagram opens as a plain process after the round trip. +- Bad, because an id the schema admits and the engine deploys, such as one with an umlaut, is refused until renamed, since moddle-xml cannot read it. + +### Confirmation + +The table `xmlToIr: the document level` in `packages/transform/test/xml-to-ir.test.ts` pins every import row with its whole warning list and every refusal with its class and detail. +The camunda rows sit in the `camunda: prefix alias` describe of the same file. + +## Pros and Cons of the Options + +### Import the one executable process, warn per pool and message flow, refuse the document shapes the engine refuses + +- Good, because the import accepts exactly what the engine deploys. +- Bad, because the pool is lost on the round trip, and the warning has to say so. + +### Keep refusing every collaboration and every multi-process document + +- Good, because nothing is dropped. +- Bad, because the refusal blocks most real input on a shape the engine ignores, and its message called a lone pool "multiple linked processes". + +### Write the pool back on export + +- Good, because the round trip would keep the diagram's frame. +- Bad, because it needs a `bpmn:collaboration`, a participant shape whose bounds enclose every laid-out element, and a plane over the collaboration, none of which `bpmn-auto-layout` produces, for a frame the engine never reads. + +## More Information + +The document-level checks sit in `refuseDocumentShapes`, `refuseDroppedIds`, `refuseImports`, `selectProcess`, and `warnCollaborationDrops` in `packages/transform/src/xml-to-ir.ts`; the namespace swap sits at the top of `xmlToIr`. + +Amends ADR-0014, whose `UnsupportedCollaborationError` bullet narrows to two or more executable processes and whose `isExecutable="false"` bullet gains the absent attribute. + +Related decisions: ADR-0003 (diagram interchange is regenerated, which is why the pool has no export side). +ADR-0014 (the honest import contract behind every refusal and warning here). diff --git a/docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md b/docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md new file mode 100644 index 0000000..fbcb9b6 --- /dev/null +++ b/docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md @@ -0,0 +1,103 @@ +--- +status: accepted +date: 2026-09-14 +decision-makers: Marlon Kranz +--- + +# Reserve the minted start and end ids exactly, and respell an id the script cannot spell + +## Context and Problem Statement + +The compiler mints an implicit start and end for every container it lowers: `StartEvent_` and `EndEvent_`, plus `EndEvent_` for a boundary escape, resolved against a process-wide taken set (ADR-0010). +The printer leaves those out again, since the compiler re-derives them, and the validator refuses a statement written under one, since the compiler would mint it a second time. +Both sides read the prefixes `StartEvent_` and `EndEvent_` for that, not the ids the compiler mints. +The Modeler names its default start `StartEvent_1`, and 25 of 44 real exported diagrams sampled from public example repositories carry that id. +Under the prefix rule such a start is either left out of the script with its label and its `initiator`, or printed and refused when the script is read back. +Renaming it is not a fix the user can apply without cost: the engine keys history (`HistoricActivityInstance.getActivityId`), migration plans (`MigrationPlanBuilder.mapActivities`) and a modification (`InstantiationBuilder.startBeforeActivity`) on the activity id. + +A second defect sits one step over. +A BPMN id is an `xs:ID`, and the script's name slot is the `ID` terminal: letters, digits and `_`, a `-` between them, and no keyword. +An id such as `Task.1`, `user`, `Review-` or `WFP-6-` (the MIWG reference models) is a legal id the script cannot spell, and the printer wrote it verbatim into source that does not parse, with no warning. +Only error and escalation codes went through a minting step. +A form field id or an `operaton:inputParameter` name outside the terminal is the same defect one level down: `input my.param = ...` does not parse either. + +What should the validator reserve, what should the printer elide, and what should happen to an id the script cannot spell? + +## Decision Drivers + +- The goldens: every start and end the compiler mints is of the exact form `StartEvent_`, `EndEvent_` or `EndEvent_`, so a rule that names those forms keeps every golden byte-identical. +- ADR-0014's contract: a document that deploys imports, and every change to what runs is reported. +- An activity id is the key the engine's history and migration match on, so a changed id is a changed process, and the report has to say so. +- One fact, one home: the shape of a name is the `ID` terminal, and the transform spells it once for both directions. + +## Considered Options + +- Reserve the exact minted ids and respell an unspellable id on print, with one warning per rename +- Keep the prefix rule and rename a Modeler default on import +- Refuse every id the script cannot spell + +## Decision Outcome + +Chosen option: reserve the exact minted ids and respell an unspellable id on print, because the exact forms are the only ids the compiler mints and the respelling keeps every document importable while reporting the one change it makes. + +The validator reserves, for each statement, the exact `StartEvent_` and `EndEvent_` of the container `c` whose body holds it: the process, or the nearest `subprocess` or `attempt` block. +The message says the compiler generates that id for the container's implicit start or end. +The prefix patterns for gateways, flows, throws, catches, boundaries and event sub-processes stay as they were. +`StartEvent_1` is an ordinary name. +A handler body's own implicit start and end, and a boundary escape's end, are minted off an id no author writes, so the validator reserves those three forms by prefix instead: `StartEvent_EventSubProcess_`, `EndEvent_EventSubProcess_` and `EndEvent_Boundary_`. + +The printer recognizes a minted start or end by the same exact forms, plus `EndEvent_` for a boundary in the same container and the `Throw_` prefix an unnamed throw lowers to. +Only those are left out; a start or end under any other id prints with its label, its documentation and its initiator. + +The printer builds one map per print, from the process id and every element id that prints, to the name each is written under. +An id the `ID` terminal accepts maps to itself. +Any other maps to the id with every non-word character replaced by `_`, a leading `_` where the result would open on a digit or read as a keyword, resolved against every id in the document and every name minted before it. +Every site that writes an id reads the map: the process head, every statement head, a `goto`, a boundary's host, and a named throw or catch. +A gateway, a boundary and an event sub-process never write their id and keep it. +Each rename draws one `renamedId` warning naming the printed name and saying that the rebuilt document carries it as the activity id. + +A form field id or an `operaton:inputParameter`/`operaton:outputParameter` name outside the terminal refuses the import. +The engine sets the variable under that exact name (`FormFieldHandler.handleSubmit`, `InputParameter.execute`, `OutputParameter.execute`), so a minted name would change which variable is set rather than how it is written. + +### Consequences + +- Good, because a Modeler default diagram imports, prints and rebuilds with its start's id, label and initiator intact. +- Good, because a MIWG reference model prints as source that parses and validates, with one report per id it could not keep. +- Good, because every golden is byte-identical: the exact forms are the ones the compiler already wrote. +- Bad, because a respelled id is a changed activity id in the rebuilt document, so a process rebuilt from such a script is not the process the history and migration APIs know; the warning says so, and the remedy is a rename in the model. +- Bad, because a statement written under another container's minted id, such as `user EndEvent_S` beside `subprocess S`, is legal and makes the compiler mint `EndEvent_S_2` for `S`, which the printer then writes as an ordinary `end EndEvent_S_2`; the round trip stays stable from the second print on, and nothing runs differently. + +### Confirmation + +The reserved-name table in `packages/language/test/validating.test.ts` pins the exact rule per container and the freed `StartEvent_1`. +The respelling table in `packages/transform/test/ir-to-dsl.test.ts` prints every id site under a minted name, asserts the whole warning list, and re-parses and validates the source; the Modeler-default row asserts the printed start, end and initiator with no warning. +`packages/transform/test/synthesize-ids.test.ts` pins the exact predicates and the minting scheme. +The refusal matrix in `packages/transform/test/xml-to-ir.test.ts` pins the two import refusals. +The golden pair suites pass unchanged. + +## Pros and Cons of the Options + +### Reserve the exact minted ids and respell an unspellable id on print + +- Good, because it matches what the compiler mints, and no golden changes. +- Good, because every legal document prints as source that compiles. +- Bad, because an author can write a name the compiler would otherwise have minted for a sibling container, and the compiler takes the next free suffix rather than refusing. + +### Keep the prefix rule and rename a Modeler default on import + +- Good, because the printer and validator stay as they were. +- Bad, because the rename changes the activity id of the one element every Modeler diagram has, and a rule that renames a default is a rule that renames most imports. + +### Refuse every id the script cannot spell + +- Good, because nothing prints under a name the model does not carry. +- Bad, because the MIWG reference models and any diagram with a dotted or hyphen-terminated id would not import at all, although the engine deploys them. + +## More Information + +The exact predicates and the minting scheme sit in `packages/transform/src/synthesize-ids.ts`; the name map is built by `printedNames` in `packages/transform/src/ir-to-dsl.ts`; the per-container check is `checkReservedNames` in `packages/language/src/bpmn-script-validator.ts`; the two import refusals go through `refuseUnspellableVariable` in `packages/transform/src/xml-to-ir.ts`. + +Amends ADR-0010, whose reserved-pattern list narrows to the prefixes above and the exact minted forms. + +Related decisions: ADR-0014 (the honest import contract behind the refusals and the warning). +ADR-0036 (where an elided end prints under its id, which this decision leaves as it is). diff --git a/docs/decisions/0051-synthesize-variable-declarations-on-print.md b/docs/decisions/0051-synthesize-variable-declarations-on-print.md new file mode 100644 index 0000000..7366028 --- /dev/null +++ b/docs/decisions/0051-synthesize-variable-declarations-on-print.md @@ -0,0 +1,94 @@ +--- +status: accepted +date: 2026-09-14 +decision-makers: Marlon Kranz +--- + +# The print declares every variable it reads bare, typed any + +## Context and Problem Statement + +A `var x: number` line in the header declares a variable so the validator can check its uses, and nothing else. +BPMN has no slot for it, the compiler writes none, and the process the engine runs is the same with or without it. +The print used to derive a declaration only for a bare `for each ... in ` collection, and relied on form fields and catch bindings, which the XML does carry, to type everything else. +A variable read in a condition, a count, an `until`, an error mapping's `when` or an `in` mapping came back undeclared, so the script `bpmns parse` wrote warned the moment it was built. +That was every decompiled example, the Operaton invoice example, and 779 of the programs a fuzz run put through the round trip. +The golden fixtures avoided it by declaring every condition variable on a start form, which is a workaround rather than a contract. +Where should the declarations a rebuilt script needs come from? + +## Decision Drivers + +- The script the tool prints from a document should build without a warning about the tool's own output, since a warning there names nothing the author can fix. +- One fact, one home: which positions read a variable and which constructs type one are the validator's rule and its symbol table. + The print should mirror them rather than keep a list of its own. +- A form field and a catch binding already carry a type through the XML, so a second carrier for the same fact would be a second thing to reconcile. + +## Considered Options + +- Declare on print every variable the body reads bare, typed `any` +- Carry `var` lines through the XML in an extension element +- Keep declaring collections alone + +## Decision Outcome + +Chosen option: declare on print every variable the body reads bare, typed `any`, because the print already knows every position it writes a variable in. +A third-party document never carries a declaration, so the derivation is needed whatever the XML holds. + +The header gets one `var : any` line per name the body reads in a position the script writes bare and nothing types, in first-appearance order over one fixed walk of the model. +The positions are the ones the validator reads a variable at. +They are a flow's condition, a loop's count and completion condition, a conditional trigger, an error mapping's condition, an `in` mapping's source, and a bare collection. +A body prints bare only when it fits the expression subset, so only those bodies contribute names; a raw `"${...}"` body is opaque to the validator and to the print alike. +The typed set is the validator's symbol table, `DefaultVariableSymbolProvider.collect`. +It holds every form field, every catch binding, every input and output parameter name, every loop element variable, and the four counters `MultiInstanceActivityBehavior` sets once anything repeats. +`externalTask` inside a mapping and the source of an `out` mapping are left out for the reasons the validator leaves them out. +The one is resolved on the external task's execution, the other in the called process. +The type is `any` because a read says nothing more. +Every declaration of a name has to agree on its type, so a line for a name a form already types would be an error rather than a duplicate. + +The walk reads the model, not the print. +A condition the print had to drop still declares its variable, since the model reads it, the drop is reported beside it, and the declaration is what the author repairing the drop by hand needs. + +### Consequences + +- Good, because a decompiled script builds clean, the invoice example included. +- Good, because the document the engine sees is unchanged: nothing new is written to the XML. +- Bad, because a `var x: boolean` comes back as `var x: any`, so the type an author wrote is lost on the way through the XML. + A fixture that needs the type back declares the variable on a form, whose type the XML carries. +- Bad, because the positions and the typed set are spelled in the language package and mirrored in the transform package. + A position or a symbol source added on one side has to be added on the other. + The mirror is stated in one comment on each side, and the round-trip suites turn red on the first program that drifts. +- Neutral, because a variable a dropped condition read is declared and never read in the printed body. + +### Confirmation + +The row under "the header declares every variable the body reads bare" in `packages/transform/test/ir-to-dsl.test.ts` prints one process reading a variable at every position and typing one through every source. +It pins the whole header and an empty diagnostic list on the re-parse. +The two-pass table in `tests/round-trip-constructs.test.ts` requires an empty diagnostic list on every printed script, and its last row is a condition variable coming back declared and re-printing byte for byte. + +## Pros and Cons of the Options + +### Declare on print every variable the body reads bare, typed `any` + +- Good, because it works for a document nobody wrote in this language, which carries no declaration to read back. +- Good, because it adds nothing to the XML and needs no reader. +- Bad, because the type is lost, and a name can only be declared as `any`. + +### Carry `var` lines through the XML in an extension element + +- Good, because the type would survive the round trip. +- Bad, because an extension element the engine ignores would be a second home for a fact the form and catch bindings already carry. + The importer would have to reconcile the two when they disagree. +- Bad, because a third-party document never carries it, so the derivation on print is needed all the same. + +### Keep declaring collections alone + +- Good, because nothing changes. +- Bad, because nearly every decompiled program warns on rebuild, about the tool's own output. + +## More Information + +The walk is `variableDecls` in `packages/transform/src/ir-to-dsl.ts`. +The validator's positions are in `checkExpression` in `packages/language/src/bpmn-script-validator.ts` and its symbol table in `packages/language/src/variable-symbol-provider.ts`. + +Related decisions: ADR-0027 (the collection declaration this generalizes, and the seeded loop counters). +ADR-0014 (the honest import contract the clean rebuild belongs to). diff --git a/docs/decisions/0052-loop-bodies-with-conditioned-exits.md b/docs/decisions/0052-loop-bodies-with-conditioned-exits.md new file mode 100644 index 0000000..c6523cb --- /dev/null +++ b/docs/decisions/0052-loop-bodies-with-conditioned-exits.md @@ -0,0 +1,91 @@ +--- +status: accepted +date: 2026-09-14 +decision-makers: Marlon Kranz +--- + +# A split inside a loop body continues at the loop head when a route stays inside + +## Context and Problem Statement + +The catalog in ADR-0009 folds an exclusive split into an `if` chain when it has a clean join, a post-dominator the split dominates, and into a guard clause when exactly one route is unconditioned, that route being the continuation. +A split inside a loop body has neither when every route carries a condition. +A route that leaves the loop puts the split's immediate post-dominator outside it, at the graph's exit, or at a node behind the loop when the leaving route and the loop exit share an end, so there is no clean join, and with no unconditioned route there is no guard clause. +Every route then printed as a jump. +The approve-review loop of the Operaton invoice example is this shape: `approveInvoice` splits under `${approved}` to the bank transfer, which leaves the loop, and under `${!approved}` to `reviewInvoice`, which runs into the "review successful" gateway that routes back under `${clarified}`. +The printer recognized the `do ... while (clarified)` head, printed both routes as `goto`, hoisted `reviewInvoice` out of the loop after the process's end, and had no name for its edge into the folded loop gateway, so the edge was dropped with a marker. +`ExclusiveGatewayActivityBehavior.doLeave` takes the first route whose condition holds, so the model is a live loop and the rebuilt process ended after the review. +The same shape with a default flow in place of `${!approved}` printed correctly through the guard clause, which was the asymmetry to remove. +Where should a split whose every route is conditioned continue when it sits inside a loop body or a branch? + +## Decision Drivers + +- The totality guarantee in ADR-0009: every route keeps a form, and a jump is the fallback for what the catalog cannot fold, never the first answer for a shape it can. +- A modeler draws a condition on every route and names no default, so the shape is the common one, and the invoice example the thesis demonstrates on is an instance of it. +- The guard clause already continues a split at a node the split does not dominate; the rule should extend that rather than add a second continuation mechanism. + +## Considered Options + +- Continue at the enclosing construct's stop node when a route stays inside it +- Keep every such split as jumps and report it +- Recover the loop body as a region of its own before matching the split + +## Decision Outcome + +Chosen option: continue at the enclosing construct's stop node when a route stays inside it, because the construct being printed already knows where its region ends, and the dominator queries the clean join uses answer whether a route reaches it. + +`emitRoutes` asks for the join in three steps: the clean join, then the guard clause, then the enclosing continuation. +The clean join is refused when the enclosing construct's stop node does not post-dominate it. +Inside a `do` body the body dominates every node behind the loop, so a join behind the loop passes the dominance checks whenever the leaving route and the loop exit share an end, and without the refusal the staying route would be walked past the printed loop head and the back edge dropped. +The enclosing continuation is the stop node the enclosing construct handed down. +It answers only while that node is defined, no route is unconditioned, and at least one route's target is dominated by the split and post-dominated by the stop node. +The chain then walks that route inline, since `branchStaysInRegion` already accepts a target the join post-dominates, and prints the routes that leave the region as jumps. +The walk stops at the stop node, where the enclosing construct carries on. +With an unconditioned route the guard clause still answers first, and outside any enclosing construct the jumps stay, so a split at process level whose every route is conditioned prints as before. + +The chain closes with no `else`, so the recompiled split gains a fallback into the join that the model never named; the print reports it, as it does for the same chain at process level. +The loop gateway's own exit condition, `${!clarified}` in the example, prints as the fall-through after `} while (clarified)` and is reported as dropped. + +The `review-loop` golden pair freezes the invoice example's shape. +Its split carries a default flow to the join, which the compiled else-less chain gives it and which a hand-drawn original lacks, so the modeler's shape, with a condition on every route and no default anywhere, is pinned in the goto-fallback suite. + +### Consequences + +- Good, because the invoice example prints its review call inside the loop and the rebuilt process keeps the loop. +- Good, because a cascade of such splits inside one body prints as nested chains, each continuing at the same stop node. +- Good, because the rule reads the model through the dominator queries the clean join already uses and adds no state to the walk. +- Neutral, because a route running straight into the stop node prints as an empty branch, which the validator warns about; the condition on it survives, where a jump would have lost the branch. +- Bad, because a branch that ends inside a loop body, a `do { end } while`, and a jump into a race branch still print as jumps or markers, and stay reported as they are. + +### Confirmation + +The table in `packages/transform/test/ir-to-dsl.test.ts` under "a split inside a loop body whose every route is conditioned" pins the printed source for one and for two staying routes, and for a leaving route that shares its end with the loop exit, and the whole warning list. +`tests/goto-fallback.round-trip.test.ts` pins the modeler's shape through the XML hop: no marker, and the real-node reachability of the recompiled print equal to the import's plus the two pairs the invented fallback adds. +The `review-loop` round-trip suite pins the frozen artifact byte for byte, its import without a warning, and the chain inside the `do` body. + +## Pros and Cons of the Options + +### Continue at the enclosing construct's stop node when a route stays inside it + +- Good, because the stop node is already threaded through every emitter, so the rule is one arm in `emitRoutes` and one predicate beside `guardClauseContinuation`. +- Good, because the same rule serves a split inside a `while` body, a `do` body, and a branch. +- Bad, because a target the stop node post-dominates but the split does not dominate is walked inline by the chain all the same, the way the guard clause walks it, so a target reached from elsewhere leaves its other edges as jumps. + +### Keep every such split as jumps and report it + +- Good, because nothing changes and the print reports the drop. +- Bad, because the shape is the common one, and the process the thesis demonstrates on came back running differently. + +### Recover the loop body as a region of its own before matching the split + +- Good, because a region tree would answer the question for every construct at once. +- Bad, because it is the RPST decomposition ADR-0009 left for later, and the scope does not yet justify it. + +## More Information + +The predicate is `enclosingContinuation` in `packages/transform/src/ir-to-dsl.ts`; the containment check is in `cleanJoin` beside it. + +Amends ADR-0009, whose catalog gains the enclosing continuation beside the clean join and the guard clause. + +Related decisions: ADR-0009 (the catalog this rule extends). +ADR-0036 (the inline terminal in a guard clause, whose region test the chain reuses). diff --git a/docs/decisions/0053-routes-off-a-step-and-jumps-with-conditions.md b/docs/decisions/0053-routes-off-a-step-and-jumps-with-conditions.md new file mode 100644 index 0000000..57d55ae --- /dev/null +++ b/docs/decisions/0053-routes-off-a-step-and-jumps-with-conditions.md @@ -0,0 +1,106 @@ +--- +status: accepted +date: 2026-09-15 +decision-makers: Marlon Kranz +--- + +# A step's routes print as the fork the engine runs, and a degraded split keeps its conditions on its jumps + +## Context and Problem Statement + +A BPMN step may leave on several sequence flows, and the catalog in ADR-0009 printed them as an `if` chain, a choice that takes one of them. +The engine takes more than one. +`BpmnActivityBehavior.performOutgoingBehavior` leaves a step by every flow whose condition holds or that carries none, and by the flow the step names as `default` only when none was taken, so two plain flows are a parallel fork and a conditioned flow beside a plain one is an inclusive fork, the rule `InclusiveGatewayActivityBehavior.execute` applies to a gateway of that kind. +The printer had the exact forms, `parallel { { B } { C } }` and `parallel { if (ok) { B } { C } }`, and wrote `if (true) { B } else { C }` with a report instead. +The import now carries a step's `default` (`defaultFlowId` on every activity kind), which the report used to say it could not see. + +Three neighbouring shapes had no report or a poor one. +A fork or race the catalog cannot fold printed every route as an unweighed jump, `if (true) { goto A } else if (true) { goto B }`, so the conditions the routes carried were discarded and the script held no marker for the reader to find. +A parallel, inclusive or event-based gateway with no outgoing flow printed nothing, although the inclusive one throws `stuckExecutionException` in the model and the event-based one waits forever. +A gateway whose route returns to itself matched the loop patterns with the exit route as the body, so `while (go) { user A }` came out of a model that loops on the gateway until `go` holds and runs `A` once. +A jump written into a branch of an `await` or `parallel` block from outside it is refused by the validator and nothing in the print said so. +A guard clause whose branch opens on a second split dropped the edge into that split with a marker, because the branch walk admitted only terminals as guard-clause entries and a jump cannot name a gateway. + +## Decision Drivers + +- The totality guarantee in ADR-0009: every route keeps a form, and a jump is the fallback for what the catalog cannot fold, never the first answer for a shape it can. +- A report describes what runs differently and nothing else; a print that runs the same as the model draws none. +- What the marker and the warnings channel say has to be enough to start hand-repair, so a lost split names itself in the source. + +## Considered Options + +- Print a step's routes through the fork block with the step as the split, and keep the conditions on the jumps of a degraded split +- Keep the `if` chain for a step's routes and the unweighed jumps, and sharpen the reports +- Refuse a step with several routes on import + +## Decision Outcome + +Chosen option: print a step's routes through the fork block with the step as the split, and keep the conditions on the jumps of a degraded split, because both are exact forms the printer already had, and the engine's rule for a step is the inclusive fork's rule. + +`followLinear` reads the step's routes off the model. +Two or more hand the step to the fork printer as its split: inclusive when any route carries a condition or the step names a `default`, parallel otherwise, with the `default` as the block's `else`. +The one shape a choice routes the same way, exactly one weighed route beside the `default`, keeps the `if`/`else` the choice prints, which reads better and lowers to the same routing. +The fork printer is keyed on a split's id and kind rather than on a gateway, so a step's block takes the merge's `join` settings and no head settings of its own. +A step whose fallback carries a condition is reported the way an inclusive fork's is: the engine skips the fallback while weighing and takes it when nothing held, so the condition is weighed nowhere and the run is the same without it. +A step whose every route is weighed and which names no fallback draws the invented-fallback report a split of that shape draws. + +A fork or race the catalog cannot fold prints one marker line naming the split and its kind, `split G degraded to jumps; was inclusive`, and then the `if` chain of jumps with each route's condition on its own jump and the split's fallback as the `else`. +The report says the conditions are on the jumps and that the split's kind, whether it opened one branch, every branch whose condition held, or the first to resolve, is written nowhere. +An exclusive split with no clean join prints its jumps as it did, with no marker: an `if` chain of jumps is a choice, so nothing of it is lost. + +A gateway with no route out prints nothing and is reported by kind: a parallel gateway ends the run as the script's block end does (`ParallelGatewayActivityBehavior.execute` leaves by no route and `PvmExecutionImpl.leaveActivityViaTransitions` ends the execution); an inclusive gateway stops the model with an error (`InclusiveGatewayActivityBehavior.execute` throws `stuckExecutionException`); an event-based gateway holds the model forever (`EventBasedGatewayActivityBehavior.execute` is a wait state with nothing to wait for); an exclusive gateway keeps the model from deploying (`BpmnParse.validateExclusiveGateway`), which the import refuses ahead of the printer, so the print report covers hand-built IR alone. + +The loop patterns ignore a back edge whose source is its target. +The gateway then prints as a choice whose route back into itself has no name, so it takes the dropped-edge marker and its report, and the exit route keeps its condition. + +The branch walk records the `await` or `parallel` branch each statement is printed in, as the path of branch ids from the outermost block down, and every jump records the path it was printed under. +Once the walk is done, a jump whose target sits in a branch the jump's path does not begin with draws a report under `refusedStatement`: the validator refuses a `goto` into a branch from outside it, since a branch's steps run only when the whole block is reached. +The jump is written all the same; the model is where the route comes from. + +A guard clause admits a gateway the split dominates as its branch entry. +A jump could not name it anyway, and the branch walk sorts the gateway's routes with the guard's continuation as their stop node, so a split whose every route is weighed inside a guarded branch continues at that node the way ADR-0052 describes, and a loop that opens a guarded branch prints as the loop it is. + +An event sub-process a flow edge leads into is malformed IR and the printer throws, as it does on a duplicate id; the import refuses the flow ahead of it. + +### Consequences + +- Good, because a step with two plain routes or with a weighed route beside a plain one round-trips to the routing the engine runs, with no report. +- Good, because the conditions of a degraded split survive on its jumps, and the marker line puts the reader at the split rather than leaving the jumps to explain themselves. +- Good, because the fork printer, the invented-fallback report and the fallback-condition report each have one home, and a gateway and a step share it. +- Neutral, because a step whose routes reconverge at a merge of another kind, or at a step, degrades to jumps under the marker: the block form synchronizes at a merge of its own kind, which the model does not have there. +- Bad, because a branch that ends inside a loop body, a `do { end } while`, and a loop gateway the compiler leaves without an incoming flow still print as jumps, and a post-test loop whose body ends inside a branch prints the jump into that branch the validator refuses; these are reported and pinned as they are. + +### Confirmation + +The table in `packages/transform/test/ir-to-dsl.test.ts` under "a step whose own routes split" pins the printed source, the whole warning list, the re-parse and the real-node reachability for two plain routes, two plain routes into an implicit merge, a weighed route beside a plain one, one weighed route beside the fallback, a weighed route beside a plain one and the fallback, and two weighed routes with no fallback. +Beside it, one row pins the marker line and the weighed jumps of an inclusive fork whose merge a boundary chain also enters, one row per gateway kind pins the empty-split report, two rows pin the self-looping gateway, two the jump across a branch border, and one the guard clause opening on a split. +`tests/goto-fallback.round-trip.test.ts` runs the composite shapes a fuzz run drew through compile, export, import and print, and pins each print's warning categories and whether it validates. + +## Pros and Cons of the Options + +### Print a step's routes through the fork block with the step as the split, and keep the conditions on the jumps of a degraded split + +- Good, because the fork block already prints both kinds and both fallback reports, so the step gains them by naming itself as the split. +- Good, because a jump chain that weighs its routes is the same chain the choice prints, so the degraded print gains no new form. +- Bad, because a step whose routes merge at a merge of another kind now prints jumps under a marker where it printed a readable `if` chain, which was a choice the model is not. + +### Keep the `if` chain for a step's routes and the unweighed jumps, and sharpen the reports + +- Good, because nothing in the print changes. +- Bad, because the print of a common Modeler shape, a task with two plain flows, runs one route where the model runs both, and the exact form was one line away. + +### Refuse a step with several routes on import + +- Good, because the printer never sees the shape. +- Bad, because the engine deploys and runs it, and the import contract in ADR-0014 refuses only what the engine refuses or what changes a stored value. + +## More Information + +The step's routing is `followLinear` in `packages/transform/src/ir-to-dsl.ts`; the fork block is `emitFork`, the jumps `emitJumps`, the empty-split report `emptySplitWarning`, the branch bookkeeping `emitBranch` and the check at the end of `Emitter.emit`, and the guard-clause entry `branchStaysInRegion`. + +Amends ADR-0009, whose catalog entry for a statement with more than one route out becomes the fork block, and whose degraded split keeps its conditions on the jumps under a marker line. + +Related decisions: ADR-0009 (the catalog this rule extends). +ADR-0014 (the import contract, whose `default`-on-a-step clause this rule closes). +ADR-0045 (the fallback the compiler reserves on a fork, which the printed block of a step's routes reads back through). +ADR-0052 (the enclosing continuation, which a split inside a guarded branch now reaches). diff --git a/docs/decisions/0054-import-tiers-follow-the-engine-parse.md b/docs/decisions/0054-import-tiers-follow-the-engine-parse.md new file mode 100644 index 0000000..b4a1de6 --- /dev/null +++ b/docs/decisions/0054-import-tiers-follow-the-engine-parse.md @@ -0,0 +1,146 @@ +--- +status: accepted +date: 2026-09-15 +decision-makers: Marlon Kranz +--- + +# Import tiers follow the engine's parse + +## Context and Problem Statement + +ADR-0014 draws two tiers: what changes execution refuses, what does not warns. +It left the placement of each construct to the reader of the IR, and an audit of the importer against `BpmnParse` found the two disagreeing in both directions. +The importer refused what the engine deploys: a second listener on one event, a listener naming two bindings, a text beside a nested parameter value, an unknown binding word on a call activity, an `operaton:in` naming two source shapes, a throw whose `errorRef` names no root, an intermediate throw with no definition. +It carried what the engine refuses: an exclusive gateway with two plain routes, an `operaton:inputOutput` on a start event, a second `operaton:formData` on one task, a `jobPriority` of `1.5`, a sub-process with no start, two plain starts on the process. +And it dropped what the engine reads with a note that this tool "keeps" the very category being dropped: an execution listener on a gateway, an `operaton:in` on a thrown signal, a `formKey` on the process's start, a step's `default`. +Where should each tier sit, and what must a warning say when the dropped setting is one the engine runs? + +## Decision Drivers + +- ADR-0014's own claim: no silent semantic loss, and no refusal of a file the engine deploys. +- The engine's parser is the one reader whose verdict decides whether a document runs, so it is the one reference for where a tier sits. +- A warning that names what the engine does with the dropped setting is the only form under which a reader can tell a cosmetic drop from one that changes a run. +- The Camunda Modeler's default output, and the Operaton invoice example, have to import. + +## Considered Options + +- Draw each tier where `BpmnParse` draws it, and name the engine method in every refusal and every drop of a setting the engine reads +- Keep the tiers as the surface drew them and correct the wording alone +- Refuse every setting the engine reads that this surface cannot spell + +## Decision Outcome + +Chosen option: draw each tier where `BpmnParse` draws it, because it is the only option under which the importer accepts exactly what the engine deploys and says what the engine does with what it drops. + +The rule has three clauses. +A shape `BpmnParse` fails the deployment on refuses, and the message names the method and quotes its sentence, since there is nothing that runs to write back. +A shape the engine deploys and this surface can spell is carried, with a warning where the spelling changes. +A shape the engine deploys and this surface cannot spell drops with a warning that names the method that reads it and says the document written back runs without it. +It refuses only where the drop changes a value the engine stores, which is a thrown message's result variable beside an expression binding. + +The rows below are the tier moves, one per shape, with the engine method that decides it. + +| Shape | Engine method | Was | Now | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `operaton:async="true"` on a job-setting owner | `BpmnParse.isAsyncBefore` | dropped as unread, with a warning | carried as `asyncBefore`, warning names the respelling | +| `jobPriority` or `taskPriority` neither an integer nor an expression | `BpmnParse.parsePriority` | carried as written | refused | +| `operaton:resultVariableName` | `BpmnParse.parseResultVariable` | dropped with a warning | carried as `resultVariable`, warning names the respelling | +| `resultVariable` beside an `external` or built-in `type` binding on a task | `BpmnParse.parseServiceTaskLike` | carried in silence | carried, warning says the binding never writes it | +| `operaton:decisionRefTenantId` | `BpmnParse.parseTenantId` | dropped with a warning | refused, as `calledElementTenantId` is | +| `calledElementBinding` or `decisionRefBinding` outside the four words | `BpmnParse.parseBinding`, `BaseCallableElement.isLatestBinding` | refused | carried as `latest`, warning names the rule | +| `operaton:in`/`out` naming two shapes (`source` beside `sourceExpression`, `variables="all"` beside either, a `businessKey` beside them, a second `businessKey`) | `BpmnParse.parseCallableElementProvider`, `parseInputParameter`, `setBusinessKeyValueProvider` | refused | the engine's pick carried, one warning per loser | +| `operaton:potentialStarter` on the process | `BpmnParse.parseStartAuthorization` | dropped as residual extension content | read onto the candidate starter lists, warning names the rewrite | +| A literal form default the field's type cannot convert | `LongFormType.convertValue`, `BooleanFormType.convertValue`, `DateFormType` | carried in silence | carried, warning says the printed script draws an error there | +| An empty `name`, `label`, `defaultValue`, or `operaton:assignee` | read as the empty text | read as absent | carried as `""`, so the second compile writes the same attribute | +| A script body holding three consecutive backticks | none; no fence can enclose it | printed into a script that does not parse | refused | +| A thrown message's `resultVariable` beside `class` or `delegateExpression` | `BpmnParse.parseServiceTaskLike` | dropped with a warning | refused | +| A thrown message's `resultVariable` beside `expression` | `BpmnParse.parseServiceTaskLike` into `ServiceTaskExpressionActivityBehavior` | dropped with a warning | refused; the engine stores the value and the throw has no slot | +| A thrown message's `resultVariable` beside `external` or no binding | `BpmnParse.parseServiceTaskLike` | dropped under a note that this tool keeps it | dropped, warning says no binding writes it | +| A thrown message's `taskPriority`, `operaton:properties`, `operaton:errorEventDefinition`, or injected field | `BpmnParse.parseExternalServiceTask`, `parseServiceTaskLike` | dropped under the same note | dropped, warning names the reader | +| A dangling `errorRef` on a catch | `BpmnParse.parseBoundaryErrorEventDefinition`, `parseErrorStartEventDefinition` | imported as a catch-all | imported as the code spelled by the text, with a warning | +| A dangling `errorRef` on an error end | `BpmnParse.parseEndEvents` | refused | imported as the code spelled by the text, with a warning | +| A dangling `escalationRef` on a catch | `BpmnParse.createEscalationEventDefinitionForEscalationHandler` | imported as a catch-all | refused | +| An absent or dangling `escalationRef` on a throw | `BpmnParse.findEscalationForEscalationEventDefinition` | imported as a catch-all | refused | +| An `operaton:errorEventDefinition` with no `errorRef` | `BpmnParse.parseOperatonErrorEventDefinitions` | refused | skipped with a warning, as the engine skips it | +| An `operaton:errorEventDefinition` with a dangling `errorRef` and an `expression` | `BpmnParse.parseOperatonErrorEventDefinitions` | refused | imported as the code spelled by the text, with a warning | +| `operaton:initiator` or `operaton:formData` on a start that is not the process's own | `BpmnParse.parseScopeStartEvent`, `parseStartFormHandlers` | carried | dropped, warning names the reader that never runs there | +| An intermediate throw with no event definition | `BpmnParse.parseIntermediateThrowEvent` into `IntermediateThrowNoneEventActivityBehavior` | refused | imported as a plain step, warning names the rewrite | +| `operaton:inputOutput` on a start event, a gateway, or an event handler | `BpmnParse.ensureNoIoMappingDefined`, `checkActivityInputOutputSupported` | dropped with a warning, or carried | refused | +| `operaton:outputParameter` on an end event | `BpmnParse.checkActivityOutputParameterSupported` | dropped with a warning | refused | +| A second `operaton:inputOutput`, `formData`, `properties`, or `failedJobRetryTimeCycle` on one element | `Element.elementNS` | first kept, rest warned | refused | +| A second listener on one event | `CoreModelElement.addListenerToMap`, `TaskDefinition.addTaskListener`, `addTimeoutTaskListener` | refused | carried in document order | +| A listener naming two or more bindings | `BpmnParse.parseExecutionListener`, `parseTaskListener` | refused | first in the engine's order kept, one warning per loser | +| An execution listener with `expression=""`, a task listener with `class=""`, `expression=""`, or `delegateExpression=""` | `ExpressionExecutionListener`, `ExpressionTaskListener`, `ClassDelegateTaskListener`, `DelegateExpressionTaskListener` | refused as naming no binding | carried, warning says the printed script draws an error there | +| An execution listener with `class=""` or `delegateExpression=""` | `BpmnParse.parseExecutionListener` | refused as naming no binding | refused quoting "cannot be empty" | +| A timer under a task listener that is not a `timeout` | `BpmnParse.parseTaskListener` | refused | dropped with a warning | +| Body text beside one nested value in a parameter | `BpmnParseUtil.parseNestedParamValueProvider` | refused | nested value kept, text warned | +| An exclusive gateway with no route, one conditioned route, a conditioned default, or a plain non-default route | `BpmnParse.validateExclusiveGateway` | imported and printed as `if (true)` | refused as `UnsupportedGatewayShapeError` | +| A condition typed through `xsi:type` as anything but `tFormalExpression` | `BpmnParse.parseConditionExpression` | imported | refused | +| A condition or completion condition body with no `${` or `#{` opener, or an empty one | `UelExpressionCondition.evaluate`, `MultiInstanceActivityBehavior.completionConditionSatisfied` | carried in silence | carried inside `${...}`, or as no condition when empty, with a warning | +| A sequence flow's `name` differing from the label the rebuilt document derives | none; the engine routes by condition | dropped in silence | dropped with a `label` warning | +| A `bpmn:subProcess` with no start event | `BpmnParse.parseStartEvents` | imported with a start added | refused | +| A `bpmn:process` with no start event | `BpmnParse.parseStartEvents` | imported with a warning | refused | +| A `bpmn:transaction` with no start event | `SubProcessActivityBehavior.execute` | imported with a start added, in silence | imported with a start added, with a warning | +| Two or more plain or timer starts on the process | `BpmnParse.selectInitial` | imported | refused | +| A sequence flow into or out of an event handler | `BpmnParse.parseSequenceFlow` | caught only through the element's own `incoming`/`outgoing` children | refused off the flow's ends | +| A `default` on a step naming one of its routes | `BpmnActivityBehavior.handleNoTransitions` | dropped with a warning | carried; a `default` naming a foreign flow still drops with a warning | +| A collection named in both spellings with different shapes | `MultiInstanceActivityBehavior.resolveNrOfInstances` | BPMN spelling kept | the expression kept, warning names the rule | +| A `language` on a `bpmn:completionCondition` | `BpmnParse.parseMultiInstanceLoopCharacteristics` | dropped in silence | dropped with a warning | + +Three rules stand behind the rows. + +Listeners repeat per event. +`CoreModelElement.addListenerToMap` and `TaskDefinition.addTaskListener` both append rather than replace, so several `start` listeners run in document order. +`BpmnParse.parseTaskListeners` routes a `timeout` listener to `TaskDefinition.addTimeoutTaskListener`, which keys it by its id, so two `timeout` listeners with distinct ids and timers are the ordinary reminder-then-escalate shape. +The validator's one-listener-per-event error goes, the importer keeps document order as the IR order, and a listener naming two bindings keeps the first in the order `parseExecutionListener` and `parseTaskListener` read them: `class`, `expression`, `delegateExpression`, then the `operaton:script` child. + +A thrown message is a service task to the engine. +`parseIntermediateThrowEvent` and `parseEndEvents` hand a message definition to `parseServiceTaskLike`, so off the definition the engine reads every binding word, the injected fields, the result variable, the priority, and the error mappings. +This surface's throw carries the binding alone, so each of the rest drops with a warning naming the reader. +The result variable is the exception: beside a `class` or `delegateExpression` the engine refuses it, and beside an `expression` the engine stores it where the throw cannot spell it, so both refuse. + +A drop of what the engine reads names its reader. +`warnUnimportedSetting` says that this tool reads no such setting on the owner's tag and that the document written back carries none, and where the engine does read it there, the sentence continues with the method and says the document written back runs without it. +The note that listed what this tool keeps is gone, since it named the very setting being dropped whenever that setting fell in one of its categories. + +### Consequences + +- Good, because a document the engine deploys imports, and one it refuses is refused with the engine's own sentence rather than imported into a script that cannot deploy. +- Good, because every warning on a setting the engine reads says which method reads it, so the reader knows whether the drop changes a run. +- Good, because the Modeler's default export and the invoice example, both refused before, import. +- Bad, because the engine's sentences are quoted in the importer and drift with the engine's wording. +- Bad, because a shape the engine deploys and then fails at run time, such as an empty listener `class` on a task, is carried into a script that draws an error at the step, which the reader has to repair by hand. + +### Confirmation + +The refusal and warning tables in `packages/transform/test/xml-to-ir.test.ts` pin every row above with the whole warning list or the refusal class and detail, and the audit reproductions each row came from are the fixtures. +`tests/review-loop.round-trip.test.ts` and the invoice fixture in `tests/` pin the Modeler-shaped documents end to end. + +## Pros and Cons of the Options + +### Draw each tier where `BpmnParse` draws it + +- Good, because the engine is the one reader whose verdict decides what runs, so the tiers stop being a second opinion. +- Bad, because every row needs the engine method read before its sentence is written. + +### Keep the tiers as the surface drew them and correct the wording alone + +- Good, because no import result changes and no test moves. +- Bad, because the importer would keep refusing files the engine deploys and carrying files it refuses, which ADR-0014 does not license. + +### Refuse every setting the engine reads that this surface cannot spell + +- Good, because nothing the engine runs is ever dropped. +- Bad, because a listener on a gateway or a `formKey` on a process start would refuse every Modeler export that carries one, and the drop costs the reader a warning, not a run. + +## More Information + +The checks sit in `packages/transform/src/xml-to-ir.ts`: `checkExclusiveGateways`, `checkStartEventCount`, `checkHandlerFlows`, `attachDefaultFlows`, `refuseIoMapping`, `onlyExtensionElement`, `resolveListenerBinding`, `readThrownMessageBinding`, and the `ENGINE_READS_ELSEWHERE` table behind `warnUnimportedSetting`. +The consumer-facing summary is the import contract section of `packages/transform/README.md`. + +Amends ADR-0014, whose `UnsupportedExtensionFormError`, `UnsupportedErrorMappingError`, and step-`default` bullets and whose gateway-listener line point here. +Amends ADR-0023 (listeners repeat per event), ADR-0033 (the call-mapping shapes that warn rather than refuse), ADR-0037 (form defaults the type cannot convert are carried), ADR-0038 (a thrown message's extras and the error-mapping shapes), ADR-0039 (an `operaton:potentialStarter` reads like a `bpmn:potentialOwner`), and ADR-0043 (the result-variable rules on a task and on a thrown message). + +Related decisions: ADR-0049 (the document-level refusals and the collaboration, decided on the same reading of the engine). +ADR-0048 (the validator's mirror of the same parse-time rules on the compile side). +ADR-0053 (how a carried step `default` prints). diff --git a/docs/glossary.md b/docs/glossary.md index 590003b..b4e083a 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -35,7 +35,7 @@ Terms that show up across the READMEs, the ADRs, and the code. The parser's structured, in-memory representation of a source file, with nodes for each `process`, `user`, `service`, `if`, `while`, `parallel`, and so on. - **IR**: intermediate representation. A small set of plain objects (`packages/transform/src/ir/types.ts`) that every transform reads from or writes to, in both directions. - See [ADR-0006](decisions/0006-engine-agnostic-intermediate-representation.md). + See [ADR-0006](decisions/0006-intermediate-representation-between-ast-and-bpmn.md). [ADR-0022](decisions/0022-engine-attributes-as-named-ir-fields.md) and [ADR-0023](decisions/0023-listeners-on-the-attribute-block.md) extend that shape to the engine's execution settings, input/output parameters, and lifecycle listeners. - **Validator**: checks that a parsed process is structurally sound and reports errors in the editor. The full list of checks is in [packages/language/README.md](../packages/language/README.md#diagnostics). diff --git a/examples/README.md b/examples/README.md index f56a776..ae6bf6d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -50,8 +50,8 @@ Running `bpmns build` on any of them produces the deployable `.bpmn`. ### Testcontainers harness -Fifteen E2E test files in `tests/e2e/` use [testcontainers-node](https://testcontainers.com/) to start the Docker image, deploy compiled BPMN over the Operaton REST API, start instances, and assert engine behavior: `invoice-approval`, `parallel-approval`, `loan-approval`, `loan-approval-kopp`, `boundary-events` (over `order-handling`), `awaiting-confirmation`, `engine-extensions`, `service-boundary-and-compensation` (over `charge-with-recovery` and `compensating-saga` in one container boot), `event-positions` (over `order-intake`, `stock-alert`, `scheduled-audit`, `support-ticket`, and `order-rework` in one container boot), `task-kinds`, `repetition` (over `batch-approval` and `empty-batch` in one container boot), `booking-attempt`, `branch-and-race` (over `order-dispatch`), `forms-and-external-tasks` (over `card-charge` and `plan-selection` in one container boot), and `job-settings-and-mail` (over `nightly-report` and `outage-notice` in one container boot). -The remaining fixtures are demo-only. +Sixteen E2E test files in `tests/e2e/` use [testcontainers-node](https://testcontainers.com/) to start the Docker image, deploy compiled BPMN over the Operaton REST API, start instances, and assert engine behavior: `invoice-approval`, `parallel-approval`, `loan-approval`, `loan-approval-kopp`, `boundary-events` (over `order-handling`), `awaiting-confirmation`, `engine-extensions`, `service-boundary-and-compensation` (over `charge-with-recovery` and `compensating-saga` in one container boot), `event-positions` (over `order-intake`, `stock-alert`, `scheduled-audit`, `support-ticket`, and `order-rework` in one container boot), `task-kinds`, `repetition` (over `batch-approval` and `empty-batch` in one container boot), `booking-attempt`, `branch-and-race` (over `order-dispatch`), `forms-and-external-tasks` (over `card-charge` and `plan-selection` in one container boot), `job-settings-and-mail` (over `nightly-report` and `outage-notice` in one container boot), and `deploy-sweep`. +`deploy-sweep` deploys every golden under `tests/golden/`, every fixture under `tests/fixtures/`, and every example here in one container boot, once as written and once rebuilt from the tool's own print of it. The compensation half of `service-boundary-and-compensation` asserts that the `emit compensation` its `on error` handler raises reaches the undo block of the subprocess that completed before the charge failed. `booking-attempt` asserts the same machinery on the other route into it: when the block is given up, the engine must have run the undo block of the step that had already finished before the cancel handler opens. diff --git a/examples/spring-boot/processes/loan-approval.bpmnscript b/examples/spring-boot/processes/loan-approval.bpmnscript index c41318b..9171cf0 100644 --- a/examples/spring-boot/processes/loan-approval.bpmnscript +++ b/examples/spring-boot/processes/loan-approval.bpmnscript @@ -1,5 +1,4 @@ process loan-approval( - label: "Loan Approval", documentation: "Decides a consumer loan request. Small loans are screened automatically and only the ones the screening does not clear reach a loan officer.", historyTimeToLive: "P180D", candidateStarterUsers: "demo", diff --git a/examples/spring-boot/processes/order-rework.bpmnscript b/examples/spring-boot/processes/order-rework.bpmnscript index 8199acb..4b0fed7 100644 --- a/examples/spring-boot/processes/order-rework.bpmnscript +++ b/examples/spring-boot/processes/order-rework.bpmnscript @@ -6,7 +6,7 @@ // Both steps are user tasks assigned to "demo", so this process deploys and // runs on the demo profile without additional Java classes. -process order-rework(label: "Order Rework") { +process order-rework { var approved: boolean start Received diff --git a/packages/cli/README.md b/packages/cli/README.md index 546f99a..3d8a994 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -25,14 +25,21 @@ bpmns parse invoice-approval.bpmn -o invoice-approval.bpmnscript ``` `build` writes BPMN 2.0 XML with generated diagram interchange data. +When the layout library cannot place the graph, `build` writes the document without diagram data instead, prints `Warning: no diagram could be drawn for this process (...); the file deploys but opens without shapes in a modeler` to stderr, and still exits `0`, since Operaton deploys such a file unchanged (`BpmnParse.parseDiagramInterchangeElements` reads a diagram only when one is present). `parse` writes `.bpmnscript` source. Exit codes are `0` for success, `1` for validation or parse errors, `2` for I/O errors. Both directions report non-fatal warnings on stderr without changing the exit code, so an undeclared variable reference on compile, or a dropped Operaton extension attribute or lane on import, is warned about but still produces a file. `parse` reports what the print hop could not carry into the script as well: some of those warnings bear on what a recompiled document runs, and some on whether the script builds back at all, so read them before running `build` on the output. +After those warnings, `parse` also re-validates the script it just wrote and reports every error `build` would draw from it, still exiting `0` and still writing the file. The roundtripping section of the root [README](../../README.md#roundtripping-bpmn-xml---bpmnscript) walks through both hops. A BPMN construct the DSL cannot express is the exception: `parse` exits `1` with a message naming the construct and writes nothing. +Both subcommands read a file from disk; neither one reads stdin. +An `-o` equal to the input path always exits `2` before anything is touched, `--force` included: there is no output left to write to once it's the same file as the source. +An existing output file at a different path is left alone unless you pass `--force`; without it, the command exits `2` instead of overwriting it. +An `-o` that names an existing directory writes into it under the input's own basename. + The package is not meant to be imported programmatically. Import `@bpmn-script/transform` for the transforms and `@bpmn-script/language` for the Langium services. diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 225ad7a..d0be0bd 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -1,25 +1,28 @@ -import { - createBpmnScriptServices, - BpmnScriptLanguageMetaData, -} from '@bpmn-script/language'; +import { BpmnScriptLanguageMetaData } from '@bpmn-script/language'; import type { Model } from '@bpmn-script/language'; -import { NodeFileSystem } from 'langium/node'; -import { URI } from 'langium'; import chalk from 'chalk'; import * as fs from 'node:fs/promises'; import * as fsSync from 'node:fs'; import * as path from 'node:path'; -import { astToIr, irToXml } from '@bpmn-script/transform'; -import { CLI_VERSION, diagnosticMessage, resolveOutputPath } from './util.js'; +import { astToIr, irToXml, LayoutError } from '@bpmn-script/transform'; +import { + CLI_VERSION, + SEVERITY_ERROR, + SEVERITY_WARNING, + buildDocument, + diagnosticMessage, + formatDiagnostic, + guardOutputPath, + refuseDirectoryInput, + resolveOutputPath, +} from './util.js'; export type BuildOptions = { output?: string; + force?: boolean; }; -const SEVERITY_ERROR = 1; -const SEVERITY_WARNING = 2; - export async function buildAction( fileName: string, opts: BuildOptions, @@ -30,30 +33,28 @@ export async function buildAction( console.error(chalk.red(`Error: file not found: ${fileName}`)); process.exit(2); } + refuseDirectoryInput(resolvedInput, fileName); + // The document loader below picks its language service by extension, so a + // wrong one cannot merely warn: it fails a few lines later with an internal + // Langium message ("service registry contains no services"). const extensions: readonly string[] = BpmnScriptLanguageMetaData.fileExtensions; if (!extensions.includes(path.extname(resolvedInput))) { console.error( - chalk.yellow( - `Warning: expected a file with one of these extensions: ${extensions.join(', ')}`, + chalk.red( + `Error: expected a file with one of these extensions: ${extensions.join(', ')}`, ), ); + process.exit(2); } const outPath = resolveOutputPath(resolvedInput, '.bpmn', opts.output); - - const services = createBpmnScriptServices(NodeFileSystem).BpmnScript; + guardOutputPath(resolvedInput, outPath, opts); let document; try { - document = - await services.shared.workspace.LangiumDocuments.getOrCreateDocument( - URI.file(resolvedInput), - ); - await services.shared.workspace.DocumentBuilder.build([document], { - validation: true, - }); + document = await buildDocument(resolvedInput); } catch (err) { console.error( chalk.red( @@ -69,16 +70,22 @@ export async function buildAction( if (errors.length > 0) { console.error(chalk.red('Validation errors:')); for (const diag of errors) { - console.error( - chalk.red( - ` line ${diag.range.start.line + 1}: ${diagnosticMessage(diag)}` + - ` [${document.textDocument.getText(diag.range)}]`, - ), - ); + console.error(chalk.red(formatDiagnostic(document, diag))); } process.exit(1); } + // A comment-only or blank source parses without error into a model with no + // processes; catching that here, on the parsed model, keeps this message + // for every such case instead of leaking astToIr's own internal wording. + // Checked only once the document is error-free, so a keyword typo (which + // also yields zero Process nodes) reports its real parser error instead. + const ast = document.parseResult?.value as Model; + if (ast.processes.length === 0) { + console.error(chalk.red('Error: the file has no process')); + process.exit(1); + } + const warnings = (document.diagnostics ?? []).filter( (d) => d.severity === SEVERITY_WARNING, ); @@ -90,33 +97,30 @@ export async function buildAction( ); } - const ast = document.parseResult?.value as Model; - let ir; try { ir = astToIr(ast); } catch (err) { - console.error( - chalk.red( - `Error: AST to IR conversion failed: ${(err as Error).message}`, - ), - ); + console.error(chalk.red(`Error: ${(err as Error).message}`)); process.exit(1); } - let xml; + let xml: string; try { - xml = await irToXml(ir, { - sourceFileName: path.basename(resolvedInput), - exporterVersion: CLI_VERSION, - }); + xml = await irToXml(ir, { exporterVersion: CLI_VERSION }); } catch (err) { + if (!(err instanceof LayoutError)) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + // The document itself is fine (Operaton deploys it); only the auto-layout + // step failed, so the file is written without a diagram instead of lost. + xml = err.xml; console.error( - chalk.red( - `Error: IR to XML conversion failed: ${(err as Error).message}`, + chalk.yellow( + `Warning: no diagram could be drawn for this process (${err.message}); the file deploys but opens without shapes in a modeler`, ), ); - process.exit(1); } try { diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 7c760a5..7466675 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -19,6 +19,7 @@ export default function (): void { '-o, --output ', 'output .bpmn file path (default: same dir, same basename)', ) + .option('--force', 'overwrite an existing output file') .description('Compile a .bpmnscript source file to BPMN 2.0 XML') .action(buildAction); @@ -29,6 +30,7 @@ export default function (): void { '-o, --output ', 'output .bpmnscript file path (default: same dir, same basename)', ) + .option('--force', 'overwrite an existing output file') .description('Decompile a BPMN 2.0 XML file to .bpmnscript DSL') .action(parseAction); diff --git a/packages/cli/src/parse.ts b/packages/cli/src/parse.ts index dac9b65..5cf91af 100644 --- a/packages/cli/src/parse.ts +++ b/packages/cli/src/parse.ts @@ -11,12 +11,63 @@ import { UnsupportedElementError, } from '@bpmn-script/transform'; import type { ImportWarning, PrintWarning } from '@bpmn-script/transform'; -import { resolveOutputPath } from './util.js'; +import { + SEVERITY_ERROR, + buildDocument, + formatDiagnostic, + guardOutputPath, + refuseDirectoryInput, + resolveOutputPath, +} from './util.js'; export type ParseOptions = { output?: string; + force?: boolean; }; +// The one message `moddle-xml`'s reader gives when the root element does not +// resolve to any registered type, bpmn:Definitions included: it never says +// what it found (`moddle-xml`'s `Reader`, root-handler branch). +const WRONG_ROOT_MESSAGE = 'failed to parse document as '; +const BPMN_NAMESPACE = 'http://www.omg.org/spec/BPMN/20100524/MODEL'; + +/** The tag name and, where present, the xmlns declaration on that same tag. */ +function describeRoot(xml: string): string { + const tag = /<([\w:]+)/.exec(xml)?.[1]; + if (tag === undefined) return ''; + // A greedy `[^>]*` backtracks from the tag's end, so it lands on the LAST + // xmlns declared on the root, not the one for the root's own prefix. + const colon = tag.indexOf(':'); + const decl = colon === -1 ? 'xmlns' : `xmlns:${tag.slice(0, colon)}`; + const uri = new RegExp(`<${tag}[^>]*?\\s${decl}="([^"]+)"`).exec(xml)?.[1]; + const found = uri === undefined ? '' : `; found ${decl}=${uri}`; + return ` (root element is <${tag}>; expected in namespace ${BPMN_NAMESPACE}${found})`; +} + +/** + * `saxen`'s parse errors quote the unparsed remainder of the document, which + * for a large or binary file is megabytes on stderr, control characters + * included when the remainder holds any. Cut at the first line (the + * remainder trails after a real newline in the message), cap and drop + * control characters from what's left, then add whatever root-mismatch + * detail applies. + */ +function readableParseError(message: string, xml: string): string { + const firstLine = message.split('\n')[0]; + const capped = ( + firstLine.length > 200 ? firstLine.slice(0, 200) + '...' : firstLine + ).replace(/\p{C}/gu, ''); + // The root-mismatch hint is added after capping: it is bounded by the + // document's own tag and namespace text, not by the remainder a malformed + // document can dump into the message this replaces. + return capped === WRONG_ROOT_MESSAGE ? capped + describeRoot(xml) : capped; +} + +/** The first 40 characters with control characters stripped, for previewing a non-XML file without dumping it. */ +function printablePreview(text: string): string { + return text.slice(0, 200).replace(/\p{C}/gu, '').slice(0, 40); +} + export async function parseAction( fileName: string, opts: ParseOptions, @@ -27,8 +78,10 @@ export async function parseAction( console.error(chalk.red(`Error: file not found: ${fileName}`)); process.exit(2); } + refuseDirectoryInput(resolvedInput, fileName); const outPath = resolveOutputPath(resolvedInput, '.bpmnscript', opts.output); + guardOutputPath(resolvedInput, outPath, opts); let xml: string; try { @@ -40,6 +93,23 @@ export async function parseAction( process.exit(2); } + // `trim`/`trimStart` treat a leading BOM as whitespace (ECMA-262 WhiteSpace + // includes U+FEFF), so an empty-or-BOM-only file and one indented under a + // BOM both fall through this pair of checks correctly with no extra strip. + if (xml.trim() === '') { + console.error(chalk.red('Error: the file is empty')); + process.exit(2); + } + if (!xml.trimStart().startsWith('<')) { + console.error( + chalk.red( + `Error: not an XML document (starts with "${printablePreview(xml.trimStart())}"); ` + + 'a .bpmnscript file is built with `bpmns build`', + ), + ); + process.exit(2); + } + let ir; let warnings: ImportWarning[]; try { @@ -80,7 +150,8 @@ export async function parseAction( } console.error( chalk.red( - `Error: failed to parse ${fileName}: ${(err as Error).message}`, + `Error: failed to parse ${fileName}: ` + + readableParseError((err as Error).message, xml), ), ); process.exit(2); @@ -91,11 +162,7 @@ export async function parseAction( try { ({ source: dsl, warnings: printWarnings } = irToDsl(ir)); } catch (err) { - console.error( - chalk.red( - `Error: IR to DSL conversion failed: ${(err as Error).message}`, - ), - ); + console.error(chalk.red(`Error: ${(err as Error).message}`)); process.exit(2); } @@ -120,4 +187,25 @@ export async function parseAction( for (const w of [...warnings, ...printWarnings]) { console.error(chalk.yellow(`Warning: ${w.elementId}: ${w.message}`)); } + + // The print hop from IR to source never refuses (ADR-0014): a warning above + // can name that a setting was carried as written, but none of them run the + // validator, so whether it actually draws an error is still unconfirmed. + // Re-running the same document-builder path `build` uses over the script + // just written is the one place that can still catch it, for every shape. + const rebuilt = await buildDocument(outPath); + const buildErrors = (rebuilt.diagnostics ?? []).filter( + (d) => d.severity === SEVERITY_ERROR, + ); + if (buildErrors.length > 0) { + console.error( + chalk.yellow( + `Warning: the printed script draws ${buildErrors.length} error(s) ` + + 'when built; hand-repair is needed:', + ), + ); + for (const diag of buildErrors) { + console.error(chalk.yellow(formatDiagnostic(rebuilt, diag))); + } + } } diff --git a/packages/cli/src/util.ts b/packages/cli/src/util.ts index 2388ad7..460b46f 100644 --- a/packages/cli/src/util.ts +++ b/packages/cli/src/util.ts @@ -1,10 +1,18 @@ -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync, statSync } from 'node:fs'; import * as path from 'node:path'; +import chalk from 'chalk'; import type { LangiumDocument } from 'langium'; +import { URI } from 'langium'; +import { NodeFileSystem } from 'langium/node'; +import { createBpmnScriptServices } from '@bpmn-script/language'; /** Read off the document so the shape is Langium's own, not a restatement. */ type Diagnostic = NonNullable[number]; +/** LSP's `DiagnosticSeverity` values; both actions filter diagnostics by these. */ +export const SEVERITY_ERROR = 1; +export const SEVERITY_WARNING = 2; + /** * A diagnostic carries its message as plain text or as LSP markup. Everything * the language server raises is text, but the union has to be read either way: @@ -32,10 +40,84 @@ export function resolveOutputPath( defaultExt: string, outputOverride?: string, ): string { - if (outputOverride !== undefined) { - return path.resolve(outputOverride); - } - const dir = path.dirname(resolvedInput); const base = path.basename(resolvedInput, path.extname(resolvedInput)); - return path.join(dir, `${base}${defaultExt}`); + if (outputOverride === undefined) { + return path.join(path.dirname(resolvedInput), `${base}${defaultExt}`); + } + const resolved = path.resolve(outputOverride); + // `-o some/dir` names a place to put the file, not the file itself: without + // this, the write later fails with a bare EISDIR. + return existsSync(resolved) && statSync(resolved).isDirectory() + ? path.join(resolved, `${base}${defaultExt}`) + : resolved; +} + +export type GuardOptions = { force?: boolean }; + +/** + * Refuses two destructive shapes before either action reads its input: + * an output path identical to the input, and an existing output without + * `--force`. Both actions call this right after resolving the output path. + */ +export function guardOutputPath( + resolvedInput: string, + outPath: string, + opts: GuardOptions, +): void { + if (outPath === resolvedInput) { + console.error( + chalk.red('Error: the input and the output are the same file'), + ); + process.exit(2); + } + if (!opts.force && existsSync(outPath)) { + console.error( + chalk.red( + `Error: ${outPath} exists; pass --force to overwrite it or -o for another path`, + ), + ); + process.exit(2); + } +} + +/** + * Parses and validates a BpmnScript document through Langium's own pipeline. + * `build` loads the file it was given this way; `parse` reloads the file it + * just wrote the same way, to catch what the print step (which never + * refuses, ADR-0014) still draws wrong. + */ +export async function buildDocument( + filePath: string, +): Promise { + const services = createBpmnScriptServices(NodeFileSystem).BpmnScript; + const document = + await services.shared.workspace.LangiumDocuments.getOrCreateDocument( + URI.file(filePath), + ); + await services.shared.workspace.DocumentBuilder.build([document], { + validation: true, + }); + return document; +} + +/** ` line N: message [snippet]`, the shape both actions print a diagnostic in. */ +export function formatDiagnostic( + document: LangiumDocument, + diagnostic: Diagnostic, +): string { + return ( + ` line ${diagnostic.range.start.line + 1}: ${diagnosticMessage(diagnostic)}` + + ` [${document.textDocument.getText(diagnostic.range)}]` + ); +} + +/** A directory has no text or document to read; refused before the loader turns it into a bare EISDIR. */ +export function refuseDirectoryInput( + resolvedInput: string, + fileName: string, +): void { + if (statSync(resolvedInput).isDirectory()) { + console.error(chalk.red(`Error: ${fileName} is a directory`)); + process.exit(2); + } } diff --git a/packages/cli/test/build-parse.smoke.test.ts b/packages/cli/test/build-parse.smoke.test.ts index 20456d4..fd81868 100644 --- a/packages/cli/test/build-parse.smoke.test.ts +++ b/packages/cli/test/build-parse.smoke.test.ts @@ -4,6 +4,8 @@ import { describe, test, expect, beforeAll } from 'vitest'; import * as fs from 'node:fs'; +import * as fsp from 'node:fs/promises'; +import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; @@ -16,6 +18,7 @@ import { xmlToIr } from '@bpmn-script/transform'; import { expectMentions, + runActionAt, runBuild, runParse, type Input, @@ -97,6 +100,31 @@ const TWO_DROPPED_CONDITIONS_BPMN = ` `; +// An external task's `taskPriority` is carried as written, with an import +// warning naming the step (`BpmnParse.parsePriority` fails the deployment on +// a non-integer constant); the warning says so but does not itself run the +// validator, so re-validating the printed script is what actually confirms +// `"abc"` draws the error. +/** A number field whose default the engine's `LongFormType` cannot convert: imported as written, refused by the validator. */ +const DECIMAL_DEFAULT_BPMN = ` + + + + + + + + + + + + + + +`; + type ParseExpectation = { /** The id of every `Warning: : ...` line, in the order printed. */ warningIds: string[]; @@ -106,6 +134,13 @@ type ParseExpectation = { script?: string[]; /** Set where the warnings differ in nothing but the element they name. */ sameMessage?: boolean; + /** + * The exact lines the re-validation of the printed script appends after + * every `warningIds` line, one entry per line, asserted whole: a string for + * an exact match, a pattern where the message text is pinned but a detail + * (the line number) is not. + */ + buildErrorLines?: (string | RegExp)[]; }; type ParseRow = readonly [ @@ -141,22 +176,49 @@ describe('bpmns parse', () => { script: ['CheckStock', 'ReserveGoods'], }, ], + [ + 'a setting the import warning names as an eventual failure is confirmed, after that warning, by re-validating the printed script', + { text: DECIMAL_DEFAULT_BPMN }, + { + warningIds: ['Review'], + mentions: ["default '1.5'", 'imported as written'], + script: ['amount: number = 1.5'], + buildErrorLines: [ + 'Warning: the printed script draws 1 error(s) when built; hand-repair is needed:', + /^ {2}line \d+: The default 1\.5 of number field 'amount' is not an integer; the engine converts it with Long\.valueOf every time the form renders \(LongFormType\.convertValue\) and throws on anything else\. \[.+\]$/, + ], + }, + ], ])('%s', async (_title, input, expected) => { const run = await runParse(input); expect(run.exit).toBeUndefined(); expect(run.output).toBeDefined(); - const prefixes = run.stderr.map( + // Every stderr line is accounted for: the id-prefixed warnings, then + // (where the row expects them) the re-validation lines appended after. + const tailLen = expected.buildErrorLines?.length ?? 0; + expect(run.stderr).toHaveLength(expected.warningIds.length + tailLen); + const splitAt = run.stderr.length - tailLen; + const idLines = run.stderr.slice(0, splitAt); + const tail = run.stderr.slice(splitAt); + + const prefixes = idLines.map( (line) => /^Warning: ([^:]+): /.exec(line)?.[1], ); expect(prefixes).toEqual(expected.warningIds); - expectMentions(run.stderr.join('\n'), expected.mentions ?? []); + tail.forEach((line, i) => { + const want = expected.buildErrorLines![i]; + if (want instanceof RegExp) expect(line).toMatch(want); + else expect(line).toBe(want); + }); + + expectMentions(idLines.join('\n'), expected.mentions ?? []); expectMentions(run.output ?? '', expected.script ?? []); if (expected.sameMessage) { - const bodies = run.stderr.map((line, i) => + const bodies = idLines.map((line, i) => line.slice(`Warning: ${expected.warningIds[i]}: `.length), ); expect(new Set(bodies).size).toBe(1); @@ -181,6 +243,11 @@ const WARNING_ONLY_SOURCE = `process warning-only { } `; +// A keyword typo (`proces` for `process`) leaves the grammar unable to find +// a Process node at all, the same symptom an empty file produces; only the +// parser error, not the no-process message, tells the two apart. +const KEYWORD_TYPO_SOURCE = 'proces p { user A }\n'; + // Declares `amount` as string, then compares it numerically: severity 1. const TYPE_MISMATCH_SOURCE = `process type-mismatch { var amount: string @@ -230,6 +297,15 @@ describe('bpmns build', () => { { text: TYPE_MISMATCH_SOURCE }, { exit: 1, stderrLines: 2, mentions: ['Validation errors:'] }, ], + [ + 'a keyword typo lists the parser error instead of the no-process message', + { text: KEYWORD_TYPO_SOURCE }, + { + exit: 1, + stderrLines: 2, + mentions: ['Validation errors:', 'proces'], + }, + ], ])('%s', async (_title, input, expected) => { const run = await runBuild(input); @@ -246,6 +322,396 @@ describe('bpmns build', () => { }); }); +const VALID_DSL = 'process guard { start S end E }'; + +const VALID_BPMN = ` + + + + + + +`; + +// DSL text handed to `parse` by mistake: no comma, no newline, over 40 +// characters, so the preview's cutoff is visible without hand-counting it. +const SWAPPED_DSL = + 'process invoice_approval { start S user Review(assignee: "demo") end E }'; + +// Accented text a real BPMN export can carry: only control bytes are +// stripped from the preview now, so these survive it. Over 40 characters, so +// the cutoff is visible without hand-counting it. +const NON_ASCII_PREVIEW_SOURCE = + 'prüfen, ob Umlaute überleben und nicht nur ASCII-Zeichen übrig bleiben'; + +type GuardCase = { + inputPath: string; + opts: { output?: string; force?: boolean }; + exit: number | undefined; + /** The exact first stderr line, where the row expects one. */ + line?: string; + /** Run after the action, for what a stderr line can't say (file contents). */ + check?: () => void; +}; + +type GuardRow = readonly [ + title: string, + action: 'build' | 'parse', + make: (dir: string) => GuardCase, +]; + +// One table for both commands: the five destructive-path guards (same file, +// existing output, --force, directory input, `-o` a directory) are one +// mechanism shared by `build` and `parse` through `util.ts`, so a row pair +// proves it fires identically on both rather than trusting a build-only test. +describe('bpmns build / bpmns parse: guards against destructive or unclear failures', () => { + test.each([ + [ + 'build: an existing output is refused without --force, and is left untouched', + 'build', + (dir) => { + const inputPath = path.join(dir, 'order.bpmnscript'); + fs.writeFileSync(inputPath, VALID_DSL, 'utf-8'); + const outputPath = path.join(dir, 'order.bpmn'); + fs.writeFileSync(outputPath, 'PRE-EXISTING', 'utf-8'); + return { + inputPath, + opts: {}, + exit: 2, + line: `Error: ${outputPath} exists; pass --force to overwrite it or -o for another path`, + check: () => + expect(fs.readFileSync(outputPath, 'utf-8')).toBe('PRE-EXISTING'), + }; + }, + ], + [ + 'build: --force overwrites an existing output', + 'build', + (dir) => { + const inputPath = path.join(dir, 'order.bpmnscript'); + fs.writeFileSync(inputPath, VALID_DSL, 'utf-8'); + const outputPath = path.join(dir, 'order.bpmn'); + fs.writeFileSync(outputPath, 'PRE-EXISTING', 'utf-8'); + return { + inputPath, + opts: { force: true }, + exit: undefined, + check: () => + expect(fs.readFileSync(outputPath, 'utf-8')).toContain(' { + const inputPath = path.join(dir, 'same.bpmnscript'); + fs.writeFileSync(inputPath, VALID_DSL, 'utf-8'); + return { + inputPath, + opts: { output: inputPath }, + exit: 2, + line: 'Error: the input and the output are the same file', + check: () => + expect(fs.readFileSync(inputPath, 'utf-8')).toBe(VALID_DSL), + }; + }, + ], + [ + 'build: a directory input is refused', + 'build', + (dir) => ({ + inputPath: dir, + opts: {}, + exit: 2, + line: `Error: ${dir} is a directory`, + }), + ], + [ + 'build: an -o naming a directory writes inside it under the default basename', + 'build', + (dir) => { + const inputPath = path.join(dir, 'order.bpmnscript'); + fs.writeFileSync(inputPath, VALID_DSL, 'utf-8'); + const outDir = path.join(dir, 'out'); + fs.mkdirSync(outDir); + return { + inputPath, + opts: { output: outDir }, + exit: undefined, + check: () => + expect( + fs.readFileSync(path.join(outDir, 'order.bpmn'), 'utf-8'), + ).toContain(' { + const inputPath = path.join(dir, 'order.txt'); + fs.writeFileSync(inputPath, VALID_DSL, 'utf-8'); + return { + inputPath, + opts: {}, + exit: 2, + line: 'Error: expected a file with one of these extensions: .bpmnscript', + }; + }, + ], + [ + 'build: an empty source is reported plainly, without a pipeline stage name', + 'build', + (dir) => { + const inputPath = path.join(dir, 'empty.bpmnscript'); + fs.writeFileSync(inputPath, ' \n \n', 'utf-8'); + return { + inputPath, + opts: {}, + exit: 1, + line: 'Error: the file has no process', + }; + }, + ], + [ + 'build: a comment-only source is reported the same way, not as an astToIr internal error', + 'build', + (dir) => { + const inputPath = path.join(dir, 'comment-only.bpmnscript'); + fs.writeFileSync(inputPath, '// nothing but a comment here\n', 'utf-8'); + return { + inputPath, + opts: {}, + exit: 1, + line: 'Error: the file has no process', + }; + }, + ], + [ + 'parse: an existing output is refused without --force, and is left untouched', + 'parse', + (dir) => { + const inputPath = path.join(dir, 'order.bpmn'); + fs.writeFileSync(inputPath, VALID_BPMN, 'utf-8'); + const outputPath = path.join(dir, 'order.bpmnscript'); + fs.writeFileSync(outputPath, 'PRE-EXISTING', 'utf-8'); + return { + inputPath, + opts: {}, + exit: 2, + line: `Error: ${outputPath} exists; pass --force to overwrite it or -o for another path`, + check: () => + expect(fs.readFileSync(outputPath, 'utf-8')).toBe('PRE-EXISTING'), + }; + }, + ], + [ + 'parse: --force overwrites an existing output', + 'parse', + (dir) => { + const inputPath = path.join(dir, 'order.bpmn'); + fs.writeFileSync(inputPath, VALID_BPMN, 'utf-8'); + const outputPath = path.join(dir, 'order.bpmnscript'); + fs.writeFileSync(outputPath, 'PRE-EXISTING', 'utf-8'); + return { + inputPath, + opts: { force: true }, + exit: undefined, + check: () => + expect(fs.readFileSync(outputPath, 'utf-8')).toContain( + 'process guard', + ), + }; + }, + ], + [ + 'parse: an -o equal to the input path is refused, and the source is left untouched', + 'parse', + (dir) => { + const inputPath = path.join(dir, 'same.bpmn'); + fs.writeFileSync(inputPath, VALID_BPMN, 'utf-8'); + return { + inputPath, + opts: { output: inputPath }, + exit: 2, + line: 'Error: the input and the output are the same file', + check: () => + expect(fs.readFileSync(inputPath, 'utf-8')).toBe(VALID_BPMN), + }; + }, + ], + [ + 'parse: a directory input is refused', + 'parse', + (dir) => ({ + inputPath: dir, + opts: {}, + exit: 2, + line: `Error: ${dir} is a directory`, + }), + ], + [ + 'parse: an -o naming a directory writes inside it under the default basename', + 'parse', + (dir) => { + const inputPath = path.join(dir, 'order.bpmn'); + fs.writeFileSync(inputPath, VALID_BPMN, 'utf-8'); + const outDir = path.join(dir, 'out'); + fs.mkdirSync(outDir); + return { + inputPath, + opts: { output: outDir }, + exit: undefined, + check: () => + expect( + fs.readFileSync(path.join(outDir, 'order.bpmnscript'), 'utf-8'), + ).toContain('process guard'), + }; + }, + ], + [ + 'parse: an empty file is reported plainly, not as an XML parse failure', + 'parse', + (dir) => { + const inputPath = path.join(dir, 'empty.bpmn'); + fs.writeFileSync(inputPath, '', 'utf-8'); + return { + inputPath, + opts: {}, + exit: 2, + line: 'Error: the file is empty', + }; + }, + ], + [ + 'parse: binary input is reported, not dumped onto stderr', + 'parse', + (dir) => { + const inputPath = path.join(dir, 'binary.bpmn'); + const bytes = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0x00]); + fs.writeFileSync(inputPath, bytes); + // Only control bytes (0x00, 0x01, 0x02) are stripped now; 0xff/0xfe + // are not valid UTF-8 on their own and decode as U+FFFD, a printable + // symbol the preview keeps. + const preview = bytes.toString('utf-8').replace(/\p{C}/gu, ''); + return { + inputPath, + opts: {}, + exit: 2, + line: + `Error: not an XML document (starts with "${preview}"); ` + + 'a .bpmnscript file is built with `bpmns build`', + }; + }, + ], + [ + 'parse: an accented preview keeps its own characters, only control bytes are stripped', + 'parse', + (dir) => { + const inputPath = path.join(dir, 'umlaut.bpmn'); + fs.writeFileSync(inputPath, NON_ASCII_PREVIEW_SOURCE, 'utf-8'); + return { + inputPath, + opts: {}, + exit: 2, + line: + `Error: not an XML document (starts with "${NON_ASCII_PREVIEW_SOURCE.slice(0, 40)}"); ` + + 'a .bpmnscript file is built with `bpmns build`', + }; + }, + ], + [ + 'parse: DSL text given by mistake is reported, not echoed back whole', + 'parse', + (dir) => { + const inputPath = path.join(dir, 'swapped.bpmn'); + fs.writeFileSync(inputPath, SWAPPED_DSL, 'utf-8'); + return { + inputPath, + opts: {}, + exit: 2, + line: + `Error: not an XML document (starts with "${SWAPPED_DSL.slice(0, 40)}"); ` + + 'a .bpmnscript file is built with `bpmns build`', + }; + }, + ], + [ + 'parse: a non-BPMN root names the tag and namespace it expected', + 'parse', + (dir) => { + const inputPath = path.join(dir, 'not-definitions.bpmn'); + fs.writeFileSync(inputPath, 'hi', 'utf-8'); + return { + inputPath, + opts: {}, + exit: 2, + line: + `Error: failed to parse ${inputPath}: failed to parse document as ` + + '(root element is ; expected in namespace ' + + 'http://www.omg.org/spec/BPMN/20100524/MODEL)', + }; + }, + ], + [ + 'parse: a wrong bpmn namespace names the namespace it found', + 'parse', + (dir) => { + const inputPath = path.join(dir, 'wrong-ns.bpmn'); + fs.writeFileSync( + inputPath, + '' + + '', + 'utf-8', + ); + return { + inputPath, + opts: {}, + exit: 2, + line: + `Error: failed to parse ${inputPath}: failed to parse document as ` + + '(root element is ; expected in namespace ' + + 'http://www.omg.org/spec/BPMN/20100524/MODEL; found xmlns:bpmn=http://example.com/wrong)', + }; + }, + ], + [ + 'parse: an unclosed tag with no closing bracket anywhere is cut to its first line, capped at 200 characters, with any control byte stripped', + 'parse', + (dir) => { + const inputPath = path.join(dir, 'unclosed.bpmn'); + // `saxen` echoes the whole unparsed remainder into this one line, a + // control byte included, then appends `\n\tline: ...` after it, which + // this row's cutoff must never reach. + const unclosedTag = ' { + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bpmns-guard-')); + try { + const { inputPath, opts, exit, line, check } = make(dir); + const run = await runActionAt(action, inputPath, opts); + expect(run.exit).toBe(exit); + if (line !== undefined) expect(run.stderr[0]).toBe(line); + check?.(); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } + }); +}); + describe('tmLanguage extension sync', () => { test('the extension ships the current grammar, copied by a build step', () => { expect(fs.readFileSync(EXTENSION_TMLANGUAGE, 'utf-8')).toBe( diff --git a/packages/cli/test/build.layout-guard.test.ts b/packages/cli/test/build.layout-guard.test.ts new file mode 100644 index 0000000..da2da37 --- /dev/null +++ b/packages/cli/test/build.layout-guard.test.ts @@ -0,0 +1,30 @@ +/** + * `bpmn-auto-layout` is mocked here rather than provoking its real crash + * (`ir-to-xml.layout-guard.test.ts` in `packages/transform` does that): this + * file only needs `bpmns build`'s `LayoutError` fallback exercised end to + * end, not the library's own defect. + */ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('bpmn-auto-layout', () => ({ + layoutProcess: vi.fn(() => { + throw new TypeError('Cannot set properties of undefined'); + }), +})); + +import { runBuild } from './helpers/actions.js'; + +describe('bpmns build: layoutProcess guard', () => { + it('falls back to writing the DI-less document with a warning instead of failing the build', async () => { + const run = await runBuild({ text: 'process guard { start S end E }' }); + + expect(run.exit).toBeUndefined(); + expect(run.stderr).toEqual([ + 'Warning: no diagram could be drawn for this process (bpmn-auto-layout ' + + 'failed to lay out the process: Cannot set properties of undefined); ' + + 'the file deploys but opens without shapes in a modeler', + ]); + expect(run.output).toBeDefined(); + expect(run.output).not.toContain('bpmndi:'); + }); +}); diff --git a/packages/cli/test/decompile-contract.e2e.test.ts b/packages/cli/test/decompile-contract.e2e.test.ts index 222942e..668ddef 100644 --- a/packages/cli/test/decompile-contract.e2e.test.ts +++ b/packages/cli/test/decompile-contract.e2e.test.ts @@ -39,16 +39,19 @@ const ERROR_START_BPMN = path.resolve( ); // `StartEvent_1`/`EndEvent_1` are what a modeler mints for a start and an end -// drawn without a name of their own, and the shape the desugarer reserves for -// the ids it generates, so a script cannot spell either one back. -const GENERATED_ID_LABELS_BPMN = ` +// drawn without a name of their own. +/** A labeled start and end under the given ids, in process `generated-id-labels`. */ +const labeledTerminalsBpmn = ( + startId: string, + endId: string, +): string => ` - + - - - + + + `; @@ -97,7 +100,7 @@ describe('decompile contract: what the import makes of a fixture', () => { ['ReviewRequest', 'extensionAttribute'], ]); expectMentions(warnings.map((w) => w.message).join('\n'), [ - 'operaton:Properties', + 'operaton:properties', ]); }); @@ -145,7 +148,7 @@ describe('decompile contract: what `bpmns parse` does with the same fixtures', ( LANES_AND_ASYNC_BPMN, { warningIds: ['Lane_Ops', 'ReviewRequest'], - mentions: ['operaton:Properties'], + mentions: ['operaton:properties'], script: [ 'process lanes-and-async', 'start ReviewStart', @@ -196,25 +199,53 @@ describe('decompile contract: the script it hands back goes through the pipeline expect(diagnostics).toHaveLength(0); }); - it('the DSL produced from a diagram whose labeled start and end carry generated-shaped ids re-parses with zero diagnostics, and each dropped label is warned about', async () => { - const { ir, warnings } = await xmlToIr(GENERATED_ID_LABELS_BPMN); - const dsl = irToDsl(ir).source; - - const document = await parse(dsl, { validation: true }); - expect(document.parseResult.parserErrors).toHaveLength(0); - - const { diagnostics } = await validate(dsl); - expect(diagnostics).toHaveLength(0); - - const labelWarnings = warnings.filter((w) => w.category === 'label'); - expect(labelWarnings.map((w) => w.elementId)).toEqual([ + // Only the exact ids the compiler generates for the process are left out + // of the script; a modelling tool's default ids are names like any other. + it.each([ + [ + "a modelling tool's default ids keep their statements and labels", 'StartEvent_1', 'EndEvent_1', - ]); - expect(labelWarnings[0]?.message).toContain('Order received'); - expect(labelWarnings[1]?.message).toContain('Order filed'); - for (const w of labelWarnings) assertNoForbiddenJargon(w.message); - }); + [ + 'start StartEvent_1(label: "Order received")', + 'end EndEvent_1(label: "Order filed")', + ], + [], + ], + [ + 'the ids this tool generates for the process drop their statements, and each dropped label is warned about', + 'StartEvent_generated-id-labels', + 'EndEvent_generated-id-labels', + [], + ['StartEvent_generated-id-labels', 'EndEvent_generated-id-labels'], + ], + ])( + 'the DSL produced from a diagram where %s re-parses with zero diagnostics', + async (_title, startId, endId, printedLines, warnedIds) => { + const { ir, warnings } = await xmlToIr( + labeledTerminalsBpmn(startId, endId), + ); + const dsl = irToDsl(ir).source; + + const document = await parse(dsl, { validation: true }); + expect(document.parseResult.parserErrors).toHaveLength(0); + + const { diagnostics } = await validate(dsl); + expect(diagnostics).toHaveLength(0); + expect( + dsl.split('\n').filter((line) => /^\s+(start|end) /.test(line)), + ).toEqual(printedLines.map((line) => ` ${line}`)); + + const labelWarnings = warnings.filter((w) => w.category === 'label'); + expect(labelWarnings.map((w) => w.elementId)).toEqual(warnedIds); + for (const w of labelWarnings) { + expect(w.message).toContain( + w.elementId === startId ? 'Order received' : 'Order filed', + ); + assertNoForbiddenJargon(w.message); + } + }, + ); it('the DSL produced from the lanes-and-async fixture builds again without validation errors, and the rebuilt BPMN re-imports cleanly', async () => { const xml = fs.readFileSync(LANES_AND_ASYNC_BPMN, 'utf-8'); diff --git a/packages/cli/test/helpers/actions.ts b/packages/cli/test/helpers/actions.ts index 94a8988..09e6df9 100644 --- a/packages/cli/test/helpers/actions.ts +++ b/packages/cli/test/helpers/actions.ts @@ -35,11 +35,48 @@ export type ActionRun = { /** Either a source written into the temp dir, or a file already on disk. */ export type Input = { text: string } | { file: string }; +export type ActionOptions = { output?: string; force?: boolean }; + type Action = ( fileName: string, - opts: { output?: string }, + opts: ActionOptions, ) => Promise; +/** + * Installs the same console/exit capture `run` uses, without owning a + * directory or picking paths: the guard tests below manage their own temp + * dir, since they need file layouts `run`'s fixed `input`/`output` names + * cannot express (a pre-existing output, `-o` equal to the input, a + * directory as either argument). + */ +async function capture( + invoke: () => Promise, +): Promise> { + const stderr: string[] = []; + const spies = [ + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + stderr.push(String(args[0])); + }), + vi.spyOn(console, 'log').mockImplementation(() => {}), + // Throws so the action stops where process.exit() would have. A no-op + // mock would let it fall through and keep running. + vi.spyOn(process, 'exit').mockImplementation((code?: unknown) => { + throw new ExitCalled(typeof code === 'number' ? code : 0); + }), + ]; + + let exit: number | undefined; + try { + await invoke(); + } catch (err) { + if (!(err instanceof ExitCalled)) throw err; + exit = err.code; + } finally { + for (const spy of spies) spy.mockRestore(); + } + return { exit, stderr }; +} + async function run( action: Action, inputExt: string, @@ -55,28 +92,9 @@ async function run( } const outputPath = path.join(dir, `output${outputExt}`); - const stderr: string[] = []; - const spies = [ - vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { - stderr.push(String(args[0])); - }), - vi.spyOn(console, 'log').mockImplementation(() => {}), - // Throws so the action stops where process.exit() would have. A no-op - // mock would let it fall through and keep running. - vi.spyOn(process, 'exit').mockImplementation((code?: unknown) => { - throw new ExitCalled(typeof code === 'number' ? code : 0); - }), - ]; - - let exit: number | undefined; - try { - await action(inputPath, { output: outputPath }); - } catch (err) { - if (!(err instanceof ExitCalled)) throw err; - exit = err.code; - } finally { - for (const spy of spies) spy.mockRestore(); - } + const { exit, stderr } = await capture(() => + action(inputPath, { output: outputPath }), + ); return { exit, @@ -90,6 +108,21 @@ async function run( } } +/** + * Runs an action against a path the caller already put on disk, with the + * caller's own options, for the destructive-path guards: no path is + * invented or cleaned up here, so the test can assert on the exact file it + * set up (still present, overwritten, or written inside a directory). + */ +export const runActionAt = ( + action: 'build' | 'parse', + inputPath: string, + opts: ActionOptions, +): Promise> => + capture(() => + (action === 'build' ? buildAction : parseAction)(inputPath, opts), + ); + /** Compiles a `.bpmnscript` source to BPMN, as `bpmns build` does. */ export const runBuild = (input: Input): Promise => run(buildAction, '.bpmnscript', '.bpmn', input); diff --git a/packages/cli/test/util.test.ts b/packages/cli/test/util.test.ts index 509413d..c818ba8 100644 --- a/packages/cli/test/util.test.ts +++ b/packages/cli/test/util.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { diagnosticMessage, @@ -23,6 +25,18 @@ describe('resolveOutputPath', () => { path.resolve(expected), ); }); + + it('an override naming an existing directory writes inside it under the input basename', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bpmns-util-')); + try { + const input = path.join(dir, 'invoice-approval.bpmnscript'); + expect(resolveOutputPath(input, '.bpmn', dir)).toBe( + path.join(dir, 'invoice-approval.bpmn'), + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); describe('diagnosticMessage', () => { diff --git a/packages/extension/injection/bpmn-script.injection.tmLanguage.json b/packages/extension/injection/bpmn-script.injection.tmLanguage.json index fbc71f4..b1a0eed 100644 --- a/packages/extension/injection/bpmn-script.injection.tmLanguage.json +++ b/packages/extension/injection/bpmn-script.injection.tmLanguage.json @@ -11,7 +11,7 @@ ], "repository": { "javascript-block": { - "begin": "(`{3})(javascript|js)\\s*$", + "begin": "(`{3})(javascript|js|ecmascript)\\s*$", "beginCaptures": { "1": { "name": "punctuation.definition.script.begin.bpmn-script" }, "2": { "name": "fenced_code.block.language.bpmn-script" } diff --git a/packages/extension/src/extension/conversion-core.ts b/packages/extension/src/extension/conversion-core.ts index f8f6939..2004887 100644 --- a/packages/extension/src/extension/conversion-core.ts +++ b/packages/extension/src/extension/conversion-core.ts @@ -11,6 +11,7 @@ import { xmlToIr, irToDsl, UnsupportedConstructError, + LayoutError, } from '@bpmn-script/transform'; import type { ImportWarning, PrintWarning } from '@bpmn-script/transform'; @@ -26,7 +27,7 @@ export interface ConvDiagnostic { } export type CompileResult = - | { ok: true; output: string } + | { ok: true; output: string; layoutWarning?: string } | { ok: false; kind: 'validation'; diagnostics: ConvDiagnostic[] } | { ok: false; kind: 'error'; message: string }; @@ -44,7 +45,6 @@ let nextDocId = 0; export async function compileDslToBpmn( source: string, - sourceFileName: string, exporterVersion: string, ): Promise { const uri = URI.parse(`memory:///conv-${nextDocId++}.bpmnscript`); @@ -89,17 +89,27 @@ export async function compileDslToBpmn( } let output; + let layoutWarning: string | undefined; try { - output = await irToXml(ir, { sourceFileName, exporterVersion }); + output = await irToXml(ir, { exporterVersion }); } catch (err) { - return { - ok: false, - kind: 'error', - message: `IR to XML conversion failed: ${(err as Error).message}`, - }; + if (err instanceof LayoutError) { + output = err.xml; + layoutWarning = err.message; + } else { + return { + ok: false, + kind: 'error', + message: `IR to XML conversion failed: ${(err as Error).message}`, + }; + } } - return { ok: true, output }; + return { + ok: true, + output, + ...(layoutWarning !== undefined ? { layoutWarning } : {}), + }; } catch (err) { return { ok: false, @@ -113,7 +123,6 @@ export async function compileDslToBpmn( export async function decompileBpmnToDsl( xml: string, - _sourceFileName: string, // unused; keeps the signature parallel to compile ): Promise { let ir; let warnings: ImportWarning[]; diff --git a/packages/extension/src/extension/conversion.ts b/packages/extension/src/extension/conversion.ts index 4b43ea5..b7479f0 100644 --- a/packages/extension/src/extension/conversion.ts +++ b/packages/extension/src/extension/conversion.ts @@ -64,11 +64,7 @@ export function compileCommand( const sourceFileName = path.basename(sourceUri.fsPath); const text = await readText(sourceUri); - const result = await compileDslToBpmn( - text, - sourceFileName, - extensionVersion, - ); + const result = await compileDslToBpmn(text, extensionVersion); // Cleared on every outcome; the validation branch repopulates. diagnostics.delete(sourceUri); @@ -92,6 +88,12 @@ export function compileCommand( `BPMNscript: Compiled "${sourceFileName}" -> "${path.basename(outputPath)}"`, ); + if (result.layoutWarning) { + void vscode.window.showWarningMessage( + `BPMNscript: "${sourceFileName}" compiled without a diagram: ${result.layoutWarning}`, + ); + } + return outputUri; } else if (result.kind === 'validation') { diagnostics.set(sourceUri, result.diagnostics.map(toVsDiagnostic)); @@ -123,7 +125,7 @@ export function decompileCommand( const sourceFileName = path.basename(sourceUri.fsPath); const text = await readText(sourceUri); - const result = await decompileBpmnToDsl(text, sourceFileName); + const result = await decompileBpmnToDsl(text); diagnostics.delete(sourceUri); diff --git a/packages/extension/test/bundled-conversion.e2e.test.ts b/packages/extension/test/bundled-conversion.e2e.test.ts index 1790125..4c48770 100644 --- a/packages/extension/test/bundled-conversion.e2e.test.ts +++ b/packages/extension/test/bundled-conversion.e2e.test.ts @@ -70,7 +70,7 @@ describe('bundled asset resolution and transform under the shim', () => { ' const bpmnPath = process.argv[2];', " const xml = readFileSync(bpmnPath, 'utf-8');", ' const { ir } = await xmlToIr(xml);', - " const bpmnOut = await irToXml(ir, { sourceFileName: 'verify', exporterVersion: '0.0.1' });", + " const bpmnOut = await irToXml(ir, { exporterVersion: '0.0.1' });", " process.stdout.write('PROCESS_ID:' + ir.id + '\\n');", ' process.stdout.write(bpmnOut);', '})().catch(err => { console.error(err); process.exit(1); });', diff --git a/packages/extension/test/conversion-core.test.ts b/packages/extension/test/conversion-core.test.ts index bdc25bb..c2d0942 100644 --- a/packages/extension/test/conversion-core.test.ts +++ b/packages/extension/test/conversion-core.test.ts @@ -117,7 +117,7 @@ describe('compileDslToBpmn', () => { { ok: true, reimportsAs: 'p' }, ], ])('%s', async (_title, source, expected) => { - const result = await compileDslToBpmn(source, 'test.bpmnscript', '0.0.1'); + const result = await compileDslToBpmn(source, '0.0.1'); expect(result.ok).toBe(expected.ok); if (expected.ok) { @@ -140,6 +140,34 @@ describe('compileDslToBpmn', () => { expected.diagnostics.map(() => true), ); }); + + // `bpmn-auto-layout`'s grid solver throws on this validator-clean `goto` + // restructuring (a mixed true/false/expression `else if` chain feeding one + // `goto` each); the source is otherwise unremarkable. + test('a layouter crash on a validator-clean goto graph still compiles, without a diagram, and reports why', async () => { + const source = `process p { + if (true) { + goto L + } else if (a.b) { + goto U + } else if (false) { + } + receive R + end E + emit compensation L + user U + end H +} +`; + + const result = await compileDslToBpmn(source, '0.0.1'); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.output).toContain(' { { ok: true, warnings: [], mentions: [] }, ], ])('%s', async (_title, xml, expected) => { - const result = await decompileBpmnToDsl(xml, 'input.bpmn'); + const result = await decompileBpmnToDsl(xml); expect(result.ok).toBe(expected.ok); if (expected.ok) { diff --git a/packages/extension/test/injection-grammar.test.ts b/packages/extension/test/injection-grammar.test.ts index 3d4e336..d4dc5f6 100644 --- a/packages/extension/test/injection-grammar.test.ts +++ b/packages/extension/test/injection-grammar.test.ts @@ -19,7 +19,7 @@ const EMBEDDED_SCOPE_BY_FORMAT: Readonly> = { }; // Tags deliberately left unhighlighted: VS Code ships no grammar for them. -const NO_INSTALLED_GRAMMAR = new Set(['feel']); +const NO_INSTALLED_GRAMMAR = new Set(['feel', 'juel']); const EXTENSION_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), diff --git a/packages/language/README.md b/packages/language/README.md index 174ac5a..ea4cc29 100644 --- a/packages/language/README.md +++ b/packages/language/README.md @@ -17,7 +17,9 @@ The package also owns the base VS Code TextMate grammar (`syntaxes/bpmn-script.t One `process` block per file. Steps run top to bottom, so sequence flow is never written out, and control flow uses structured statements instead. -Optional `var` declarations go in the process header, before the body, alongside the header's own settings: `versionTag`, which labels the deployed process definition, and `historyTimeToLive`, `candidateStarterUsers`, and `candidateStarterGroups`, which the engine reads before it starts an instance. +Optional `var` declarations go in the process header, before the body, alongside the header's own settings: `versionTag`, which labels the deployed process definition, `historyTimeToLive`, and `candidateStarterUsers` and `candidateStarterGroups`, whose entries are listed as candidate starters (Tasklist's process list; `BpmnDeployer.addAuthorizations` stores each entry as written). +`historyTimeToLive` is the number of days the engine keeps a finished instance's history, written `PD` or ``, and an absent value compiles to `P30D`, since `HistoryTimeToLiveParser.parseAndValidate` refuses a null value under the engine's default `enforceHistoryTimeToLive`; the cleanup itself runs only once its batch window is configured. +Every header value is stored as written, never evaluated: `label`, `documentation` and `versionTag` are quoted strings, the tag at most 64 characters, and no header setting takes a `"${...}"` expression. ```bpmnscript process invoice-approval { @@ -35,7 +37,8 @@ process invoice-approval { ``` Every targetable statement carries an explicit id, which is what `goto` and boundary events refer to. -The BPMN `name` is derived from that id (`ReviewInvoice` becomes "Review Invoice") unless a `label` setting gives one instead: `user ReviewInvoice(label: "Review invoice")`. +An id is ASCII only: a letter or `_`, then any run of letters, digits and `_`, with single hyphens allowed between such runs (the `ID` terminal in the grammar). +A step, a call and a sub-process derive the BPMN `name` from that id (`ReviewInvoice` becomes "Review Invoice") unless a `label` setting gives one instead: `user ReviewInvoice(label: "Review invoice")`; a `start` and an `end` are named by `label` alone, and `on`, `throw`, `emit` and `await` show no name at all, except the two ends of a link pair, which carry the link name. A `documentation` setting carries free-form text alongside it, spelled the same way: `user ReviewInvoice(documentation: "Escalate to the senior approver above 1000.")`. ### Statements @@ -76,7 +79,7 @@ Five engine execution settings are legal on every element that takes settings, ` | Element | Keys beyond the engine settings | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `start` | `initiator` | +| `start` | `initiator`, on the process's own start | | `end`, `await`, `on`, `subprocess`, `attempt` | none | | `user` | `assignee`, `formKey`, `formRef`, `binding`, `version`, `candidateGroups`, `candidateUsers`, `dueDate`, `followUpDate`, `priority` | | `service` | `class`, `expression`, `delegate`, `topic`, `type`, `resultVariable`, `taskPriority` | @@ -90,13 +93,20 @@ Five engine execution settings are legal on every element that takes settings, ` | process header | `label`, `documentation`, `versionTag`, `historyTimeToLive`, `candidateStarterUsers`, `candidateStarterGroups` | The engine settings are `asyncBefore` and `asyncAfter`, which put a transaction boundary before or after the step, `exclusive`, which says whether the engine may run the step's jobs beside other jobs of the same instance, `jobPriority`, which orders those jobs in the queue, and `retryCycle`, the ISO cycle a failed job is retried on. +`exclusive`, `jobPriority`, or `retryCycle` written without `asyncBefore` or `asyncAfter` beside it configures no job and draws a warning, since Operaton creates the job only from the async flags (BpmnParse.parseAsynchronousContinuation, DefaultFailedJobParseListener.parseActivity); a timer carrier is exempt, since the timer itself creates the job. +A process's signal start, an awaited signal, and a signal race branch are exempt for `jobPriority` alone: the subscription job BpmnParse.parseSignalCatchEventDefinition declares for them takes its priority from the catcher and runs when the throwing side is async, while `exclusive` and `retryCycle` still reach that job only through the async flags. The gateways that `if`, `while`, `do...while`, `parallel`, and a multi-branch `await` synthesize take the same five settings on the statement head, see [Job settings on a gateway](#job-settings-on-a-gateway). `taskPriority` rides a `topic` binding alone, see [External task extras](#external-task-extras). A repeated step also takes `runAsyncBefore`, `runAsyncAfter`, `runExclusive`, and `runRetryCycle`, written to the repetition itself and read by the engine onto each run rather than around the whole loop. -There is no `runJobPriority`, since the engine reads a job priority off the step alone, see [Repetition](#repetition). +There is no `runJobPriority`: `jobPriority` already prices each run's job once a step repeats, because `createActivityOnScope` reads it off the step regardless of what repeats around it; the whole-loop job takes the process's own priority instead, see [Repetition](#repetition). +A user task's `assignee` is the user id: a bare word or a quoted string reaches the engine as written, and `"${who}"` reads a variable. +`candidateUsers` and `candidateGroups` are read the same way. +`dueDate` and `followUpDate` take a period starting with `P`, an ISO date-time such as `"2026-01-01T09:00:00"`, or a `"${...}"` expression, the shapes `DueDateBusinessCalendar.resolveDuedate` parses when the task is created. +`priority` is an integer or an expression yielding one; `TaskDecorator.initializeTaskPriority` parses a constant with `Integer.parseInt` at task creation, so `"high"` deploys and then fails the instance. A user task names its deployed form with `formKey` or with `formRef`, never both. `formRef` also needs `binding: latest`, `binding: deployment`, or `version: ` beside it, the same version-pinning a `call` or a `decide` step carries, since the engine refuses to deploy a form reference it cannot resolve a version for. +`version` is a positive whole number, quoted or not, or an expression yielding one, on a `formRef`, a `call`, and a `decide` step alike: the engine parses it as an integer when the step runs (`BaseCallableElement.getVersion`, `TaskEntity.initializeFormRefFromTaskDefinition`), so `1.5` deploys and then fails the instance. #### Job settings on a gateway @@ -111,13 +121,13 @@ process order-fulfillment { start OrderPlaced - if (amount > 10000) (asyncBefore: true, joinJobPriority: 5) { + if (amount > 10000) (asyncBefore: true, joinAsyncBefore: true, joinJobPriority: 5) { user AuditReview(assignee: "auditor") } else { user SkipAudit(assignee: "clerk") } - parallel (exclusive: true, joinAsyncAfter: true) { + parallel (asyncBefore: true, exclusive: true, joinAsyncAfter: true) { { user PackItems } @@ -136,7 +146,7 @@ A `join`-prefixed setting on a statement whose branches all end their own path i #### Forms -A `start` and a `user` task each take one `form` block, `operaton:formData` on the wire, which Tasklist renders as one input per field and which fills one process variable per field when the form is submitted. +A process's own `start` and a `user` task each take one `form` block, `operaton:formData` on the wire, which Tasklist renders as one input per field and which fills one process variable per field when the form is submitted. A field takes the shape every element takes ([ADR-0029](../../docs/decisions/0029-one-bracket-shape-for-every-element.md)): `id: type "label" = default (settings) { members }`, the label and the default optional. The parens hold what the engine validates a submission under, plus the date pattern; the braces hold what the field is built from, an `enum`'s values and its `property` lines. `id` is the process variable the field fills, so a `var` of the same name has to declare the same type. @@ -147,18 +157,23 @@ The type is `string`, `number`, `boolean`, `date`, or `enum`. Each setting in the parens but `pattern` is one `operaton:constraint`, evaluated in the order written. `FormValidators.createValidator` fails the deployment on any constraint name the engine has not registered, so the seven below are the closed set the parens take. -| Setting | What the engine checks | Fits | Value | -| ---------------------------------- | ----------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------ | -| `required: true` | a value was submitted, or the variable or the default already holds one; empty text fails | any type | the literal `true` | -| `readonly: true` | no value was submitted for the field | any type | the literal `true` | -| `min: `, `max: ` | the submitted number is at least `n`, or below `n` (`MaxValidator.validate` is exclusive) | `number` | an integer literal or a quoted integer such as `"-5"` | -| `minlength: `, `maxlength: ` | the submitted text has at least, or at most, `n` characters | `string` | an integer literal or a quoted integer | -| `validator: "..."` | whatever the named class or expression decides | any type | a class name or a `"${...}"` expression, the shapes `class:` takes | -| `pattern: "..."` | nothing; it is the format a submitted date is parsed with, `datePattern` on the wire | `date` | a non-empty quoted pattern such as `"dd/MM/yyyy"` | +| Setting | What the engine checks | Fits | Value | +| ---------------------------------- | ----------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------- | +| `required: true` | a value was submitted, or the variable or the default already holds one; empty text fails | any type | the literal `true` | +| `readonly: true` | no value was submitted for the field | any type | the literal `true` | +| `min: `, `max: ` | the submitted number is at least `n`, or below `n` (`MaxValidator.validate` is exclusive) | `number` | an integer literal or a quoted integer such as `"-5"` | +| `minlength: `, `maxlength: ` | the submitted text has at least, or at most, `n` characters | `string` | an integer literal or a quoted integer | +| `validator: "..."` | whatever the named class or expression decides | any type | a non-empty class name or a `"${...}"` expression | +| `pattern: "..."` | nothing; it is the format a submitted date is parsed with, `datePattern` on the wire | `date` | quoted `SimpleDateFormat` letters, quoted runs, and non-letters, such as `"dd/MM/yyyy"` | A flag is on while written and off otherwise, so `required: false` is an error telling you to leave the setting out. The bounds fit one type each: `AbstractNumericValidator.validate` throws on a submitted value that is not a number and `AbstractTextValueValidator.validate` on one that is not text, so a `min` on a `string` field would deploy and then fail every submission. All four take an integer, since a `number` field is an Operaton `long`, whose validator parses the bound with `Long.parseLong`, and a length is a character count. +`validator` cannot be empty, the one shape `FormValidators.createValidator` refuses to deploy, and `pattern` builds a `java.text.SimpleDateFormat`, which throws on a letter outside that set. + +A literal default is converted through the field's type on every render, exactly as a submitted value is (`FormFieldHandler.createFormField`). +A `number` default has to be an integer, which `LongFormType.convertValue` reads with `Long.valueOf`; a `boolean` default has to be `true` or `false`, since `BooleanFormType.convertValue` reads anything else as `false` with no error at all; and a `date` default with no `pattern` setting is read against the engine's own default, `"dd/MM/yyyy"` (`ProcessEngineConfigurationImpl.initFormTypes`), so an ISO-shaped default such as `"2026-01-01"` is refused unless a `pattern` says otherwise. +A bare name or a `"${...}"` expression is left to the engine, since it is evaluated fresh at every render. An `enum` field lists its values in the braces, one `id "Label"` line each in the order the form offers them, the label optional. Its default, when it is a literal, has to name one of them: `FormFieldHandler.createFormField` converts the default through the type on every render of the form, so a default naming no value deploys and then fails to open. @@ -198,9 +213,11 @@ process gym-membership { A `service` task takes exactly one of five binding attributes. `class` is a Java delegate class name, `expression` maps to `operaton:expression`, `delegate` is the friendlier spelling of `operaton:delegateExpression`, `topic` hands the work to an external worker through `operaton:type="external"` and `operaton:topic`, and `type` selects a behaviour Operaton builds itself, see [Mail and shell tasks](#mail-and-shell-tasks). +`expression:` and `delegate:` take a `"${...}"` template or a bare name, never a quoted string: the engine evaluates the value, so quoted text under `delegate:` reaches it as a string that resolves to no delegate, and under `expression:` as a string that evaluates to itself and runs nothing. +`class:` takes a class name alone, loaded as written, so a template there is not evaluated. `resultVariable` names the process variable the invocation's return value is stored in, and is not itself a binding. It is legal beside `expression:`, where the engine stores the return value, and beside `decision:` on a `decide` task, where it holds the decision result. -Beside `topic:` and `type:` it is accepted and ignored, and beside `class:` and `delegate:` it is refused, because the engine refuses to deploy that combination. +Beside `topic:` and `type:` it is deployed and never written, since `BpmnParse.parseServiceTaskLike` hands it to the `expression` binding alone, so the validator warns; beside `class:` and `delegate:` it is refused, because the engine refuses to deploy that combination. #### Send, receive, and decision tasks @@ -213,7 +230,8 @@ With no `message` key it still waits, but names nothing to correlate on: only th A `decide` task answers a decision table when `decision` names one. `binding: latest` or `binding: deployment` pins the deployed version the same way a `call` does, `version: ` pins a specific one, and `mapDecisionResult` picks what lands in `resultVariable`: `singleEntry`, `singleResult`, `collectEntries`, or `resultList`. -With no `decision` key it falls back to the same five binding attributes a `service` task takes, serialized as a `bpmn:businessRuleTask` instead of a `bpmn:serviceTask`. +`mapDecisionResult` without a `resultVariable` is built and never applied (`DecisionEvaluationUtil.evaluateDecision` maps only when storing), so the validator warns. +With no `decision` key it falls back to the same five binding attributes a `service` task takes, serialized as a `bpmn:businessRuleTask` instead of a `bpmn:serviceTask`, and `binding`, `version` and `mapDecisionResult` are refused there, since `BpmnParse.parseBusinessRuleTask` reads them on the decision path alone. #### Mail and shell tasks @@ -242,12 +260,12 @@ A shell task needs `command`. Every shell field is a quoted literal, never a `"${...}"` expression, and its three flags, `wait`, `redirectError`, and `cleanEnv`, are written `true` or `false`. A shell task's output has no `resultVariable` of its own to hold it. `field outputVariable = "..."` names the process variable the command's stdout is written to, and `field errorCodeVariable = "..."` the one its exit code is written to. -`resultVariable` beside `type:` is accepted and ignored, the same as beside `topic:`. +`resultVariable` beside `type:` is deployed and never written, the same as beside `topic:`, and draws the same warning. #### Input and output parameters -`input = ` and `output = ` map data across a step's boundary as `operaton:inputParameter` and `operaton:outputParameter` entries in its `operaton:inputOutput` block, and are legal on a `user`, `service`, `script`, `step`, `send`, `receive`, or `decide` task, a `subprocess`, an `attempt` block, a `call`, and an `on` handler with no host. -A host-less handler takes them because it lowers to an event sub-process; a hosted `on :` handler lowers to a boundary event, which carries none. +`input = ` and `output = ` map data across a step's boundary as `operaton:inputParameter` and `operaton:outputParameter` entries in its `operaton:inputOutput` block, and are legal on a `user`, `service`, `script`, `step`, `send`, `receive`, or `decide` task, a `subprocess`, an `attempt` block, and a `call`. +An `on` handler takes none in either form: a hosted `on :` handler lowers to a boundary event and a host-less one to an event sub-process, and the engine refuses to deploy either with one (`BpmnParse.parseBoundaryEvents`, `BpmnParse.checkActivityInputOutputSupported`). A value is an ordinary expression, a `[ ... ]` list, a `{ key: value }` map, or a fenced script that computes it, and lists and maps nest freely. Entries keep the order they were written in, and a name may repeat across the two directions but not within one. @@ -290,7 +308,8 @@ A worker fetching the task with `includeExtensionProperties` receives the list. An `error when ` line is an `operaton:errorEventDefinition errorRef= expression=`: it raises the declared code (see [The event layer](#the-event-layer)) when the condition holds, checked on a failure the worker reports and again on a completion. `ExternalTaskEntity.evaluateThrowBpmnError` runs the mappings in the order written, and the first true one wins. Beside the process variables the condition may read `externalTask.errorMessage`, `externalTask.errorDetails`, and `externalTask.retries`, the names the engine resolves on an external task's execution and nowhere else, so `externalTask` inside a mapping draws no undeclared-variable warning and in an `if` it does. -`parseExternalServiceTask` is the only reader of all three, so each is an error on a task bound with `class`, `expression`, `delegate`, `type`, or `decision`, and on a `throw message` or an `emit message` bound with a topic, which take none of them. +`parseExternalServiceTask` is the only reader of all three, so each is an error on a task bound with `class`, `expression`, `delegate`, `type`, or `decision`. +Each is an error on a `throw message` or an `emit message` bound with a topic too: `parseServiceTaskLike` reads the priority and the mappings off the message definition there (the property list off the event element), but this surface writes none of them for a throw. ```bpmnscript process card-payment { @@ -312,13 +331,13 @@ An element's braces also hold listeners, written with `on ` and the thing An execution listener fires when the element starts or ends and is legal on every element that takes settings. A task listener fires on a step in a user task's lifecycle and is legal on a `user` task alone. -| Listener kind | Events | -| ----------------- | ------------------------------------------------------------- | -| execution | `start`, `end` | -| task, `user` only | `create`, `assign`, `complete`, `update`, `delete`, `timeout` | +| Listener kind | Events | +| ----------------- | ----------------------------------------------------------------- | +| execution | `start`, `end` | +| task, `user` only | `create`, `assignment`, `complete`, `update`, `delete`, `timeout` | A listener runs exactly one thing: `class`, `expression`, or `delegate` in its own parens, or a fenced script whose opening tag names the language. -That is the same choice a service task binding makes, without the external `topic` or the built-in `type`. +That is the same choice a service task binding makes, without the external `topic` or the built-in `type`, and each value takes the shape it takes there: a class name under `class`, a `"${...}"` template or a bare name under `expression` and `delegate`. A `class` or `delegate` binding also opens a brace block of its own, holding injected fields the same way a `service`, `send`, or `decide` task's braces do; see [Field injection](#field-injection). `on timeout` carries the timer that says when it runs, written as an `after`, `at`, or `every` particle and a time, and no other event takes one. @@ -338,7 +357,8 @@ An `on` inside an element's braces is a listener; an `on` at statement position #### Script tasks -A `script` task's body is a markdown-style fenced block whose opening tag names the language: `javascript` or `js`, `groovy`, `python` or `py`, `ruby` or `rb`, and `feel`. +A `script` task's body is a markdown-style fenced block whose opening tag names the language: `juel`, `javascript`/`js` (or `ecmascript`), `groovy`, `python`/`py`, `ruby`/`rb`, and `feel`. +`juel` is the only one every Operaton deployment can run out of the box; the others need a JSR-223 engine on the deployment's classpath, which `ScriptingEngines.getScriptEngineForLanguage` resolves the first time the script runs rather than at deployment. #### Sub-processes @@ -357,7 +377,7 @@ A `call` reads like a function call at the process boundary. A `local` mapping (`in local x = y`) reads from or writes to variables belonging to the call step itself rather than to the whole process. It's rare when authoring, and exists mainly so diagrams that use step-local variables import faithfully. -`mapper: ""` and `mapperDelegate: "${bean}"` name a Java class or a bean that computes the mapping in code, `operaton:variableMappingClass` and `operaton:variableMappingDelegateExpression` on the wire. +`mapper: ""` and `mapperDelegate: "${bean}"` name a Java class or a bean that computes the mapping in code, `operaton:variableMappingClass` and `operaton:variableMappingDelegateExpression` on the wire; they take the shapes `class` and `delegate` take on a `service` task. Either one names an implementation of Operaton's `DelegateVariableMapping`, whose `mapInputVariables` runs after the declared `in` mappings and whose `mapOutputVariables` runs after the declared `out` ones. A mapper therefore adds to what the call declares rather than standing in for it, and sits beside any `in` and `out` mappings the same call carries. Writing both spellings on one call is an error, since Operaton would read the class and drop the delegate expression without saying so. @@ -388,6 +408,8 @@ It is legal on `user`, `service`, `script`, `step`, `send`, `receive`, `decide`, | `for 3` | three times | | `for 3 each line in lines` | three times, run `n` still seeing element `n` of `lines` | +A literal count is a non-negative whole number, quoted or not; a bare name or a `"${...}"` expression is left to the engine, which reads a constant count as text with `Integer.parseInt` and truncates any other number with `intValue()` (`MultiInstanceActivityBehavior.resolveLoopCardinality`). + A count and a collection are not alternatives. Writing both lets the count decide how many runs there are while each run still binds its element. A collection written as a plain name is the process variable of that name, and anything else is written as a quoted expression, so `for each line in "${order.lines}"` reads a property where `for each line in lines` reads a variable. @@ -397,7 +419,8 @@ The runs happen at once, which is the engine's own default, unless `sequentially `until ( )` drops the runs still outstanding, once the condition holds. Operaton keeps `nrOfInstances`, `nrOfActiveInstances`, and `nrOfCompletedInstances` on the repetition as a whole and evaluates the completion condition there, so those three are the names the condition can read. -`loopCounter` is set on each run instead, which makes it a name the step's own body and its `input` parameters read rather than the condition. +`loopCounter` is set on each run and is readable there too: in the run's body, its `input` parameters, and the `until` condition alike. +A sequential repetition evaluates the condition on the run that just finished, since only one run is ever active (`SequentialMultiInstanceActivityBehavior.complete`); a parallel one evaluates it on whichever concurrent run just ended, not the whole set, so `loopCounter` there names that one run's index (`ParallelMultiInstanceActivityBehavior.concurrentChildExecutionEnded`). All four are in scope without a `var` declaration, as is the element name. ```bpmnscript @@ -413,10 +436,11 @@ process invoice-batch { } ``` -`asyncBefore` in a repeated step's settings puts one job around the whole repetition rather than one job per run. +`asyncBefore`, `asyncAfter`, `exclusive`, and `retryCycle` in a repeated step's settings put one job around the whole repetition rather than one job per run. `runAsyncBefore`, `runAsyncAfter`, `runExclusive`, and `runRetryCycle` put the matching job on each run instead, written in the same parens as the step's own settings. -There is no `runJobPriority`: the engine reads a job priority off the step alone, so writing one on the repetition would be honored by nothing. +There is no `runJobPriority`: `jobPriority` prices the per-run job on its own, since `createActivityOnScope` reads it off the step regardless of what repeats around it; the whole-loop job takes the process's own priority, and no attribute writes one there. A `run*` key on a step with no `for` clause is an error, since one job per run means nothing where there is no run. +`exclusive` or `retryCycle` needs `asyncBefore` or `asyncAfter` beside it for the whole-loop job, and `jobPriority`, `runExclusive`, or `runRetryCycle` needs `runAsyncBefore` or `runAsyncAfter` beside it for the per-run job, or the setting configures nothing (warning). ```bpmnscript process order-notifications { @@ -440,9 +464,10 @@ The head decides the element for the whole statement, not just for its own branc No condition anywhere on the statement compiles to the ordinary `bpmn:parallelGateway` fork and join. A condition on any one branch compiles both the fork and the join to `bpmn:inclusiveGateway` instead, and every unconditioned sibling still runs, alongside whichever conditioned branches also matched. -The compiler always emits a fallback flow out of an inclusive fork, whether or not an `else` branch is written. -Operaton only takes that flow when none of the fork's other flows were taken, so a fork with every branch conditioned and no fallback at all would still deploy, then stop with a stuck execution the first time nothing matches. -Writing `else` gives the fallback a body to run before the join; leaving it out routes the fallback straight to the join. +The compiler emits a fallback flow out of an inclusive fork only where Operaton can take it. +The engine takes that flow when none of the fork's other flows were taken, so a fork with every branch conditioned and no fallback at all would still deploy, then stop with a stuck execution the first time nothing matches. +Writing `else` gives the fallback a body to run before the join; leaving it out routes the fallback straight to the join when every branch carries a condition. +A branch with no head is taken every time, so beside one no fallback is written at all. ```bpmnscript process expense-approval { @@ -662,6 +687,7 @@ process order-intake { Four words are legal there: `message`, `signal`, `timer`, and `condition`, each with the payload it already carries elsewhere, a name for the first two, a duration or an `at` or `every` key for the third, and the condition expression, the same clause `on condition` and `await condition` carry, for the fourth. A subprocess start and an event-handler start take none, because both are entered by their container rather than by an event of their own; the validator rejects a trigger written on either. +Neither takes `initiator` or a `form` block: `BpmnParse.parseScopeStartEvent` reads neither off a start that is not the process's own, so the validator rejects both there too. Writing `error`, `escalation`, or `compensation` on a process start is rejected too, for a different reason: Operaton's own start-event parser does not branch on those three, so the engine ignores the trigger and starts the process exactly as if none were written, and this surface refuses to compile XML the engine would disregard. @@ -682,6 +708,8 @@ A `start` opens a chain and takes no incoming flow, so it sits first, right afte Starts written back to back share the chain that follows, which is how `ByAgent` and `ByEmail` both enter `Triage`, and a start after an `end` opens a chain of its own that reaches a shared step by `goto`. A start after a step whose flow is still live is an error, since the page would not say whether that flow runs past the start or stops at it. A subprocess, an `attempt` block, and an event-handler body keep exactly one start, because Operaton's `BpmnParse.parseScopeStartEvent` rejects a second start on any scope that is not a process. +A process keeps one plain or timer start, because `BpmnParse.selectInitial` rejects a second, and one message or signal start per name and one condition start per condition, because `BpmnParse.addEventSubscriptionDeclaration` rejects a repeat; the validator refuses each of those before the engine does. +A body that does not open with a `start` gets a plain start of its own from the compiler, and that one is in the count: a plain or timer `start` further down such a body is the second one, so it needs a message, signal, or condition trigger, or has to open the body itself. Two warnings follow from how the engine picks a default start. A process whose starts are all message, signal, or condition starts has no default, so the engine can create an instance only by triggering one of them, and starting it by key fails at runtime. @@ -784,7 +812,8 @@ The editor still highlights and completes them, but only in the positions where ### Expressions Condition expressions are parsed as a real AST over the JUEL native subset: integer, decimal, string, boolean, and null literals; variable references with dot-property and index accessors; unary `!` and `-`; binary arithmetic and comparison; logical `&&` and `||`; the ternary `? :`; and parentheses. -Anything outside that subset uses the quoted raw fallback, `"${...}"`. +Anything outside that subset uses the quoted raw fallback, `"${...}"` or `"#{...}"`, carried as written. +Both openers are the ones the engine evaluates, and a raw template is read with the same escapes as a string literal, so `"${fn(\"a\")}"` is the expression `${fn("a")}`. `on condition(...)` reuses the same grammar, so a variable read inside it goes through the same undeclared-variable and type checks as one read inside an `if`. ## Diagnostics @@ -792,28 +821,32 @@ Anything outside that subset uses the quoted raw fallback, `"${...}"`. Validation beyond syntax lives in `src/bpmn-script-validator.ts`, which is the source of truth for the exact rules and their wording. The categories it covers: -- Variables: an undeclared reference (warning), a type mismatch against the declared `var`, a name declared twice. -- Tasks: a duplicate attribute key, a `service`, `send`, or `decide` task without exactly one binding attribute, a `resultVariable` beside a `class` or `delegate` binding, which the engine refuses to deploy, a `mapDecisionResult` outside the four result mappings, a `script` task with an unsupported fence tag or an empty or unterminated body. +- Variables: an undeclared reference (warning), a type mismatch against the declared `var`, a name declared twice, a JUEL keyword or a hyphen in a name a rendered expression carries, a JUEL keyword read as a property inside a `"${...}"` template (`"${order.and}"`, outside its string literals), a condition whose static type is not boolean, and a composite `"${...} ${...}"` template under an operator. +- Tasks: a duplicate attribute key, a `service`, `send`, or `decide` task without exactly one binding attribute, a binding value in the wrong shape (quoted text under `expression`, `delegate`, or `mapperDelegate`, a template under `class` or `mapper`, an empty `class`, `expression`, `delegate`, `mapper`, `mapperDelegate`, `topic`, `decision`, or `process`), a `resultVariable` beside a `class` or `delegate` binding, which the engine refuses to deploy, or beside `topic` or `type`, which nothing writes (warning), a `mapDecisionResult` outside the four result mappings or without a `resultVariable` to map into (warning), `binding`, `version`, or `mapDecisionResult` on a `decide` step with no `decision`, a `script` task with an unsupported fence tag or an empty or unterminated body. + A user task adds its own: a `priority` that is neither an integer nor a `"${...}"` expression, and a `dueDate` or `followUpDate` constant that is neither a `P...` period nor an ISO date-time. + A `version` on a `formRef`, a `call`, or a `decide` step that is not a positive whole number or an expression is refused on all three alike. An external task's extras add their own: a `taskPriority` or an `error ... when` mapping on a step not bound with `topic`, a mapping headed by a word other than `error` or missing `when`, a mapping naming an undeclared code, and a `taskPriority` or `jobPriority` that is neither an integer nor a `"${...}"` expression. A `service`, `send`, or `decide` task bound with `type` adds its own: a required field its type's own parse requires missing, a field name its behaviour class does not declare, and, on a shell task, a field carried as an expression or a flag other than `true`/`false`. -- Settings: a key the element does not own, a value in a shape its lowering cannot read (a quoted `asyncBefore`, an unquoted `versionTag`), a `form` block on an element that renders none, and a process header carrying a key it does not own. +- Settings: a key the element does not own, a value in a shape its lowering cannot read (a quoted `asyncBefore`, an unquoted `retryCycle`), a `retryCycle` the engine's retry parser cannot read, which is dropped rather than refused (warning), a `for` clause's count that is not a non-negative whole number, a variable, or an expression yielding one, a `form` block on an element that renders none, a process header carrying a key it does not own, and an unkeyed value in the parens of an element with no payload to read it as (a task, a call, a subprocess, a listener, the process header, or a `start` or `end` with no trigger, where `start S("PT30M")` is a timer payload missing its `timer` word). + A process header adds its own, since the engine stores every header value as written: a `historyTimeToLive` outside `PD` or ``, a `versionTag` that is not a quoted string or runs past 64 characters, a `"${...}"` template anywhere in `candidateStarterUsers` or `candidateStarterGroups`, and a `label` or `documentation` that is not a quoted string. A gateway statement's parens add their own: a bare value rather than a `key: value` setting, a `join`-prefixed key on a `while` or `do...while` loop, and `asyncAfter` on a multi-branch `await` head. A `join`-prefixed key on a statement whose branches all terminate warns, since the join it would set is pruned. A `run*` key on a step with no `for` clause, and `runJobPriority`, which does not exist, since the engine reads a job priority off the step alone. -- Parameters: a direction word the owner doesn't take, a parameter on an element that carries none, a name repeated within one direction, and an `output` mapping on a repeated step. - A `field` also errors on a kind that takes none, when its binding is anything other than `class`, `delegate`, or `type`, and when its value is neither a quoted string nor a `"${...}"` expression. + `exclusive`, `jobPriority`, or `retryCycle`, plain, `join`-, or `run`-prefixed, without the matching `asyncBefore`/`asyncAfter` beside it configures no job (warning); a timer carrier is exempt, since the timer itself creates the job, and a signal start, an `await signal`, or a signal race branch is exempt for `jobPriority`, which prices its subscription job (a boundary or handler signal catch declares none and keeps the warning). +- Parameters: a direction word the owner doesn't take, a parameter on an element that carries none, a name repeated within one direction, an `output` mapping on a repeated step, an empty or blank string as a value, a list item, or a map entry's value, which the engine trims and reads as no value at all rather than as an empty string (warning), and the same fence rules a `script` task's body follows on a fenced script anywhere in a value, a list, or a map, since the engine resolves that tag only when the parameter is first evaluated. + A `field` also errors on a kind that takes none, when its binding is anything other than `class`, `delegate`, or `type`, when its value is neither a quoted string nor a `"${...}"` expression, when it is empty, which deploys as neither a fixed value nor an expression, and when its quoted string opens with an escaped `\${` or `#{`, which has no fixed-text slot. A `property` line errors the same way on a kind that takes none and on a `service`, `send`, or `decide` step not bound with `topic`. - Listeners: an event word the element does not have, a binding count other than one, a missing timer on `on timeout` or a timer on any other event, a repeated event on one element, and the same fence rules a `script` body follows. -- Structure: an empty process, subprocess, or handler body, an empty branch or loop body (warning), an unreachable statement, a process-level `start` after a step whose flow still runs on, a `start` anywhere but first in its subprocess, attempt block, or handler body, a process with several starts and no plain or timer one among them (warning), a `form` block on a start that is not the default one (warning), a `goto` reaching into a `parallel` or `await` branch from outside it, a second `else` branch on a `parallel` statement, an `else` branch with no conditioned sibling, and an `else` branch beside a sibling carrying no condition. -- Names: a reused process name, step name, or `label`, and a name matching a synthesized-id pattern ([ADR-0010](../../docs/decisions/0010-deterministic-structural-ids.md)). +- Structure: an empty process, subprocess, or handler body, an empty branch (warning), an empty loop body, which would drop the loop and its condition, a `do` body that always ends or redirects the flow, which would leave the loop gateway with no incoming flow, a parameter or listener written inside a body rather than in an element's attribute block, an unreachable statement, a process-level `start` after a step whose flow still runs on, a `start` anywhere but first in its subprocess, attempt block, or handler body, a second plain or timer start on a process, counting the plain start the compiler adds to a body that does not open with a `start`, a second message, signal, or condition start repeating an earlier one's payload, `initiator` or a `form` block on a start inside a subprocess, attempt block, or handler body, a process with several starts and no plain or timer one among them (warning), a `form` block on a start that is not the default one (warning), a `goto` reaching into a `parallel` or `await` branch from outside it, a second `else` branch on a `parallel` statement, an `else` branch with no conditioned sibling, and an `else` branch beside a sibling carrying no condition. +- Names: a reused process name, step name, or `label`, a step name equal to the process id, which the compiled document can hold only once, and a name matching a synthesized-id pattern, the desugarer's own or the layouter's `_di`, `BPMNDiagram_`, and `BPMNPlane_` ids ([ADR-0010](../../docs/decisions/0010-deterministic-structural-ids.md)). - Call activities: a missing `process`, an unknown `binding` value, `binding` and `version` together, `mapper` and `mapperDelegate` together, and duplicate `in` or `out` mappings. A `decide` step pins its decision table with `binding` and `version`, under those same two rules. -- Form fields: a type outside the five, a repeated field id, a field typed against a `var` of its name, a settings key outside the seven constraints and `pattern`, a constraint on a type its validator refuses or a `pattern` off a `date` field, a value in a shape the engine cannot parse (`required: false`, a bound that is not an integer, an empty `pattern`), a value line on a field that is not an `enum`, an `enum` offering no values (warning), a repeated value id, a literal default naming none of the values, a block member that is not a `property` line, and a property value that is neither a quoted string nor a `"${...}"` expression. +- Form fields: a type outside the five, a repeated field id, a field typed against a `var` of its name, a settings key outside the seven constraints and `pattern`, a constraint on a type its validator refuses or a `pattern` off a `date` field, a value in a shape the engine cannot parse (`required: false`, a bound that is not an integer, an empty `pattern` or `validator`, a `pattern` holding a letter `SimpleDateFormat` does not read), a literal default that is not what its type converts (`number`, not an integer; `boolean`, not `true` or `false`; `date` with no `pattern`, an ISO date rather than the engine's own `dd/MM/yyyy`), a value line on a field that is not an `enum`, an `enum` offering no values (warning), a repeated value id, a literal default naming none of the values, a block member that is not a `property` line, and a property value that is neither a quoted string nor a `"${...}"` expression. - Form references: `formKey` beside `formRef` on a user task, a `formRef` with neither `binding` nor `version`, and `binding` or `version` with no `formRef` to pin, under the same `binding`/`version` exclusivity a `call` and a `decide` step already use. -- Events: a trigger word outside the set its verb accepts, a payload that doesn't match its trigger's shape, a handler in the wrong container or not at the end of its body, `alongside` on `error`, `compensation`, or `cancel`, two handlers that would catch the same thing, a host that isn't an activity a token can sit at, a binding attribute on a `throw` or `emit` whose trigger is not `message`, and more than one binding on one whose trigger is. +- Events: a trigger word outside the set its verb accepts, a payload that doesn't match its trigger's shape, a handler in the wrong container or not at the end of its body, `alongside` on `error`, `compensation`, or `cancel`, two handlers registering one catch on one engine scope (a handler inside a block and one attached to that block share it), an escalation catch-all beside a coded catch of its own kind, two branches of one `await` block on one message or signal name, a timer job setting written on both an `on timer` head and its body's start, a host that isn't an activity a token can sit at, a binding attribute on a `throw` or `emit` whose trigger is not `message`, and more than one binding on one whose trigger is. A link pair adds its own: an `await link` after a statement whose flow still runs on, an `emit link` with no catch of its name, a second `await link` of one name anywhere in the file, a pair split across containers, an `emit link` reaching into a `parallel` or `await` branch from outside, `link` heading a race branch, a `goto` onto a link catch, an engine setting or listener on `emit link`, a `throw link`, and an `await link` nothing emits (warning). - The cancel construct: a cancel end outside an `attempt` block, a cancel handler with no host or on a host that is not one, and either half of the pair written without the other (warning). -- Timers: an unknown particle, a value that doesn't look like its particle expects (warning), and a repeating `every` on an interrupting handler (warning). +- Timers: an unknown particle, and a value that does not fit its particle's shape (`after` an ISO 8601 duration on its own or beside a start or end date-time, `at` a period or an ISO date-time, `every` an `R/...` repeat, a six-field cron, or a cron nickname such as `@daily`): an error on a timer start, which the engine parses at deployment, and a warning on every other carrier (an awaited timer, a race branch, an `on` handler, a timeout listener), which reads it only once its scope is entered; plus a repeating `every` on an interrupting handler (warning). Diagnostics name the valid alternative rather than reporting an unknown identifier, so `emit error` points at `throw error` and `binding = version` points at `version = `. @@ -884,7 +917,7 @@ npm test | `src/bpmn-script-parser-error-message-provider.ts` | Guidance for a reserved word used as an identifier | | `src/bpmn-script-completion.ts` | Snippet completions for the structural keywords and for the settings an element holds | | `src/bpmn-script-semantic-tokens.ts` | Highlights the soft words (trigger, attribute key, parameter direction, listener event) | -| `src/bpmn-script-value-converter.ts` | Unquotes a timer's `"${...}"` time so both `time` alternatives carry the same shape | +| `src/bpmn-script-value-converter.ts` | Reads a raw template, `"${...}"` or `"#{...}"`, with the string escapes a literal gets | | `src/bpmn-script-module.ts` | Langium dependency injection wiring | | `src/expression-render.ts` | `renderExpression(astNode): string`, serializing a parsed expression AST to `${...}` text | | `src/variable-symbol-provider.ts` | `VariableSymbolProvider`, collecting `var` declarations and the names a repetition brings into scope | diff --git a/packages/language/src/bpmn-script-validator.ts b/packages/language/src/bpmn-script-validator.ts index b23eb8c..f211233 100644 --- a/packages/language/src/bpmn-script-validator.ts +++ b/packages/language/src/bpmn-script-validator.ts @@ -5,6 +5,7 @@ import { type ValidationChecks, } from 'langium'; import type { + Accessor, Additive, Block, BpmnScriptAstType, @@ -67,20 +68,27 @@ import { type TimerPayload, } from './paren-items.js'; import { + isAccessor, isAdditive, isSetting, isBlock, isCallActivity, isCodeDecl, isDoWhileStatement, + isElseIf, isEmitStatement, isEndEvent, isErrorMapping, isExpr, + isFormField, isGotoStatement, isIfStatement, isIntermediateCatchEvent, + isListener, isLiteralBool, + isLiteralDecimal, + isLiteralInt, + isLiteralNull, isLiteralString, isLogical, isMapEntry, @@ -88,16 +96,20 @@ import { isOnHandler, isParallelBranch, isParallelStatement, + isParen, isParenValue, isProcess, isRaceBranch, isRaceStatement, isRawExpr, isRelational, + isScriptLiteral, isServiceTask, isStartEvent, isSubProcess, + isTernary, isThrowStatement, + isUnary, isUserTask, isVarDecl, isVariableMapping, @@ -106,11 +118,14 @@ import { } from './generated/ast.js'; import { integerLiteralText, + renderExpression, renderExpressionInner, + singleTemplateBody, } from './expression-render.js'; import type { BpmnScriptServices } from './bpmn-script-module.js'; import { ATTEMPT_BLOCK_RULE, + ASYNC_FLAG_KEYS, ATTRIBUTE_BLOCK_RULES, attributeBlockRuleOf, BUILTIN_FIELD_NAMES, @@ -130,6 +145,7 @@ import { ERROR_MAPPING_WHEN, EVENT_BINDING_FIELDS, EXECUTION_LISTENER_EVENTS, + EXPRESSION_OPEN, EXTERNAL_TASK_EL_NAME, FIELD_BINDING_KEYS, FIELD_DIRECTION, @@ -146,6 +162,7 @@ import { JOIN_ENGINE_KEYS, JOIN_KEY_BY_ENGINE_KEY, joinSettingKey, + JUEL_RESERVED_WORDS, LISTENER_BINDING_KEYS, listenerEventsFor, ON_TRIGGERS, @@ -155,6 +172,7 @@ import { RUN_ENGINE_KEYS, runSettingKey, SCRIPT_FORMAT_ALIASES, + scriptFormatOf, SERVICE_TASK_BINDING_KEYS, SHELL_FLAG_FIELDS, SHELL_FLAG_LITERALS, @@ -164,6 +182,7 @@ import { TASK_PRIORITY_KEY, THROW_BINDING_KEYS, THROW_TRIGGERS, + TIMER_JOB_KEYS, TIMER_PARTICLES, TRIGGER_PAYLOAD, TYPE_BINDING_KEY, @@ -182,6 +201,7 @@ import { import { isRepeated, type VariableSymbolProvider, + type VariableTable, } from './variable-symbol-provider.js'; export function registerValidationChecks(services: BpmnScriptServices) { @@ -212,6 +232,7 @@ export function registerValidationChecks(services: BpmnScriptServices) { EmitStatement: validator.checkEmitStatement, IntermediateCatchEvent: validator.checkIntermediateCatchEvent, ParenValue: validator.checkParenValue, + Block: validator.checkBlock, }; registry.register(checks, validator); } @@ -239,15 +260,22 @@ const engineSpellings = (key: string): string[] => [ /** * Keys whose value names something outside process-variable scope, so a - * bareword there must not warn about an undeclared variable. `jobPriority`, - * `taskPriority`, `priority`, and `businessKey` stay out: a bareword there - * lowers to `${...}` and does name a variable. The date keys are here because - * `dueDate = deadline` emits `operaton:dueDate="deadline"`, which Operaton - * cannot parse as a date, so declaring `deadline` would hide the warning and - * leave the attribute just as broken; {@link - * BpmnScriptValidator.checkAttributeValues} asks for a quoted date instead. + * bareword there must not warn about an undeclared variable, nor be scanned + * for the JUEL spelling rules. `jobPriority`, `taskPriority`, `priority`, and + * `businessKey` stay out: a bareword there lowers to `${...}` and does name a + * variable. `assignee` is in: a bareword lowers to the plain text + * `operaton:assignee="demo"`, which `TaskDecorator.initializeTaskAssignee` + * reads as the user id `demo`, exactly as `candidateUsers` is read, and + * `label`, `documentation` and `versionTag` lower as plain text the same way. + * The date keys are here because `dueDate = deadline` emits + * `operaton:dueDate="deadline"`, which Operaton cannot parse as a date, so + * declaring `deadline` would hide the warning and leave the attribute just as + * broken; {@link BpmnScriptValidator.checkAttributeValues} asks for a quoted + * date instead. */ const NON_VARIABLE_ATTR_KEYS: ReadonlySet = new Set([ + 'label', + 'documentation', 'class', 'formKey', 'formRef', @@ -259,12 +287,14 @@ const NON_VARIABLE_ATTR_KEYS: ReadonlySet = new Set([ 'binding', 'version', 'mapDecisionResult', + 'assignee', 'candidateGroups', 'candidateUsers', 'dueDate', 'followUpDate', ...engineSpellings('retryCycle'), 'resultVariable', + 'versionTag', 'historyTimeToLive', 'candidateStarterUsers', 'candidateStarterGroups', @@ -277,28 +307,151 @@ const BOOLEAN_ATTR_KEYS: ReadonlySet = new Set( ['asyncBefore', 'asyncAfter', 'exclusive'].flatMap(engineSpellings), ); +/** Each timer job key is dead on an element with no job, so written alone it draws a warning. */ +const TIMER_JOB_KEYS_WITHOUT_PRIORITY: readonly string[] = + TIMER_JOB_KEYS.filter((key) => key !== 'jobPriority'); +const TIMER_JOB_KEY_SET: ReadonlySet = new Set(TIMER_JOB_KEYS); + +/** A user task's own priority, `operaton:priority`, beside the job and external-task ones. */ +const USER_PRIORITY_KEY = 'priority'; + +/** The two dates a user task carries, read through one business calendar. */ +const DUE_DATE_KEYS: readonly string[] = ['dueDate', 'followUpDate']; +const DUE_DATE_KEY_SET: ReadonlySet = new Set(DUE_DATE_KEYS); + /** * Keys `BpmnParse.parsePriority` reads: a constant there must parse as an * integer or the deployment fails, so anything else has to be an expression. + * A user task's `priority` deploys unchecked and is held to the same shape + * by `TaskDecorator.initializeTaskPriority` when the task is created. */ const PRIORITY_ATTR_KEYS: ReadonlySet = new Set([ ...engineSpellings('jobPriority'), TASK_PRIORITY_KEY, + USER_PRIORITY_KEY, ]); +/** Every spelling `retryCycle` carries, shared between the key-membership check and its shape check. */ +const RETRY_CYCLE_ATTR_KEYS: ReadonlySet = new Set( + engineSpellings('retryCycle'), +); + /** * Keys whose value the engine parses rather than takes as written, so a * bareword or a number there reaches it as something it cannot read. The other - * text keys stay out: the engine takes them as written. + * text keys stay out: the engine takes them as written, and the process + * header's keys have rules of their own ({@link PROCESS_HEADER_VALUE_RULES}). */ const TEXT_ATTR_KEYS: ReadonlySet = new Set([ - 'versionTag', - 'historyTimeToLive', - ...engineSpellings('retryCycle'), - 'dueDate', - 'followUpDate', + ...RETRY_CYCLE_ATTR_KEYS, + ...DUE_DATE_KEYS, ]); +/** + * `TaskDecorator.initializeTaskDueDate` hands a string constant to + * `DueDateBusinessCalendar.resolveDuedate`, which parses a `P...` period or + * an ISO date-time and fails the task creation on anything else. + */ +const DUE_DATE_TEXT = /^(P|\d{4}-\d{2}-\d{2})/; + +/** + * `BaseCallableElement.getVersion` and + * `TaskEntity.initializeFormRefFromTaskDefinition` parse the value as an + * integer when the step runs, so a constant is the digits of a deployed + * version, and a decimal deploys and then fails the instance. A bare name is + * refused too, since `version` is in `NON_VARIABLE_ATTR_KEYS` and the name + * would never be checked against the declarations; a raw template is the + * spelling for a computed version. + */ +const VERSION_TEXT = /^[1-9]\d*$/; + +/** The one shape `ParseUtil.parseHistoryTimeToLive` reads: `^P(\d+)D$`, else `Integer.parseInt`. */ +const HISTORY_TIME_TO_LIVE_TEXT = /^(P\d+D|\d+)$/; + +/** `ACT_RE_PROCDEF.VERSION_TAG_` is 64 characters wide in every `create.engine.sql`; nothing checks the length before the insert. */ +const VERSION_TAG_MAX_LENGTH = 64; + +/** A template anywhere in the text, not only at its start, since a starter list is split at commas first. */ +const TEMPLATE_ANYWHERE = /[$#]\{/; + +/** + * The duration `DurationHelper.parsePeriod` reads (no anchors, for reuse in an + * interval and a retry cycle's comma list): `PW` on its own, or the XML + * Schema duration `DatatypeFactory.newDuration` parses, which has no `W` and + * throws on a bare `P` or a `T` with nothing after it, so `P1W2D` deploys on a + * timer start and fails there. + */ +const ISO_DURATION_BODY = String.raw`(?:P\d+W|P(?!$)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?)`; + +/** + * The period `DueDateBusinessCalendar.resolveDuedate` reads under `at`: Joda's + * `ISOPeriodFormat.standard`, which takes `W` beside the other fields. + */ +const JODA_PERIOD_BODY = String.raw`P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?`; + +/** + * `DateTimeUtil.parseDateTime` is Joda's `ISODateTimeFormat.dateTimeParser`: + * a calendar date (a year with an optional month and day), an ordinal date + * (`2026-100`) or a week date (`2026-W10-1`), then an optional `T` with an + * optional time down to hours and an optional zone, `Z` or an offset down to + * hours, the colon optional. The zone sits inside the `T` group: Joda's + * grammar is `date-element ['T' [time-element] [offset]]`, so `2026-12-01T` + * parses and `2026-12-01Z` is refused. Joda is looser still (single-digit + * fields, a time with no date); those spellings stay refused here. + */ +const ISO_DATE_TIME_BODY = String.raw`(?:\d{4}(-\d{2}(-\d{2})?)?|\d{4}-\d{3}|\d{4}-W\d{2}(-\d)?)(T(\d{2}(:\d{2}(:\d{2})?)?([.,]\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)?)?`; + +/** + * The interval `DurationHelper.parseDurationOrTime` reads: a duration with an + * optional end date, or a start date with a duration or an end date. + */ +const INTERVAL_BODY = String.raw`(?:${ISO_DURATION_BODY}(?:/${ISO_DATE_TIME_BODY})?|${ISO_DATE_TIME_BODY}/(?:${ISO_DURATION_BODY}|${ISO_DATE_TIME_BODY}))`; + +/** A `DurationHelper` repeat: `R`, an optional count, then the interval. */ +const REPEAT_BODY = String.raw`R\d*/${INTERVAL_BODY}`; + +/** + * `after`: an interval, the shape `DurationBusinessCalendar.resolveDuedate` + * parses. A repeat parses there too (`DurationHelper.parseRecurrence`) and + * the timer then fires once at the first interval, since only a cycle sets a + * repeat on its job (`TimerDeclarationImpl.initializeConfiguration`). + */ +const AFTER_TIME_TEXT = new RegExp(`^(${INTERVAL_BODY}|${REPEAT_BODY})$`); + +/** `at`: a point in time, or a period counted from now (`DueDateBusinessCalendar.resolveDuedate` reads both). */ +const AT_TIME_TEXT = new RegExp( + `^(${JODA_PERIOD_BODY}|${ISO_DATE_TIME_BODY})$`, +); + +/** + * `CronTimer.parse` reads a six-field Spring cron (`CronType.SPRING53`, which + * also takes these seven nicknames and not `@reboot`): a seventh field fails + * at deployment. + */ +const CRON_BODY = String.raw`(?:\S+(\s+\S+){5}|@(?:yearly|annually|monthly|weekly|daily|midnight|hourly))`; + +/** `every`: a repeat or a cron expression (`CycleBusinessCalendar.resolveDuedate`). */ +const EVERY_TIME_TEXT = new RegExp(`^(${REPEAT_BODY}|${CRON_BODY})$`); + +/** + * `retryCycle`: a single repeat, or one-or-more comma-separated durations. + * `ParseUtil.parseRetryIntervals` only reads the `R.../...` repeat shape when + * it is the sole interval; every other spelling is a plain list of durations. + */ +const RETRY_CYCLE_TEXT = new RegExp( + `^(${REPEAT_BODY}|${ISO_DURATION_BODY}(,${ISO_DURATION_BODY})*)$`, +); + +/** A non-negative whole number, bare or quoted, as {@link FORM_BOUND_TEXT} admits a signed one. */ +const NON_NEGATIVE_INT_TEXT = /^\d+$/; + +/** `DateFormType`'s constructor builds a `java.text.SimpleDateFormat` from `pattern`, which reads only these letters, a quoted literal run, or a non-letter. */ +const PATTERN_LETTERS_TEXT = + /^(?:'[^']*'|[^A-Za-z]|[GyYMLwWDdFEuaHkKhmsSzZX])*$/; + +/** The ISO shape a date field's default is refused in when no `pattern` overrides the engine's own "dd/MM/yyyy" (`ProcessEngineConfigurationImpl.initFormTypes`). */ +const ISO_DATE_ONLY_TEXT = /^\d{4}-\d{2}-\d{2}$/; + const ON_TRIGGERS_SET: ReadonlySet = new Set(ON_TRIGGERS); const END_TRIGGERS_SET: ReadonlySet = new Set(END_TRIGGERS); const CATCH_TRIGGERS_SET: ReadonlySet = new Set(CATCH_TRIGGERS); @@ -358,27 +511,22 @@ const EXECUTION_LISTENER_EVENT_SET: ReadonlySet = new Set( const TASK_LISTENER_EVENT_SET: ReadonlySet = new Set( TASK_LISTENER_EVENTS, ); -const SUPPORTED_SCRIPT_TAGS: ReadonlySet = new Set( - Object.keys(SCRIPT_FORMAT_ALIASES), -); - /** * Read off the block rules so the message cannot name a set the checks do not - * enforce. `attempt` shares its AST type with `subprocess` and the host-less - * `on` handler shares one with the hosted form, so neither has a row of its - * own to read and both are added back here. + * enforce. `attempt` shares its AST type with `subprocess`, so it has no row + * of its own to read and is added back here. */ -const PARAMETER_HOSTS_MESSAGE = `parameters belong on ${[ - ...Object.values(ATTRIBUTE_BLOCK_RULES), - ATTEMPT_BLOCK_RULE, -] - .filter((rule) => rule.parameters) - .map((rule) => rule.description) - .join(', ')}, and an 'on' handler with no host.`; +const PARAMETER_HOSTS_MESSAGE = `parameters belong on ${formatPlainWordList( + [...Object.values(ATTRIBUTE_BLOCK_RULES), ATTEMPT_BLOCK_RULE] + .filter((rule) => rule.parameters) + .map((rule) => rule.description), + 'and', +)}.`; const REPEATED_OUTPUT_MESSAGE = "A repeated step cannot map an 'output' parameter: the engine refuses to " + - 'deploy it. Move the mapping to a step after the repetition.'; + 'deploy it (BpmnParse.checkActivityOutputParameterSupported). Move the ' + + 'mapping to a step after the repetition.'; /** * Read off the block rules, as {@link PARAMETER_HOSTS_MESSAGE} is. A listener @@ -474,7 +622,56 @@ const MAPPING_HEAD_MESSAGE = const MAPPING_WHEN_MESSAGE = `Write '${ERROR_MAPPING_WHEN}' between the code and the condition: '${ERROR_MAPPING_HEAD} ${ERROR_MAPPING_WHEN} '.`; const priorityShapeMessage = (key: string) => - `Setting '${key}' takes an integer or a "\${...}" expression; the engine refuses to deploy a constant that is not an integer.`; + key === USER_PRIORITY_KEY + ? `Setting '${key}' takes an integer or a "\${...}" expression; the engine parses a constant with Integer.parseInt when the task is created (TaskDecorator.initializeTaskPriority) and fails the instance on anything else.` + : `Setting '${key}' takes an integer or a "\${...}" expression; the engine refuses to deploy a constant that is not an integer.`; + +const VERSION_SHAPE_MESSAGE = + `Setting 'version' takes a positive whole number, quoted or not, or a "\${...}" expression yielding one; ` + + 'the engine parses the value as an integer when the step runs (BaseCallableElement.getVersion, TaskEntity.initializeFormRefFromTaskDefinition) and fails the instance on anything else.'; + +const dueDateShapeMessage = (key: string) => + `Setting '${key}' takes a period starting with 'P' or an ISO date-time such as "2026-01-01T09:00:00", or a "\${...}" expression; ` + + 'the engine parses a constant with DueDateBusinessCalendar.resolveDuedate when the task is created and fails the instance on anything else.'; + +const RETRY_CYCLE_SHAPE_MESSAGE = + "Setting 'retryCycle' takes an ISO 8601 duration, an 'R/' repeat, or a comma " + + 'list of durations; a single interval the engine cannot read is logged and dropped, so the ' + + 'job keeps its default retries (ParseUtil.parseRetryIntervals), and a bad member of a list ' + + 'is stored unchecked and, when its turn comes, drops that retry to the default strategy ' + + 'with no wait (DefaultJobRetryCmd.execute).'; + +/** @param binding The binding key written beside it (`'topic'` or `'type'`). */ +const resultVariableUnreadMessage = (binding: string) => + `Setting 'resultVariable' has no effect beside '${binding}': the engine hands it to an 'expression' binding alone (BpmnParse.parseServiceTaskLike), so nothing writes the variable.`; + +const MAP_DECISION_RESULT_UNREAD_MESSAGE = + "Setting 'mapDecisionResult' has no effect without 'resultVariable': the engine applies the mapping only when storing the result into that variable (DecisionEvaluationUtil.evaluateDecision)."; + +const decisionModifierMessage = (key: string) => + `Setting '${key}' stands only beside 'decision': the engine reads 'binding', 'version' and 'mapDecisionResult' on a step answering a decision table alone (BpmnParse.parseBusinessRuleTask).`; + +/** The three settings the engine reads on the decision path alone. */ +const DECISION_MODIFIER_KEYS: readonly string[] = [ + 'binding', + 'version', + 'mapDecisionResult', +]; + +const HISTORY_TIME_TO_LIVE_MESSAGE = + "Setting 'historyTimeToLive' takes a quoted number of days, 'PD' or ''; " + + 'the engine reads the attribute as text, never as an expression, and refuses to deploy anything else (ParseUtil.parseHistoryTimeToLive).'; + +const VERSION_TAG_LITERAL_MESSAGE = + "Setting 'versionTag' takes a quoted string; the engine stores the tag as written, never evaluated (BpmnParse.parseProcess)."; + +const VERSION_TAG_LENGTH_MESSAGE = `Setting 'versionTag' is longer than ${VERSION_TAG_MAX_LENGTH} characters, the width of the column it is stored in (ACT_RE_PROCDEF.VERSION_TAG_), so the deployment fails.`; + +const candidateStarterMessage = (key: string) => + `Setting '${key}' takes ids as written: the engine stores each entry as a candidate identity link without evaluating it (BpmnDeployer.addAuthorizations), so a "\${...}" template names the id spelled by its text.`; + +const headerLiteralMessage = (key: string) => + `Setting '${key}' takes a quoted string on a process header; the engine stores it as written, never evaluated (BpmnParse.parseProcess).`; /** @param description Noun phrase with article, e.g. `'a service task'`. */ const runWithoutClauseMessage = (key: string, description: string) => @@ -482,6 +679,12 @@ const runWithoutClauseMessage = (key: string, description: string) => const RUN_JOB_PRIORITY_MESSAGE = `Setting '${RUN_JOB_PRIORITY_KEY}' does not exist: Operaton reads a job priority off the step alone (BpmnParse.createActivityOnScope), so 'jobPriority' applies to every run's job.`; +const REPEAT_COUNT_MESSAGE = + 'A repeat count must be a non-negative whole number, a variable, or a ' + + '"${...}" expression yielding one; the engine reads a constant as text ' + + 'with Integer.parseInt and truncates any other number with intValue() ' + + '(MultiInstanceActivityBehavior.resolveLoopCardinality).'; + const TYPE_VALUE_MESSAGE = `Setting '${TYPE_BINDING_KEY}' must be ${formatWordList(TYPE_BINDING_VALUES)}.`; /** The class each type's fields are set on, for the refusal of an undeclared name. */ @@ -556,6 +759,123 @@ const fieldValueMessage = (name: string) => `Field '${name}' takes a quoted string or a "\${...}" expression; ` + 'put the value in quotes.'; +/** + * The engine reads `stringValue` as fixed text + * (`BpmnParse.parseStringFieldDeclaration`), but which slot a value is written + * to is decided by its opening, so a literal opening with one can only ever + * reach the expression slot. + */ +const escapedFieldLiteralMessage = (name: string) => + `Field '${name}' cannot carry quoted text opening with '\${' or '#{': the expression slot is picked by that opening, so the text would be evaluated rather than injected as written. Drop the backslash to write an expression.`; + +/** + * `getStringValueFromAttributeOrElement` reads an empty `stringValue` as + * absent, so `parseStringFieldDeclaration` finds no literal and + * `parseExpressionFieldDeclaration` finds no expression either: the + * declaration is neither and the deployment fails. + */ +const emptyFieldMessage = (name: string) => + `Field '${name}' cannot be empty: the engine reads an empty value as absent, so the ` + + 'field declares neither a fixed value nor an expression and the deployment fails ' + + `(BpmnParse.parseFieldDeclaration). Write '\${""}' for an actual empty string.`; + +/** `BpmnParseUtil.getElValueProvider` reads an empty attribute as no value at all, never as an empty string. */ +const EMPTY_STRING_VALUE_MESSAGE = + 'An empty or blank string writes no value at all: the engine trims it and ' + + 'reads it as absent rather than as an empty string (BpmnParseUtil.getElValueProvider). ' + + 'Write \'${""}\' for an actual empty string.'; + +const JUEL_RESERVED_WORD_SET: ReadonlySet = new Set( + JUEL_RESERVED_WORDS, +); + +/** @param key The string-key spelling that reaches the name, `execution.getVariable('x')` or `order['x']`. */ +const juelKeywordMessage = (word: string, key: string) => + `'${word}' is a JUEL keyword (Scanner.addKeyToken), so the engine refuses any expression naming it. Reach the variable through a string key instead: "\${${key}}".`; + +/** `Character.isJavaIdentifierPart('-')` is false, so the identifier ends at the hyphen. */ +const hyphenNameMessage = (name: string, key: string) => + `'${name}' carries a hyphen, which JUEL scans as a minus (Scanner.nextIdentifier), so the engine reads a subtraction. Reach the variable through a string key instead: "\${${key}}".`; + +/** The slots whose value the engine casts to a Boolean, each throwing in its own method. */ +type ConditionSlot = 'condition' | 'until'; + +const nonBooleanConditionMessage = (shape: string, slot: ConditionSlot) => + slot === 'until' + ? `An 'until' condition must be boolean, but this one is ${shape}: the engine throws when a completion condition evaluates to anything else (MultiInstanceActivityBehavior.completionConditionSatisfied).` + : `A condition must be boolean, but this one is ${shape}: the engine throws 'condition expression returns ${shape === 'null' ? 'null' : 'non-Boolean'}' when it evaluates it (UelExpressionCondition.evaluate).`; + +const COMPOSITE_OPERAND_MESSAGE = + "A composite template cannot be spliced into the surrounding expression: JUEL has no '${' token once inside an expression (Scanner.nextEval), so only a raw that is exactly one '${...}' with no '}' outside a string literal in its body can be an operand. Write the whole expression as one raw template instead."; + +/** The keys whose value the engine evaluates as EL, so quoted text reaches it as a string. */ +const EL_BINDING_KEYS: ReadonlySet = new Set([ + 'expression', + 'delegate', + CALL_MAPPER_KEY_BY_KIND.delegateExpression, +]); + +/** The keys whose value is loaded as a class name, each with the key that resolves one at runtime instead. */ +const CLASS_BINDING_ALTERNATIVE: Readonly> = { + class: 'delegate', + [CALL_MAPPER_KEY_BY_KIND.class]: CALL_MAPPER_KEY_BY_KIND.delegateExpression, +}; + +/** Every key a blank literal is refused under, with what its value names. */ +const EMPTY_BINDING_NOUN: Readonly> = { + class: 'class to load', + expression: 'expression to evaluate', + delegate: 'delegate to resolve', + [CALL_MAPPER_KEY_BY_KIND.class]: 'mapping class to load', + [CALL_MAPPER_KEY_BY_KIND.delegateExpression]: 'mapping delegate to resolve', + topic: 'topic a worker subscribes to', + decision: 'decision table to evaluate', + process: 'process to start', +}; + +const literalElBindingMessage = (key: string) => + `Setting '${key}' takes a "\${...}" template or a bare name, never quoted text: ` + + (key === 'expression' + ? 'the string evaluates to itself and runs nothing' + : 'a string resolves to no delegate to run') + + `. Write '${key}: "\${...}"' or '${key}: '.`; + +const templateAsClassMessage = (key: string, alternative: string) => + `Setting '${key}' takes a class name, loaded as written (ClassDelegateUtil.instantiateDelegate); a "\${...}" template there is not evaluated. Write '${key}: com.example.X', or '${alternative}: "\${bean}"' to resolve one at runtime.`; + +/** + * The shapes the binding keys refuse. A blank literal: + * `BpmnParse.parseServiceTaskLike` passes over a blank `class` or `expression` + * and `validateServiceTaskLike` then refuses the task as unbound, + * `parseExecutionListener` refuses an empty `class` or `delegateExpression` + * outright, and a blank `topic`, `decision`, `process` or mapping class + * deploys and then names nothing at the step; a blank `delegate` or + * `mapperDelegate` deploys too (`parseServiceTaskLike` and + * `parseCallActivity` test that attribute against null alone) and its empty + * expression evaluates to a string no delegate is, so + * `ServiceTaskDelegateExpressionActivityBehavior.performExecution` and + * `CallableElementActivityBehavior.getDelegateVariableMapping` throw at the + * step; this language refuses all of them alike. Quoted text under an EL key + * reaches the engine as a string: + * `ServiceTaskDelegateExpressionActivityBehavior.performExecution` throws + * because a string is no delegate, and an expression evaluates to the text + * itself. A template under a class key is loaded as the class name it spells. + */ +function bindingValueMessage(key: string, value: Expr): string | undefined { + const noun = EMPTY_BINDING_NOUN[key]; + if (noun === undefined) return undefined; + if (isLiteralString(value)) { + if (value.value.trim().length === 0) { + return `Setting '${key}' cannot be empty; name the ${noun}.`; + } + return EL_BINDING_KEYS.has(key) ? literalElBindingMessage(key) : undefined; + } + const alternative = CLASS_BINDING_ALTERNATIVE[key]; + return isRawExpr(value) && alternative !== undefined + ? templateAsClassMessage(key, alternative) + : undefined; +} + const unknownDirectionMessage = (word: string, legal: readonly string[]) => `Unknown parameter direction '${word}'; write ${formatWordList(legal)}.`; @@ -605,6 +925,16 @@ const integerBoundMessage = (key: string) => const PATTERN_VALUE_MESSAGE = `Setting 'pattern' takes a non-empty quoted date pattern such as "dd/MM/yyyy".`; +const PATTERN_LETTERS_MESSAGE = + "Setting 'pattern' may hold only SimpleDateFormat letters " + + '(G, y, Y, M, L, w, W, D, d, F, E, u, a, H, k, K, h, m, s, S, z, Z, X), ' + + 'quoted literal runs, and non-letters; the engine builds a java.text.SimpleDateFormat ' + + 'from it and throws on any other letter (DateFormType).'; + +const VALIDATOR_EMPTY_MESSAGE = + "Setting 'validator' cannot be empty; name the class or the expression it " + + 'resolves (FormValidators.createValidator).'; + const valuesOnNonEnumMessage = (field: FormField) => `Value lines belong on an 'enum' field; '${field.id}' is a ${field.type} field.`; @@ -626,6 +956,28 @@ const enumDefaultMessage = ( ) => `The default "${value}" of enum field '${id}' names none of its values; write ${formatWordList(ids)}.`; +/** `LongFormType.convertValue` runs `Long.valueOf` on every render; a decimal or a word throws. */ +const numberDefaultMessage = (id: string, text: string) => + `The default ${text} of number field '${id}' is not an integer; the engine ` + + 'converts it with Long.valueOf every time the form renders (LongFormType.convertValue) ' + + 'and throws on anything else.'; + +/** `BooleanFormType.convertValue` never throws; it reads anything but "true" as false with no error at all. */ +const booleanDefaultMessage = (id: string, text: string) => + `The default ${text} of boolean field '${id}' is not "true" or "false"; the engine ` + + 'reads any other spelling as false (BooleanFormType.convertValue).'; + +/** + * `DateFormType.convertToModelValue` parses the default with the field's + * pattern, or the process engine's own "dd/MM/yyyy" where none is written + * (`ProcessEngineConfigurationImpl.initFormTypes`); an ISO-shaped default is + * the one mistake worth guessing at, since it reads as a legal date to a human. + */ +const isoDateDefaultMessage = (id: string, value: string) => + `The default "${value}" of date field '${id}' is an ISO date, but the engine's default ` + + 'pattern is "dd/MM/yyyy" (ProcessEngineConfigurationImpl.initFormTypes); add a \'pattern\' ' + + 'setting, or write the date to fit it.'; + const formFieldDirectionMessage = ( id: string, direction: string, @@ -663,13 +1015,29 @@ const integer: ValueShapeRule = (key, value) => ? undefined : integerBoundMessage(key); +/** + * `validator` reads as `class:` does: a quoted class, a bare dotted name, or + * an expression; only a blank literal is refused, the one shape + * `FormValidators.createValidator` refuses to deploy. + */ const nonEmptyText: ValueShapeRule = (_key, value) => - isLiteralString(value) && value.value.length > 0 - ? undefined - : PATTERN_VALUE_MESSAGE; + isLiteralString(value) && value.value.length === 0 + ? VALIDATOR_EMPTY_MESSAGE + : undefined; -/** `validator` reads as `class:` does: a quoted class, a bare dotted name, or an expression. */ -const anyText: ValueShapeRule = () => undefined; +/** + * `pattern` is always literal text (`FormTypes.parseFormPropertyType` reads it + * off the XML attribute, never an expression), so a raw template is refused + * the same as an empty or illegal-letter one. + */ +const datePatternShape: ValueShapeRule = (_key, value) => { + if (!isLiteralString(value) || value.value.length === 0) { + return PATTERN_VALUE_MESSAGE; + } + return PATTERN_LETTERS_TEXT.test(value.value) + ? undefined + : PATTERN_LETTERS_MESSAGE; +}; /** What each setting of a form field's parens takes; typed as {@link FORM_CONSTRAINT_TYPES} is. */ const FORM_FIELD_VALUE_RULES: Readonly> = { @@ -679,13 +1047,49 @@ const FORM_FIELD_VALUE_RULES: Readonly> = { max: integer, minlength: integer, maxlength: integer, - validator: anyText, - [DATE_PATTERN_KEY]: nonEmptyText, + validator: nonEmptyText, + [DATE_PATTERN_KEY]: datePatternShape, } satisfies Record< (typeof FORM_CONSTRAINT_NAMES)[number] | typeof DATE_PATTERN_KEY, ValueShapeRule >; +const headerLiteral: ValueShapeRule = (key, value) => + isLiteralString(value) ? undefined : headerLiteralMessage(key); + +const historyTimeToLive: ValueShapeRule = (_key, value) => + isQuotedMatching(value, HISTORY_TIME_TO_LIVE_TEXT) + ? undefined + : HISTORY_TIME_TO_LIVE_MESSAGE; + +const versionTag: ValueShapeRule = (_key, value) => + !isLiteralString(value) + ? VERSION_TAG_LITERAL_MESSAGE + : value.value.length > VERSION_TAG_MAX_LENGTH + ? VERSION_TAG_LENGTH_MESSAGE + : undefined; + +/** A bare name is an id as written; only a template, quoted whole or inside the list, is refused. */ +const candidateStarters: ValueShapeRule = (key, value) => + isRawExpr(value) || + (isLiteralString(value) && TEMPLATE_ANYWHERE.test(value.value)) + ? candidateStarterMessage(key) + : undefined; + +/** + * What each process header setting takes. `BpmnParse.parseProcess` reads + * every one of them as text, so none is an expression, unlike the same + * `label` on a task, whose name `TaskDecorator.initializeTaskName` evaluates. + */ +const PROCESS_HEADER_VALUE_RULES: Readonly> = { + label: headerLiteral, + documentation: headerLiteral, + versionTag, + historyTimeToLive, + candidateStarterUsers: candidateStarters, + candidateStarterGroups: candidateStarters, +}; + /** @param subject The clause or noun phrase that does take one, quoted as written. */ function particleOnlyMessage(subject: string): string { return `Only ${subject} takes a particle.`; @@ -712,6 +1116,40 @@ const LISTENER_TIMER_PAYLOAD_MESSAGE = TIMER_PAYLOAD_PREFIX + `'after "PT1H"', 'at "2026-08-01T09:00:00"', or 'every "R/PT10M"'.`; +const timerShapeMessage = ( + particle: string, + expected: string, + calendar: string, +) => + `'${particle}' takes ${expected}; the engine reads it as text and fails when it does not fit (${calendar}).`; + +const AFTER_SHAPE_MESSAGE = timerShapeMessage( + 'after', + 'an ISO 8601 duration such as "PT1H", on its own or beside a start or end date-time ("2026-01-01T00:00:00/PT1H", "PT1H/2026-12-31T00:00:00")', + 'DurationBusinessCalendar.resolveDuedate', +); + +const AT_SHAPE_MESSAGE = timerShapeMessage( + 'at', + 'an ISO date-time such as "2026-08-01T09:00:00", or a duration counted from now', + 'DueDateBusinessCalendar.resolveDuedate', +); + +const EVERY_SHAPE_MESSAGE = timerShapeMessage( + 'every', + 'an ISO 8601 repeat such as "R/PT10M", or a six-field cron expression or one of its nicknames such as "@daily"', + 'CycleBusinessCalendar.resolveDuedate', +); + +/** The shape each particle's calendar reads; {@link BpmnScriptValidator.checkTimerShape} picks by {@link TimerPayload.particle}. */ +const TIMER_SHAPE_BY_PARTICLE: Readonly< + Record +> = { + after: { pattern: AFTER_TIME_TEXT, message: AFTER_SHAPE_MESSAGE }, + at: { pattern: AT_TIME_TEXT, message: AT_SHAPE_MESSAGE }, + every: { pattern: EVERY_TIME_TEXT, message: EVERY_SHAPE_MESSAGE }, +}; + /** * What the condition diagnostics differ by from one position to the next: the * subject of the sentence, the clause as that position spells it, and how a @@ -771,6 +1209,56 @@ const START_TRIGGER_IN_HANDLER_MESSAGE = "The start of an event-handler body carries no trigger; the handler's own " + "'on ' is what it catches."; +const NESTED_START_FORM_MESSAGE = + 'A start inside a subprocess, attempt block, or handler body takes no ' + + "form: the engine reads a start form off the process's own start alone " + + '(BpmnParse.parseStartFormHandlers) and BpmnParse.parseScopeStartEvent ' + + 'reads none, so this form is never shown.'; + +const NESTED_START_INITIATOR_MESSAGE = + 'A start inside a subprocess, attempt block, or handler body takes no ' + + "'initiator': the engine reads it off the process's own start alone " + + '(BpmnParse.parseProcessDefinitionStartEvent) and ' + + 'BpmnParse.parseScopeStartEvent reads none, so nothing is written.'; + +const SECOND_DEFAULT_START_MESSAGE = + 'A process takes one plain or timer start: Operaton refuses a second one ' + + '(BpmnParse.selectInitial), so the deployment fails. Keep one, or give ' + + 'this start a message, signal, or condition trigger.'; + +const START_AFTER_IMPLICIT_START_MESSAGE = + 'A body that does not open with a start gets a plain start of its own, ' + + "so this is the process's second plain or timer start, which Operaton " + + 'refuses (BpmnParse.selectInitial), so the deployment fails. Open the ' + + 'body with this start, or give it a message, signal, or condition trigger.'; + +function duplicateNamedStartMessage(trigger: string, name: string): string { + return `Another start already subscribes to ${trigger} '${name}': Operaton keeps one ${trigger} start subscription per name and process (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails.`; +} + +function duplicateConditionStartMessage(text: string): string { + return `Another start already carries the condition '${text}': Operaton keeps one conditional start per condition text and process (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails.`; +} + +function timerJobKeyTwiceMessage(key: string): string { + return `Setting '${key}' is already written on the 'on timer' head, and both land on this start event, the element the engine reads the timer job's settings from (BpmnParse.parseTimer, DefaultFailedJobParseListener.parseStartEvent); keep one.`; +} + +function raceDuplicateMessage(trigger: string, name: string): string { + return `Another branch of this 'await' already catches ${trigger} '${name}': every branch subscribes on the gateway's own scope (BpmnParse.parseIntermediateCatchEvent), which keeps one ${trigger} subscription per name (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails.`; +} + +const DEAD_LOOP_MESSAGE = + "This loop can never repeat: every path through the 'do' body ends or redirects the flow, so the condition is never evaluated and the loop gateway would lower to a disconnected node with no incoming flow, which is invalid BPMN. End after the loop, or keep one path through the body."; + +function emptyLoopBodyMessage(keyword: string): string { + return `The '${keyword}' body has no steps, so the loop and its condition would be dropped: with nothing to loop over, the gateway keeps only its exit and the condition is never written. Put a step in the body, or remove the loop.`; +} + +function blockMemberMessage(what: string, verb: string): string { + return `${what} written inside a body belongs to nothing and is dropped: put it in the attribute block of the element it ${verb}, the braces before that element's body.`; +} + const END_TRIGGERS_MESSAGE = "An end event carries 'terminate', which stops every running path in this " + `scope, or 'cancel', which gives up the 'attempt' block it sits in.`; @@ -876,7 +1364,7 @@ const DECLARATION_SETTINGS_ONLY_MESSAGE = "settings, written 'key: value'."; /** @param description Noun phrase with article, e.g. `'an if statement'`. */ -const gatewaySettingsOnlyMessage = (description: string) => +const settingsOnlyMessage = (description: string) => `The parens of ${description} take only settings, written 'key: value'.`; /** @param description Noun phrase with article, e.g. `'a while loop'`. */ @@ -892,23 +1380,123 @@ const refusedHeadKeyMessage = (key: string, description: string): string => const prunedJoinMessage = (description: string, key: string) => `Every branch of this ${description.replace(/^an? /, '')} ends its path, so there is no join for '${key}' to set; the setting has no effect.`; +/** + * `retryCycle`/`exclusive` fold into `BpmnParse.parseAsynchronousContinuation`, + * which declares a job only when `asyncBefore` or `asyncAfter` is also true; + * `DefaultFailedJobParseListener.parseActivity` reads a retry cycle the same + * way. Written alone, the setting reaches an element with no job to carry it. + */ +const noJobMessage = ( + key: string, + pairing: readonly string[], + description: string, +): string => + `Setting '${key}' on ${description} configures no job: Operaton creates ` + + `one only when ${formatWordList(pairing)} is also set ` + + '(BpmnParse.parseAsynchronousContinuation, DefaultFailedJobParseListener.parseActivity).'; + +/** + * `BpmnParse.parseActivity` swaps the scope to the multi-instance body before + * `createActivityOnScope`, so a repeated step's own `jobPriority` (there is no + * `runJobPriority`) prices that per-run activity's job, the one `runAsyncBefore` + * or `runAsyncAfter` creates, never the whole-loop job the plain async flags + * create. + */ +const noPerRunJobMessage = ( + key: string, + pairing: readonly string[], + description: string, +): string => + `Setting '${key}' on ${description} prices the per-run job Operaton ` + + 'creates in the multi-instance body (BpmnParse.parseActivity), which ' + + `exists only when ${formatWordList(pairing)} is also set.`; + +/** + * A timer creates its own job regardless of the async flags, so none of the + * three keys needs one beside it: a node with its own timer trigger, or a + * host-less `on timer` handler's own start, the element `checkTimerHeadKeys` + * already treats as the one the timer job's settings land on. + */ +function isTimerCarrier(owner: AttributeOwner): boolean { + if ('trigger' in owner && owner.trigger === 'timer') return true; + if (!isStartEvent(owner) || owner.trigger !== undefined) return false; + const container = owner.$container; + return ( + isBlock(container) && + isOnHandler(container.$container) && + container.$container.trigger === 'timer' && + container.$container.host === undefined + ); +} + +/** + * `BpmnParse.parseSignalCatchEventDefinition` gives a process's signal start + * and an intermediate signal catch an `EventSubscriptionJobDeclaration` + * priced from the catcher's `jobPriority`; `EventSubscriptionEntity.eventReceived` + * creates that job when the throwing `signalEventDefinition` is async. The + * job keeps the declaration's default `exclusive`, and the retry cycle is + * read only for a timer or an async element, so the other two keys still + * need a flag. A boundary signal (`parseBoundarySignalEventDefinition`) and + * an event sub-process signal start (`parseEventDefinitionForSubprocess`) + * declare no such job, so a handler is not one of these. + */ +function isSignalSubscriptionJobCarrier(owner: AttributeOwner): boolean { + return ( + (isStartEvent(owner) || + isIntermediateCatchEvent(owner) || + isRaceBranch(owner)) && + owner.trigger === 'signal' + ); +} + /** * Ids the `astToIr` desugarer synthesizes; an author-chosen statement name * matching one produces duplicate-id IR. ADR-0010 has the templates. Gateway * ids bypass the desugarer's collision guard entirely; `Boundary_` runs * through it but would be renamed with a suffix rather than flagged. + * + * The implicit start and end of a process or subprocess body are not here: + * the desugarer mints exactly `StartEvent_` and + * `EndEvent_`, and a prefix would refuse the Modeler's own default + * `StartEvent_1` (ADR-0050). + * + * A handler body's own implicit start and end, and a boundary escape's end, + * are the exception: the desugarer mints them off an id no author writes + * (`StartEvent_EventSubProcess_`, `EndEvent_EventSubProcess_`, + * `EndEvent_Boundary__`), and that coordinate is not the + * enclosing `Process`/`SubProcess` name `isReservedName` matches against, so + * only a prefix catches them. + * + * `_di$`, `BPMNDiagram_`, and `BPMNPlane_` are not the desugarer's own ids: + * `irToXml`'s `layoutProcess` mints `_di` for every shape and edge, and + * `BPMNDiagram_`/`BPMNPlane_` for the document once, so a + * step reusing one of those collides with a duplicate `xs:ID` the same way. */ const RESERVED_ID_PATTERNS: ReadonlyArray = [ /^Gateway_.+_(split|join|fork|loop|race)$/, /^Flow_.+_.+$/, - /^StartEvent_/, - /^EndEvent_/, /^Throw_/, /^EventSubProcess_/, /^Boundary_/, /^Catch_/, + /^EndEvent_Boundary_/, + /^StartEvent_EventSubProcess_/, + /^EndEvent_EventSubProcess_/, + /_di$/, + /^BPMNDiagram_/, + /^BPMNPlane_/, ]; +/** Which implicit terminal of `containerId` the desugarer mints `name` for, if either. */ +function mintedTerminalRole( + name: string, + containerId: string, +): 'start' | 'end' | undefined { + if (name === `StartEvent_${containerId}`) return 'start'; + if (name === `EndEvent_${containerId}`) return 'end'; + return undefined; +} + /** * The patterns as the diagnostic spells them, so the sentence an author reads * cannot drift from the list that rejected them. @@ -962,6 +1550,34 @@ function statementName(stmt: Statement): string | undefined { return isNamedStatement(stmt) ? stmt.name : undefined; } +/** + * The subscription a process-level start registers, keyed as the engine + * compares it: a message or signal by name, a condition by its rendered text + * (`ConditionalEventDefinition.getConditionAsString`); a plain or timer start + * registers none. + */ +function startSubscriptionKey(start: StartEvent): string | undefined { + if (start.trigger === 'condition') { + return duplicateKey(start.trigger, conditionTextOf(start)); + } + if (start.trigger !== undefined && NAME_SCOPED_TRIGGERS.has(start.trigger)) { + return duplicateKey(start.trigger, payloadTextOf(start.items)); + } + return undefined; +} + +function conditionTextOf(start: StartEvent): string | undefined { + const value = payloadItemOf(start.items)?.value; + if (value === undefined || !isExpr(value)) return undefined; + // A partial parse leaves a child slot empty and the render throws; an + // unparsed condition keys nothing, as `duplicateKey` treats a missing part. + try { + return renderExpression(value); + } catch { + return undefined; + } +} + /** * Parser error recovery leaves a mandatory slot empty, so a `Block` and a name * are `undefined`-capable however the generated types declare them. A check @@ -1009,9 +1625,11 @@ function isLinkCatch(node: AstNode): node is IntermediateCatchEvent { * Whether `stmt`, once reached, always ends or diverts the flow. A compound * counts only when every branch does, which is exactly when the transform * prunes its synthesized join to zero incoming flows. An `if` without an - * `else` and a loop never count: their gateway keeps a non-terminating exit. - * An `emit link` counts like a `goto`: `BpmnParse.parseSequenceFlow` refuses - * a flow out of a link throw as an invalid source. + * `else` and a `while` never count: their gateway keeps a non-terminating + * exit. A `do ... while` counts when its body does, since the body runs + * before the gateway is ever reached. An `emit link` counts like a `goto`: + * `BpmnParse.parseSequenceFlow` refuses a flow out of a link throw as an + * invalid source. */ function statementTerminates(stmt: Statement): boolean { if ( @@ -1022,6 +1640,9 @@ function statementTerminates(stmt: Statement): boolean { ) { return true; } + if (isDoWhileStatement(stmt)) { + return blockTerminates(blockStatements(stmt.body)); + } if (isIfStatement(stmt) && stmt.elseBlock !== undefined) { return ( blockTerminates(blockStatements(stmt.then)) && @@ -1032,9 +1653,11 @@ function statementTerminates(stmt: Statement): boolean { ); } if (isParallelStatement(stmt)) { - // With a condition anywhere and no `else`, the fallback the transform adds - // runs to the join, which therefore always keeps an arriving path. - if (hasConditionedBranch(stmt) && !stmt.branches.some((b) => b.otherwise)) { + // With every branch conditioned, the fallback the transform adds runs + // straight to the join, which therefore always keeps an arriving path. + // Beside an `else` the fallback runs onto that branch, and beside a plain + // branch none is reserved, so those cases come down to the branches. + if (stmt.branches.every((b) => b.condition !== undefined)) { return false; } return stmt.branches.every((branch) => @@ -1077,6 +1700,36 @@ function previousFlowStatement( return undefined; } +/** + * The noun for an element whose parens have no payload slot, or `undefined` + * where the unkeyed value is read as a trigger's payload or is already + * refused under a message of its own (a gateway head, a form field, a code + * declaration). + */ +function settingsOnlyOwnerDescription( + owner: ParenValue['$container'], +): string | undefined { + if (isProcess(owner)) return 'a process header'; + if (isListener(owner)) return 'a listener'; + if ( + isFormField(owner) || + isCodeDecl(owner) || + gatewayStatementRuleOf(owner) !== undefined || + isOnHandler(owner) || + isIntermediateCatchEvent(owner) || + isRaceBranch(owner) || + isThrowStatement(owner) || + isEmitStatement(owner) + ) { + return undefined; + } + const description = attributeBlockRuleOf(owner)!.description; + if (!isStartEvent(owner) && !isEndEvent(owner)) return description; + return owner.trigger === undefined + ? `${description} with no trigger` + : undefined; +} + /** * A composite duplicate key, `undefined` when any part was left unparsed: a * template literal would stringify the missing slot into a self-colliding key. @@ -1195,36 +1848,69 @@ export class BpmnScriptValidator { } /** - * Three facts about a start set only the engine matrix can tell, all silent - * at deploy time and worth telling the author here instead. With no plain - * or timer start, `initial` stays null and starting the process by key - * throws (`ProcessDefinitionImpl.ensureDefaultInitialExists`). A start - * form binds to `initial` only, so a form on any other start is parsed and - * never shown (`BpmnParse.parseStartFormHandlers`). Two plain or - * timer starts is itself a deploy error the engine reports on its own, so - * this check does not duplicate it and treats the first as the default. + * What the engine makes of a process's start set. Three refusals are + * mirrored: `BpmnParse.selectInitial` rejects a second plain or timer start, + * and `BpmnParse.addEventSubscriptionDeclaration` rejects a second message + * or signal start of one name and a second conditional start of one + * condition text, all registered on the process definition. Three facts + * stay silent at deploy time and are told here instead. With no plain or + * timer start, `initial` stays null and starting the process by key throws + * (`ProcessDefinitionImpl.ensureDefaultInitialExists`). A start form binds + * to `initial` only, so a form on any other start is parsed and never shown + * (`BpmnParse.parseStartFormHandlers`). * `BpmnParse.parseProcessDefinitionStartEvent` reads `operaton:initiator` * off every start in document order and sets each on the process * definition, so only the last start naming one keeps its value. + * + * A body that does not open with a `start` gets a plain start minted for + * it, and that one is in the engine's count like a written one: the + * candidates are `[implicit, ...written plain or timer starts]`, and + * everything after the first is a second default start. */ private checkDefaultStart( process: Process, accept: ValidationAcceptor, ): void { const starts = process.body.filter(isStartEvent); - if (starts.length < 2) return; + const hasImplicitStart = !isStartEvent(process.body[0]); + if (starts.length + (hasImplicitStart ? 1 : 0) < 2) return; const defaultCandidates = starts.filter( (start) => start.trigger === undefined || start.trigger === 'timer', ); - if (defaultCandidates.length === 0 && process.name !== undefined) { + if ( + !hasImplicitStart && + defaultCandidates.length === 0 && + process.name !== undefined + ) { accept('warning', noDefaultStartMessage(process.name), { node: process, property: 'name', }); } + for (const start of defaultCandidates.slice(hasImplicitStart ? 0 : 1)) { + accept( + 'error', + hasImplicitStart + ? START_AFTER_IMPLICIT_START_MESSAGE + : SECOND_DEFAULT_START_MESSAGE, + { node: start, property: 'name' }, + ); + } + forEachDuplicate(starts, startSubscriptionKey, (start) => + accept( + 'error', + start.trigger === 'condition' + ? duplicateConditionStartMessage(conditionTextOf(start)!) + : duplicateNamedStartMessage( + start.trigger!, + payloadTextOf(start.items)!, + ), + { node: start, property: 'trigger' }, + ), + ); - const defaultStart = defaultCandidates[0]; + const defaultStart = hasImplicitStart ? undefined : defaultCandidates[0]; for (const start of starts) { if (start === defaultStart) continue; for (const form of start.forms) { @@ -1258,6 +1944,7 @@ export class BpmnScriptValidator { const symbols = this.variables.collect(process); + this.checkKeywordVarDecls(process, accept); for (const expr of collectExpressions(process)) { this.checkExpression(expr, symbols, accept); } @@ -1503,18 +2190,30 @@ export class BpmnScriptValidator { } } - /** A reserved-pattern name would produce duplicate-id IR; the IDE error comes first. */ + /** A reserved name would produce duplicate-id IR; the IDE error comes first. */ private checkReservedNames( named: NamedStatement[], accept: ValidationAcceptor, ): void { for (const node of named) { - if (isReservedName(node.name)) { + const container = enclosingContainerName(node); + const role = + container === undefined + ? undefined + : mintedTerminalRole(node.name, container); + if (role !== undefined) { + accept( + 'error', + `Statement name '${node.name}' is the id the compiler generates for ` + + `the implicit ${role} event of '${container}'; choose another name.`, + { node, property: 'name' }, + ); + } else if (isReservedName(node.name)) { accept( 'error', `Statement name '${node.name}' matches a reserved synthesized-id pattern. ` + - `Prefixes ${RESERVED_ID_SHAPE_LIST} are reserved for ids generated ` + - `by the BPMNscript desugarer.`, + `Prefixes ${RESERVED_ID_SHAPE_LIST} are reserved for ids the ` + + 'desugarer or the layouter generates.', { node, property: 'name' }, ); } @@ -1541,20 +2240,31 @@ export class BpmnScriptValidator { } /** - * The engine execution settings are per-flow-node and have no process-wide - * form, leaving {@link PROCESS_HEADER_KEYS}. + * The process-header key set, and why it leaves out the engine settings + * `BpmnParse.parseProcess` still reads off `bpmn:process`, are documented on + * {@link PROCESS_HEADER_KEYS}. */ private checkProcessAttributes( process: Process, accept: ValidationAcceptor, ): void { this.checkDuplicateKeys(settingsOf(process.items), accept); + const settings = configuredSettingsOf(process); this.checkAttributeKeys( - configuredSettingsOf(process), + settings, PROCESS_HEADER_KEY_SET, 'a process header', accept, ); + for (const attr of settings) { + const message = PROCESS_HEADER_VALUE_RULES[attr.key]?.( + attr.key, + attr.value, + ); + if (message !== undefined) { + accept('error', message, { node: attr, property: 'value' }); + } + } this.checkFlags(process.items, [], 'a process header', accept); } @@ -1577,11 +2287,40 @@ export class BpmnScriptValidator { { node, property: 'name' }, ), ); + + // The document has one `bpmn:process id`; a step of the same name mints a + // second element sharing it, which `irToXml` refuses as a duplicate + // `xs:ID` before layout. + for (const node of named) { + if (node.name !== process.name) continue; + accept( + 'error', + `Step name '${node.name}' equals the process id; the compiled ` + + 'document can hold only one element with that id.', + { node, property: 'name' }, + ); + } + } + + /** Every read of such a variable is refused too; the declaration is where the name is chosen. */ + private checkKeywordVarDecls( + process: Process, + accept: ValidationAcceptor, + ): void { + for (const decl of process.decls) { + if (isVarDecl(decl) && JUEL_RESERVED_WORD_SET.has(decl.name)) { + accept( + 'error', + juelKeywordMessage(decl.name, variableKeySpelling(decl.name)), + { node: decl, property: 'name' }, + ); + } + } } private checkExpression( expr: Expr, - symbols: ReturnType, + symbols: VariableTable, accept: ValidationAcceptor, ): void { // An `out` source is evaluated in the called process's scope, which the @@ -1626,15 +2365,54 @@ export class BpmnScriptValidator { !isNonVariableAttrValue && !isDeclarationItem && !isGatewayHeadValue && - !isCodePosition(expr) && - !readsExternalTask(expr) && - !symbols.has(expr.ref.$refText) + !isCodePosition(expr) ) { - accept( - 'warning', - `Variable '${expr.ref.$refText}' is not declared. Add 'var ${expr.ref.$refText}: ' to the process.`, - { node: expr, property: 'ref' }, - ); + // A bare collection name is written as it is and looked up as a + // variable (`operaton:collection`), so JUEL never scans it; a bare + // `in`/`out` mapping source is exempt the same way: `lowerCallMapping` + // writes it as a plain `source` attribute, and + // `BpmnParse.parseCallableElementProvider` wraps that attribute in a + // `ConstantValueProvider`, never an expression. + const isBareLookupName = + expr.accessors.length === 0 && + (expr.$containerProperty === 'collection' || + (expr.$containerProperty === 'source' && + isVariableMapping(container))); + if (!isBareLookupName) { + checkRenderedNames(expr, accept); + } + if (!readsExternalTask(expr) && !symbols.has(expr.ref.$refText)) { + accept( + 'warning', + `Variable '${expr.ref.$refText}' is not declared. Add 'var ${expr.ref.$refText}: ' to the process.`, + { node: expr, property: 'ref' }, + ); + } + } + } + + if (isRawExpr(expr)) { + checkRawTemplateNames(expr.raw, { node: expr, property: 'raw' }, accept); + // A raw's body is spliced into the expression around it, which only one + // template has; the top of a position is rendered as written instead. + if ( + (isExpr(container) || isAccessor(container)) && + singleTemplateBody(expr.raw) === undefined + ) { + accept('error', COMPOSITE_OPERAND_MESSAGE, { + node: expr, + property: 'raw', + }); + } + } + + const slot = conditionSlotOf(expr); + if (slot !== undefined) { + const shape = nonBooleanShapeOf(expr, symbols); + if (shape !== undefined) { + accept('error', nonBooleanConditionMessage(shape, slot), { + node: expr, + }); } } @@ -1681,7 +2459,7 @@ export class BpmnScriptValidator { node: Relational | Additive | Multiplicative | Logical, allowed: ReadonlySet, context: string, - symbols: ReturnType, + symbols: VariableTable, accept: ValidationAcceptor, ): void { for (const side of ['left', 'right'] as const) { @@ -1707,14 +2485,19 @@ export class BpmnScriptValidator { this.checkAttributeBlock(start, accept); const container = start.$container; - if (isBlock(container) && isOnHandler(container.$container)) { + if (isBlock(container)) { for (const form of start.forms) { - accept( - 'error', - `The start of an event-handler body has no form; the event's data is bound by the handler's own '(...)' bindings, not by a form.`, - { node: form }, - ); + accept('error', NESTED_START_FORM_MESSAGE, { node: form }); } + for (const setting of settingsOf(start.items)) { + if (setting.key === 'initiator') { + accept('error', NESTED_START_INITIATOR_MESSAGE, { + node: setting, + property: 'key', + }); + } + } + this.checkTimerHeadKeys(start, container.$container, accept); } if (start.trigger === undefined) return; @@ -1744,6 +2527,29 @@ export class BpmnScriptValidator { this.checkStartPayload(start, TRIGGER_PAYLOAD[start.trigger]!, accept); }; + /** + * A host-less `on timer` writes `exclusive`, `jobPriority` and `retryCycle` + * onto the start event of its body, the element the engine builds the + * timer job from, so a key written on both the head and an authored start + * aims two values at one job and the lowering keeps the head's. + */ + private checkTimerHeadKeys( + start: StartEvent, + owner: AstNode, + accept: ValidationAcceptor, + ): void { + if (!isOnHandler(owner) || owner.trigger !== 'timer' || owner.host) return; + const headKeys = new Set(settingsOf(owner.items).map((s) => s.key)); + for (const setting of settingsOf(start.items)) { + if (TIMER_JOB_KEY_SET.has(setting.key) && headKeys.has(setting.key)) { + accept('error', timerJobKeyTwiceMessage(setting.key), { + node: setting, + property: 'key', + }); + } + } + } + /** * Mirrors {@link checkCatchPayload}. Neither gets the timer shape warnings: a * repeating start is a legitimate schedule, not a one-shot mistake. @@ -1903,6 +2709,7 @@ export class BpmnScriptValidator { this.checkResultVariableBinding(task, accept); } } + this.checkDecisionModifiers(task, accept); this.checkBindingAttribute(task, accept); this.checkBindingVersionExclusion(task, 'A decision step', accept); this.checkDecisionResultMapping(task, accept); @@ -1967,47 +2774,90 @@ export class BpmnScriptValidator { } } - /** Asked, as {@link checkBuiltinBinding} is, only once exactly one binding is written. */ + /** + * Asked, as {@link checkBuiltinBinding} is, only once exactly one binding is + * written. A `topic` or `type` binding deploys with the variable and never + * writes it, so that pair is a warning rather than the refusal. + */ private checkResultVariableBinding( task: ServiceTask | SendTask | BusinessRuleTask, accept: ValidationAcceptor, ): void { const settings = settingsOf(task.items); const result = settings.find((a) => a.key === 'resultVariable'); - const binding = settings.find( + if (result === undefined) return; + const target = { node: result, property: 'key' } as const; + const refusing = settings.find( (a) => RESULT_VARIABLE_REFUSING_BINDINGS[a.key] !== undefined, ); - if (result === undefined || binding === undefined) return; - accept( - 'error', - resultVariableBindingMessage( - attributeBlockRuleOf(task)!.description, - binding.key, - SERVICE_TASK_LIKE_ELEMENT[task.$type], - ), - { node: result, property: 'key' }, + if (refusing !== undefined) { + accept( + 'error', + resultVariableBindingMessage( + attributeBlockRuleOf(task)!.description, + refusing.key, + SERVICE_TASK_LIKE_ELEMENT[task.$type], + ), + target, + ); + return; + } + const unread = settings.find( + (a) => a.key === 'topic' || a.key === TYPE_BINDING_KEY, ); + if (unread !== undefined) { + accept('warning', resultVariableUnreadMessage(unread.key), target); + } + } + + /** + * The three DMN settings stand only under a `decision` binding: the code + * and topic paths never read them, so each is refused rather than dropped. + */ + private checkDecisionModifiers( + task: BusinessRuleTask, + accept: ValidationAcceptor, + ): void { + const settings = settingsOf(task.items); + if (settings.some((a) => a.key === 'decision')) return; + for (const attr of settings) { + if (DECISION_MODIFIER_KEYS.includes(attr.key)) { + accept('error', decisionModifierMessage(attr.key), { + node: attr, + property: 'key', + }); + } + } } + /** The value's own shape comes first, so a misspelt mapping draws that refusal alone. */ private checkDecisionResultMapping( task: BusinessRuleTask, accept: ValidationAcceptor, ): void { - const attr = settingsOf(task.items).find( - (a) => a.key === 'mapDecisionResult', - ); + const settings = settingsOf(task.items); + const attr = settings.find((a) => a.key === 'mapDecisionResult'); if (!attr) { return; } const value = bindingValueText(attr.value); - if (value !== undefined && DECISION_RESULT_MAPPING_SET.has(value)) { + if (value === undefined || !DECISION_RESULT_MAPPING_SET.has(value)) { + accept( + 'error', + `Setting 'mapDecisionResult' must be ${formatWordList(DECISION_RESULT_MAPPINGS)}.`, + { node: attr, property: 'value' }, + ); return; } - accept( - 'error', - `Setting 'mapDecisionResult' must be ${formatWordList(DECISION_RESULT_MAPPINGS)}.`, - { node: attr, property: 'value' }, - ); + if ( + settings.some((a) => a.key === 'decision') && + !settings.some((a) => a.key === 'resultVariable') + ) { + accept('warning', MAP_DECISION_RESULT_UNREAD_MESSAGE, { + node: attr, + property: 'key', + }); + } } checkScriptTask = (task: ScriptTask, accept: ValidationAcceptor): void => { @@ -2124,6 +2974,14 @@ export class BpmnScriptValidator { } } + const defaultShape = formDefaultShape( + field, + settings.some((setting) => setting.key === DATE_PATTERN_KEY), + ); + if (defaultShape !== undefined) { + accept('error', defaultShape, { node: field, property: 'defaultValue' }); + } + const isEnum = field.type === 'enum'; if (!isEnum) { for (const value of field.values) { @@ -2314,7 +3172,7 @@ export class BpmnScriptValidator { * A boolean flag and an engine-side text field each accept one shape and drop * the rest without a trace: `asyncBefore: "true"` emits no * `operaton:asyncBefore` at all, so the step runs with the setting off, and - * `versionTag = 3` is that slip in reverse. A key this element does not own + * `retryCycle = 3` is that slip in reverse. A key this element does not own * is already an allowed-key error from {@link checkAllowedKeys}. */ private checkAttributeValues( @@ -2338,12 +3196,30 @@ export class BpmnScriptValidator { !isLiteralString(attr.value) && !isRawExpr(attr.value) ) { - accept( - 'error', - `Setting '${attr.key}' takes a quoted string or a "\${...}" ` + - 'expression; put the value in quotes.', - { node: attr, property: 'value' }, - ); + accept( + 'error', + `Setting '${attr.key}' takes a quoted string or a "\${...}" ` + + 'expression; put the value in quotes.', + { node: attr, property: 'value' }, + ); + } else if ( + DUE_DATE_KEY_SET.has(attr.key) && + isLiteralString(attr.value) && + !DUE_DATE_TEXT.test(attr.value.value) + ) { + accept('error', dueDateShapeMessage(attr.key), { + node: attr, + property: 'value', + }); + } else if ( + RETRY_CYCLE_ATTR_KEYS.has(attr.key) && + isLiteralString(attr.value) && + !RETRY_CYCLE_TEXT.test(attr.value.value) + ) { + accept('warning', RETRY_CYCLE_SHAPE_MESSAGE, { + node: attr, + property: 'value', + }); } else if ( PRIORITY_ATTR_KEYS.has(attr.key) && !isPriorityValue(attr.value) @@ -2352,6 +3228,16 @@ export class BpmnScriptValidator { node: attr, property: 'value', }); + } else if (attr.key === 'version' && !isVersionValue(attr.value)) { + accept('error', VERSION_SHAPE_MESSAGE, { + node: attr, + property: 'value', + }); + } else { + const binding = bindingValueMessage(attr.key, attr.value); + if (binding !== undefined) { + accept('error', binding, { node: attr, property: 'value' }); + } } } } @@ -2372,6 +3258,7 @@ export class BpmnScriptValidator { accept, ); this.checkRunSettings(owner, rule, settings, accept); + this.checkJobSettingPairing(owner, rule, settings, accept); this.checkFlags(owner.items, rule.flags, rule.description, accept); // A `call` block has no `forms` member at all. if ('forms' in owner) { @@ -2398,6 +3285,15 @@ export class BpmnScriptValidator { accept: ValidationAcceptor, ): void { if (!rule.repeats) return; + // Only a repeatable kind's grammar rule carries `cardinality` at all, + // exactly the kinds this early return leaves standing. + const cardinality = (owner as { cardinality?: Expr }).cardinality; + if (cardinality !== undefined && !isRepeatCountValue(cardinality)) { + accept('error', REPEAT_COUNT_MESSAGE, { + node: owner as AstNode, + property: 'cardinality', + }); + } const repeated = isRepeated(owner); for (const setting of settings) { if (setting.key === RUN_JOB_PRIORITY_KEY) { @@ -2418,6 +3314,73 @@ export class BpmnScriptValidator { } } + /** + * On a repeated carrier the whole-loop job (`retryCycle`/`exclusive`, paired + * with the plain async flags) and the per-run job (`runRetryCycle`/ + * `runExclusive`/`jobPriority`, paired with the `run` flags) are separate; + * elsewhere there is one job and `jobPriority` joins the plain family, + * except on a signal catcher, whose subscription job it prices by itself. + */ + private checkJobSettingPairing( + owner: AttributeOwner, + rule: AttributeBlockRule, + settings: readonly Setting[], + accept: ValidationAcceptor, + ): void { + if (isTimerCarrier(owner)) return; + if (rule.repeats && isRepeated(owner)) { + this.warnUnpairedJobSettings( + settings, + TIMER_JOB_KEYS_WITHOUT_PRIORITY, + ASYNC_FLAG_KEYS, + rule.description, + noJobMessage, + accept, + ); + this.warnUnpairedJobSettings( + settings, + [...TIMER_JOB_KEYS_WITHOUT_PRIORITY.map(runSettingKey), 'jobPriority'], + ASYNC_FLAG_KEYS.map(runSettingKey), + `a repeated ${rule.description.replace(/^an? /, '')}`, + noPerRunJobMessage, + accept, + ); + return; + } + this.warnUnpairedJobSettings( + settings, + isSignalSubscriptionJobCarrier(owner) + ? TIMER_JOB_KEYS_WITHOUT_PRIORITY + : TIMER_JOB_KEYS, + ASYNC_FLAG_KEYS, + rule.description, + noJobMessage, + accept, + ); + } + + private warnUnpairedJobSettings( + settings: readonly Setting[], + controlKeys: readonly string[], + asyncKeys: readonly string[], + description: string, + message: ( + key: string, + pairing: readonly string[], + description: string, + ) => string, + accept: ValidationAcceptor, + ): void { + if (settings.some((setting) => asyncKeys.includes(setting.key))) return; + for (const setting of settings) { + if (!controlKeys.includes(setting.key)) continue; + accept('warning', message(setting.key, asyncKeys, description), { + node: setting, + property: 'key', + }); + } + } + /** * The block's members split four ways: the io directions, the field * direction, the property direction, and a word that is none of them. Which @@ -2498,7 +3461,7 @@ export class BpmnScriptValidator { ); this.checkRepeatedOutput(owner, directed, accept); for (const param of directed) { - this.checkMapKeys(param.value, accept); + this.checkIoValueShapes(param, accept); } } @@ -2587,6 +3550,19 @@ export class BpmnScriptValidator { node: field, property: 'value', }); + } else if (isLiteralString(field.value) && field.value.value.length === 0) { + accept('error', emptyFieldMessage(field.name), { + node: field, + property: 'value', + }); + } else if ( + isLiteralString(field.value) && + EXPRESSION_OPEN.test(field.value.value) + ) { + accept('error', escapedFieldLiteralMessage(field.name), { + node: field, + property: 'value', + }); } } @@ -2615,21 +3591,41 @@ export class BpmnScriptValidator { /** * A map value may hold a list holding another map, so the walk goes to any - * depth. A keyless entry compiles to unimportable XML. + * depth. A keyless entry compiles to unimportable XML, and an empty literal + * anywhere in the tree - the value itself, a list item, or a map entry's + * value - is `BpmnParseUtil.getElValueProvider`'s null spelling rather than + * an empty string, and it trims before that test. A script anywhere in the + * tree follows the same fence rules a script task's body does: + * `ScriptingEngines.getGlobalScriptEngine` resolves the tag the same way, + * only later, when the parameter is first evaluated rather than at + * deployment. */ - private checkMapKeys( - value: IoValue | undefined, + private checkIoValueShapes( + param: IoParameter, accept: ValidationAcceptor, ): void { - if (value === undefined) return; + if (param.value === undefined) return; + const subject = `${capitalize(param.direction)} '${param.name}'`; - for (const node of AstUtils.streamAst(value)) { + for (const node of AstUtils.streamAst(param.value)) { if (isMapEntry(node) && node.key !== undefined && node.key.length === 0) { accept( 'error', `A map entry's key cannot be empty; name the key its value is looked up by.`, { node, property: 'key' }, ); + } else if (isLiteralString(node) && node.value.trim().length === 0) { + accept('warning', EMPTY_STRING_VALUE_MESSAGE, { + node, + property: 'value', + }); + } else if (isScriptLiteral(node)) { + checkFencedScript( + node.body, + subject, + { node, property: 'body' }, + accept, + ); } } } @@ -2640,26 +3636,17 @@ export class BpmnScriptValidator { rule: AttributeBlockRule, accept: ValidationAcceptor, ): void { - const recognized: Listener[] = []; + // `CoreModelElement.addListenerToMap` and `TaskDefinition.addTaskListener` + // both append to a per-event list, so several listeners on one event run + // in document order; nothing here refuses a repeat. for (const listener of owner.listeners) { if (!this.checkListenerEvent(listener, rule, accept)) { continue; } - recognized.push(listener); this.checkListenerTimer(listener, accept); this.checkListenerBinding(listener, accept); this.checkListenerFields(listener, accept); } - - forEachDuplicate( - recognized, - (listener) => listener.event, - (listener) => - accept('error', `Duplicate 'on ${listener.event}' listener.`, { - node: listener, - property: 'event', - }), - ); } private checkListenerEvent( @@ -2770,8 +3757,9 @@ export class BpmnScriptValidator { /** * A loop's parens refuse the join spelling by name, since the fix is the * unprefixed key. A join every branch closes is pruned by the transform, so - * a setting written for it warns rather than vanishing; the predicate is - * the one {@link checkUnreachableStatements} reads. + * a setting written for it warns rather than vanishing, and the pairing + * warning stays quiet there since the setting is dead either way; the + * predicate is the one {@link checkUnreachableStatements} reads. */ private checkGatewaySettings( stmt: GatewayStatement, @@ -2781,7 +3769,7 @@ export class BpmnScriptValidator { const settings = settingsOf(stmt.items); for (const item of stmt.items) { if (isParenValue(item)) { - accept('error', gatewaySettingsOnlyMessage(rule.description), { + accept('error', settingsOnlyMessage(rule.description), { node: item, }); } @@ -2816,14 +3804,32 @@ export class BpmnScriptValidator { property: 'key', }); } - if (rule.join && statementTerminates(stmt)) { + this.warnUnpairedJobSettings( + settings, + TIMER_JOB_KEYS, + ASYNC_FLAG_KEYS, + rule.description, + noJobMessage, + accept, + ); + if (!rule.join) return; + if (statementTerminates(stmt)) { for (const setting of joinSettings) { accept('warning', prunedJoinMessage(rule.description, setting.key), { node: setting, property: 'key', }); } + return; } + this.warnUnpairedJobSettings( + settings, + TIMER_JOB_KEYS.map(joinSettingKey), + ASYNC_FLAG_KEYS.map(joinSettingKey), + rule.description, + noJobMessage, + accept, + ); } /** The grammar allows an empty `Block`, so an empty branch is a warning, not an error. */ @@ -2846,12 +3852,17 @@ export class BpmnScriptValidator { } }; + /** + * An empty loop body is an error where an empty branch is a warning: with + * no entry node to run the conditioned flow into, the lowering writes the + * gateway with its exit alone and the condition is lost. + */ checkWhileStatement = ( stmt: WhileStatement, accept: ValidationAcceptor, ): void => { this.checkGatewaySettings(stmt, accept); - this.warnIfEmptyBlock(stmt.body, "The 'while' body has no steps.", accept); + this.refuseEmptyLoopBody(stmt.body, 'while', accept); }; checkDoWhileStatement = ( @@ -2859,7 +3870,47 @@ export class BpmnScriptValidator { accept: ValidationAcceptor, ): void => { this.checkGatewaySettings(stmt, accept); - this.warnIfEmptyBlock(stmt.body, "The 'do' body has no steps.", accept); + this.refuseEmptyLoopBody(stmt.body, 'do', accept); + // The body runs before the gateway, so a body that always ends leaves + // the gateway with no incoming flow; the unreachable-step scan only sees + // the statements after it. + if (blockTerminates(blockStatements(stmt.body))) { + accept('error', DEAD_LOOP_MESSAGE, { node: stmt, property: 'condition' }); + } + }; + + private refuseEmptyLoopBody( + block: Block | undefined, + keyword: string, + accept: ValidationAcceptor, + ): void { + if (block !== undefined && block.statements.length === 0) { + accept('error', emptyLoopBodyMessage(keyword), { + node: block, + property: 'statements', + }); + } + } + + /** + * The grammar admits a parameter or a listener in any body so that one + * `Block` rule serves every statement, but no lowering reads them there: + * only an element's own attribute block is lowered. + */ + checkBlock = (block: Block, accept: ValidationAcceptor): void => { + for (const param of block.params) { + accept( + 'error', + blockMemberMessage(`An '${param.direction}' parameter`, 'configures'), + { node: param, property: 'direction' }, + ); + } + for (const listener of block.listeners) { + accept('error', blockMemberMessage('A listener', 'observes'), { + node: listener, + property: 'event', + }); + } }; checkSubProcess = (stmt: SubProcess, accept: ValidationAcceptor): void => { @@ -2963,6 +4014,18 @@ export class BpmnScriptValidator { ); this.checkCatchTrigger(branch, accept); }); + forEachDuplicate( + stmt.branches.filter((branch) => + NAME_SCOPED_TRIGGERS.has(branch.trigger), + ), + (branch) => duplicateKey(branch.trigger, payloadTextOf(branch.items)), + (branch) => + accept( + 'error', + raceDuplicateMessage(branch.trigger, payloadTextOf(branch.items)!), + { node: branch, property: 'trigger' }, + ), + ); }; private warnIfEmptyBlock( @@ -3026,30 +4089,20 @@ export class BpmnScriptValidator { this.checkCallMappingDuplicates(call, accept); }; - /** A missing `process` has no node to attach to, so the diagnostic lands on `name`. */ + /** + * A missing `process` has no node to attach to, so the diagnostic lands on + * `name`; an empty one is {@link bindingValueMessage}'s. + */ private checkCallProcessAttribute( call: CallActivity, accept: ValidationAcceptor, ): void { - const processAttr = settingsOf(call.items).find((a) => a.key === 'process'); - if (!processAttr) { - accept( - 'error', - `A call must name the process it starts: add process: "".`, - { node: call, property: 'name' }, - ); - return; - } - if ( - isLiteralString(processAttr.value) && - processAttr.value.value.length === 0 - ) { - accept( - 'error', - `A call's 'process' setting cannot be empty; name the process to start.`, - { node: processAttr, property: 'value' }, - ); - } + if (settingsOf(call.items).some((a) => a.key === 'process')) return; + accept( + 'error', + `A call must name the process it starts: add process: "".`, + { node: call, property: 'name' }, + ); } /** @@ -3290,9 +4343,11 @@ export class BpmnScriptValidator { /** * The timer a trigger's parens carry: a duration written bare, a date or a - * cycle under its key. Only a handler gets the shape warnings: elsewhere a - * repeating or oddly spelled schedule is a legitimate choice rather than a - * slip worth guessing at. + * cycle under its key. A timer start's expression is parsed at deployment + * (`BpmnDeployer.adjustStartEventSubscriptions`, + * `TimerDeclarationImpl.createStartTimerInstance`), so a bad shape there is + * an error; every other carrier's is read only when its scope is entered, + * so a bad shape there is a warning. * * @param particleOnly The message for a keyed time where the kind takes none. */ @@ -3312,15 +4367,25 @@ export class BpmnScriptValidator { const timer = timerPayloadOf(node.items); if (timer === undefined) { accept('error', TIMER_PAYLOAD_MESSAGE, { node, property: 'trigger' }); - } else if (isOnHandler(node)) { - this.checkTimerShape(node, timer, accept); + return; + } + this.checkTimerShape( + timer.time, + timer.particle, + { node: timer.node, property: 'value' }, + isStartEvent(node) ? 'error' : 'warning', + accept, + ); + if (isOnHandler(node)) { + this.checkTimerRepeatsOnce(node, timer, accept); } } /** * A listener is the one header whose timer is not in the parens: `timeout` * has no lifecycle transition to fire on, so it writes the particle and the - * time after the event word. + * time after the event word. Its timer fires when the task is created, so a + * bad shape is a warning as any non-start carrier's is. */ private checkListenerTimer( listener: Listener, @@ -3347,35 +4412,49 @@ export class BpmnScriptValidator { `Unknown timer particle '${particle}'; write ${formatWordList(TIMER_PARTICLES)}.`, { node: listener, property: 'particle' }, ); + } else if (listener.time !== undefined) { + // The one template slot outside `Expr`, so the expression walk misses it. + checkRawTemplateNames( + listener.time, + { node: listener, property: 'time' }, + accept, + ); + this.checkTimerShape( + listener.time, + particle, + { node: listener, property: 'time' }, + 'warning', + accept, + ); } } - /** The shape checks are warnings because they guess at intent from the spelling. */ + /** + * A raw template's actual value cannot be checked statically, so it always + * passes; a literal is held to the shape its calendar reads + * ({@link TIMER_SHAPE_BY_PARTICLE}). + */ private checkTimerShape( - handler: OnHandler, - timer: TimerPayload, + time: string, + particle: string, + target: { node: AstNode; property: string }, + severity: 'error' | 'warning', accept: ValidationAcceptor, ): void { - const { particle, time } = timer; - if (particle === 'after' && !time.startsWith('P') && !time.includes('${')) { - accept('warning', "'after' expects a duration such as PT1H.", { - node: timer.node, - property: 'value', - }); - } else if ( - particle === 'at' && - (time.startsWith('P') || time.startsWith('R')) && - !time.includes('${') - ) { - accept( - 'warning', - "'at' expects a point in time such as 2026-08-01T09:00:00.", - { node: timer.node, property: 'value' }, - ); + if (EXPRESSION_OPEN.test(time)) return; + const shape = TIMER_SHAPE_BY_PARTICLE[particle]; + if (shape !== undefined && !shape.pattern.test(time)) { + accept(severity, shape.message, target); } - // `every` gets no shape check: cycles and cron are too varied to police. + } - if (particle === 'every' && !hasFlag(handler.items, 'alongside')) { + /** Only an `on` handler's timer can be told to run alongside; the shape checks apply to every carrier alike. */ + private checkTimerRepeatsOnce( + handler: OnHandler, + timer: TimerPayload, + accept: ValidationAcceptor, + ): void { + if (timer.particle === 'every' && !hasFlag(handler.items, 'alongside')) { accept( 'warning', 'A repeating timer that interrupts its scope fires at most once: ' + @@ -3530,9 +4609,10 @@ export class BpmnScriptValidator { } /** - * Two handlers in one container catching the same host, trigger, and code are - * ambiguous to the engine whatever their `alongside`, and Operaton rejects - * the deployment. Runs once per process so a duplicate pair is reported once. + * Two handlers registering the same catch on one engine scope + * ({@link subscriptionScopeOf}) are refused whatever their `alongside`, + * and so is an escalation catch-all beside a coded catch of its own kind. + * Runs once per process so a duplicate pair is reported once. */ private checkHandlerDuplicates( process: Process, @@ -3541,6 +4621,10 @@ export class BpmnScriptValidator { const candidates: OnHandler[] = []; for (const node of AstUtils.streamAst(process)) { if (!isOnHandler(node)) continue; + // An unrecognized trigger word already draws its own error in + // checkOnHandler; comparing it here would key HANDLER_DUPLICATE_RULE by + // a word it does not have and print "undefined" beside that error. + if (!ON_TRIGGERS_SET.has(node.trigger)) continue; // Timer and conditional handlers key no engine subscription name, and // two deadlines in one scope is a real pattern, so they never conflict. if (node.trigger === 'timer' || node.trigger === 'condition') continue; @@ -3549,16 +4633,16 @@ export class BpmnScriptValidator { if (node.host !== undefined && node.host.ref === undefined) continue; candidates.push(node); } - // Grouped by flow container, not syntactic parent: a hosted handler's body - // lowers inline, so handlers at different depths can share one container. - const byContainer = Map.groupBy(candidates, enclosingFlowContainer); - for (const [container, siblings] of byContainer) { - if (container === undefined) continue; + for (const [scope, siblings] of Map.groupBy( + candidates, + subscriptionScopeOf, + )) { + if (scope === undefined) continue; forEachDuplicate( siblings, (handler) => duplicateKey( - handlerHostKey(handler), + catchKindOf(handler), handler.trigger, payloadTextOf(handler.items) ?? '', ), @@ -3567,10 +4651,26 @@ export class BpmnScriptValidator { 'error', handler.trigger === 'compensation' ? COMPENSATION_DUPLICATE_MESSAGE - : handlerDuplicateMessage(handler), + : handlerDuplicateMessage(handler, scope), { node: handler, property: 'trigger' }, ), ); + // The engine refuses the pair on the later one it parses; the later one + // on the page is reported, whichever of the two carries the code. + const codedByKind = new Set(); + const catchAllByKind = new Set(); + for (const handler of siblings) { + if (handler.trigger !== 'escalation') continue; + const kind = catchKindOf(handler); + const coded = payloadTextOf(handler.items) !== undefined; + if (coded ? catchAllByKind.has(kind) : codedByKind.has(kind)) { + accept('error', escalationCatchAllMessage(scope), { + node: handler, + property: 'trigger', + }); + } + (coded ? codedByKind : catchAllByKind).add(kind); + } } } @@ -3652,11 +4752,20 @@ export class BpmnScriptValidator { /** * One check for every element, since the parens are one fragment: an unkeyed * value is the payload slot, and the readers all take the first, so a second - * would be dropped without a word. + * would be dropped without a word. Where the element has no reader at all, + * the first is dropped the same way and is refused here; a start or end + * with no trigger is the shape a payload written without its trigger word + * takes, `start S("PT30M")`. */ checkParenValue = (value: ParenValue, accept: ValidationAcceptor): void => { - if (payloadItemOf(value.$container.items) !== value) { + const owner = value.$container; + if (payloadItemOf(owner.items) !== value) { accept('error', SECOND_PAREN_VALUE_MESSAGE, { node: value }); + return; + } + const description = settingsOnlyOwnerDescription(owner); + if (description !== undefined) { + accept('error', settingsOnlyMessage(description), { node: value }); } }; @@ -3828,11 +4937,31 @@ function collectExpressions(process: Process): Expr[] { } /** - * Whether a name collides with the desugarer's own id namespace. Exported so - * the printer can warn about an imported model's id from this one list. + * Whether a name collides with the desugarer's own id namespace: the patterns, + * and given the id of the container the statement sits in, the exact start and + * end minted for it. Exported so the printer can warn about an imported + * model's id from this one list. + */ +export function isReservedName(name: string, containerId?: string): boolean { + return ( + RESERVED_ID_PATTERNS.some((re) => re.test(name)) || + (containerId !== undefined && + mintedTerminalRole(name, containerId) !== undefined) + ); +} + +/** + * The name of the process or `subprocess`/`attempt` block whose body holds the + * statement, which seeds the implicit start and end the desugarer mints for + * that body. A handler body is not one: its implicit events are seeded from a + * coordinate-derived id no author writes. */ -export function isReservedName(name: string): boolean { - return RESERVED_ID_PATTERNS.some((re) => re.test(name)); +function enclosingContainerName(node: NamedStatement): string | undefined { + const container = AstUtils.getContainerOfType( + node.$container, + (n): n is Process | SubProcess => isProcess(n) || isSubProcess(n), + ); + return container?.name; } function capitalize(text: string): string { @@ -3848,11 +4977,10 @@ function checkFencedScript( ): void { const { tag, code } = splitFencedScript(raw); - if (!SUPPORTED_SCRIPT_TAGS.has(tag)) { + if (scriptFormatOf(tag) === undefined) { accept( 'error', - `${subject} has an unsupported language tag '${tag}'. ` + - "Use 'javascript'/'js', 'groovy', 'python'/'py', 'ruby'/'rb', or 'feel'.", + `${subject} has an unsupported language tag '${tag}'. Use ${formatWordList(Object.keys(SCRIPT_FORMAT_ALIASES))}.`, target, ); } @@ -3976,8 +5104,8 @@ function endTriggerMessage(word: string): string { /** * `RAW_TEMPLATE` is anchored at the opening quote, so only a name that *opens* - * with `${` lexes as an expression; every other placement, and the whole `#{` - * spelling, arrives here as a plain name. + * with `${` or `#{` lexes as a raw template; an opener further in leaves the + * name a plain string. Both reach here as text through `payloadTextOf`. */ const EXPRESSION_IN_NAME = /\$\{|#\{/; @@ -4189,17 +5317,88 @@ const INITIATOR_SHADOWED_MESSAGE = 'last wins, so this setting is never written. Move it to the last ' + 'start, or drop it.'; -function handlerHostKey(handler: OnHandler): string { - return handler.host?.ref ? targetStatementName(handler.host.ref) : ''; +/** The triggers whose payload is a subscription name the engine keys per scope. */ +const NAME_SCOPED_TRIGGERS: ReadonlySet = new Set([ + 'message', + 'signal', +]); + +/** + * Why a second catch of one thing on one scope is refused, per trigger. The + * error arm is the one the engine does not refuse: `addErrorEventDefinition` + * sorts the definitions and the first match wins, so the rule is this + * surface's. + */ +const HANDLER_DUPLICATE_RULE: Readonly> = { + message: + 'Operaton keeps one message subscription per name and scope (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails', + signal: + 'Operaton keeps one signal subscription per name and scope (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails', + escalation: + 'Operaton keeps one escalation catch per code and scope (BpmnParse.addEscalationEventDefinition), so the deployment fails', + cancel: + 'Operaton keeps one cancel handler per attempt block (BpmnParse.parseBoundaryCancelEventDefinition), so the deployment fails', + error: + 'the engine refuses nothing here and takes the first match after sorting its handlers (BpmnParse.addErrorEventDefinition), so this surface keeps one handler per code', +}; + +/** + * The scope the engine registers a handler's catch on: the host of a boundary + * event (`BpmnParse.parseBoundaryEvents`), or the container an event + * sub-process sits in (`BpmnParse.parseEventDefinitionForSubprocess`, through + * the sub-process's flow scope). A handler inside `Sub` and one attached to + * `Sub` therefore share a scope. + */ +function subscriptionScopeOf(handler: OnHandler): AstNode | undefined { + return handler.host?.ref ?? enclosingFlowContainer(handler); +} + +/** + * Which catches of one scope a handler is compared against. A message or + * signal name is unique per scope among the catches that are not process + * starts (`BpmnParse.hasMultipleEventDefinitionsWithSameName` keys the + * start flag too, and `parseEventDefinitionForSubprocess` clears it), so + * the boundary and the event sub-process collide while a message or signal + * start of the same name sits beside either; an escalation, error, or cancel + * catch is compared against its own kind alone + * (`BpmnParse.addEscalationEventDefinition` branches on + * `isSubProcessScope`). A boundary on a repeated host subscribes on the + * multi-instance body, one scope above the host's own + * (`BpmnParse.getMultiInstanceScope`), so a name inside such a host stays free. + */ +function catchKindOf(handler: OnHandler): string { + if (handler.host?.ref === undefined) return 'body'; + if ( + NAME_SCOPED_TRIGGERS.has(handler.trigger) && + !isRepeated(handler.host.ref) + ) { + return 'body'; + } + return 'boundary'; +} + +function scopeNameOf(scope: AstNode): string | undefined { + return isProcess(scope) || isNamedStatement(scope) ? scope.name : undefined; } -function handlerDuplicateMessage(handler: OnHandler): string { +function scopePhrase(scope: AstNode): string { + const name = scopeNameOf(scope); + return name === undefined ? 'in this scope' : `on scope '${name}'`; +} + +function handlerDuplicateMessage(handler: OnHandler, scope: AstNode): string { const code = payloadTextOf(handler.items); const caught = - code !== undefined ? `code '${code}'` : 'every event of this kind'; - const hostKey = handlerHostKey(handler); - const scope = hostKey ? `attached to '${hostKey}'` : 'in this scope'; - return `Another 'on ${handler.trigger}' handler ${scope} already catches ${caught}; a duplicate catch is ambiguous to the engine.`; + code === undefined + ? 'every event of this kind' + : NAME_SCOPED_TRIGGERS.has(handler.trigger) + ? `name '${code}'` + : `code '${code}'`; + return `Another 'on ${handler.trigger}' handler already catches ${caught} ${scopePhrase(scope)}: ${HANDLER_DUPLICATE_RULE[handler.trigger]}.`; +} + +function escalationCatchAllMessage(scope: AstNode): string { + return `An 'on escalation' handler with no code cannot sit beside one with a code ${scopePhrase(scope)}: the code-less one would catch every escalation, and Operaton refuses the pair (BpmnParse.addEscalationEventDefinition). Give both a code, or keep one.`; } function checkEmptyCode( @@ -4236,6 +5435,64 @@ function isPriorityValue(value: Expr): boolean { ); } +/** A non-negative count, bare or quoted, or a value evaluated at each run: a bare name or a raw template. */ +function isRepeatCountValue(value: Expr): boolean { + return ( + isLiteralInt(value) || + isQuotedMatching(value, NON_NEGATIVE_INT_TEXT) || + isVarRef(value) || + isRawExpr(value) + ); +} + +/** The digits of a deployed version, bare or quoted, or a raw template ({@link VERSION_TEXT}). */ +function isVersionValue(value: Expr): boolean { + const text = integerLiteralText(value); + return ( + isRawExpr(value) || + (text === undefined + ? isQuotedMatching(value, VERSION_TEXT) + : VERSION_TEXT.test(text)) + ); +} + +/** + * A form field's default is evaluated when the form renders, so only a + * literal default can be held to the shape its type converts; a bare word or + * a raw template is left to fail, if it must, at that render instead + * (`FormFieldHandler.createFormField`). + */ +function formDefaultShape( + field: FormField, + hasPattern: boolean, +): string | undefined { + const value = field.defaultValue; + if (value === undefined || isRawExpr(value) || isVarRef(value)) { + return undefined; + } + const text = value.$cstNode?.text ?? ''; + switch (field.type) { + case 'number': + return isIntegerValue(value) || isQuotedMatching(value, FORM_BOUND_TEXT) + ? undefined + : numberDefaultMessage(field.id, text); + case 'boolean': + return isLiteralBool(value) || + (isLiteralString(value) && + (value.value === 'true' || value.value === 'false')) + ? undefined + : booleanDefaultMessage(field.id, text); + case 'date': + return !hasPattern && + isLiteralString(value) && + ISO_DATE_ONLY_TEXT.test(value.value) + ? isoDateDefaultMessage(field.id, value.value) + : undefined; + default: + return undefined; + } +} + /** * Why no external extra rides here, or `undefined` where the parens bind a * `topic`. Asked on a kind whose row takes the extras alone. @@ -4388,6 +5645,162 @@ function bindingValueText(expr: Expr): string | undefined { return undefined; } +const variableKeySpelling = (name: string) => + `execution.getVariable('${name}')`; + +/** + * The bracket spelling of a property, keyed on its object as the author wrote + * it: the source up to the accessor, so `order.line.is-paid` reads back + * `order.line['is-paid']`. + */ +function propertyKeySpelling(ref: VarRef, accessor: Accessor): string { + const object = + ref.$cstNode !== undefined && accessor.$cstNode !== undefined + ? ref.$cstNode.text + .slice(0, accessor.$cstNode.offset - ref.$cstNode.offset) + .trim() + : ref.ref.$refText; + return `${object}['${accessor.prop}']`; +} + +/** + * The root and every `.prop` of a reference the rendered `${...}` carries by + * name, each held to what JUEL's scanner reads as one identifier. + */ +function checkRenderedNames(ref: VarRef, accept: ValidationAcceptor): void { + checkRenderedName( + ref.ref.$refText, + variableKeySpelling(ref.ref.$refText), + { node: ref, property: 'ref' }, + accept, + ); + for (const accessor of ref.accessors) { + if (accessor.prop === undefined) continue; + checkRenderedName( + accessor.prop, + propertyKeySpelling(ref, accessor), + { node: accessor, property: 'prop' }, + accept, + ); + } +} + +function checkRenderedName( + name: string, + key: string, + target: { node: AstNode; property: string }, + accept: ValidationAcceptor, +): void { + if (JUEL_RESERVED_WORD_SET.has(name)) { + accept('error', juelKeywordMessage(name, key), target); + } else if (name.includes('-')) { + accept('error', hyphenNameMessage(name, key), target); + } +} + +/** Each `${...}` or `#{...}` of a template; a `}` inside a JUEL string literal is string text (`Scanner.nextString`). */ +const TEMPLATE_BODIES = + /[$#]\{((?:'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|[^'"}])*)\}/g; + +/** A JUEL string literal, to be skipped, or the `.prop` read to hold to {@link checkRenderedName}'s rule. */ +const STRING_OR_PROPERTY_READ = + /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|\.\s*([A-Za-z_$][\w$]*)/g; + +/** + * Every `.prop` a raw template reads, held to the keyword rule a rendered + * accessor is under: the scanner registers the keywords as operators wherever + * they stand, so `${order.and}` fails to parse at deployment. Text between + * two templates is never scanned as an expression, so a word there is text. + */ +function checkRawTemplateNames( + raw: string, + target: { node: AstNode; property: string }, + accept: ValidationAcceptor, +): void { + for (const [, body] of raw.matchAll(TEMPLATE_BODIES)) { + for (const match of body!.matchAll(STRING_OR_PROPERTY_READ)) { + const word = match[1]; + if (word === undefined || !JUEL_RESERVED_WORD_SET.has(word)) continue; + // The object as written, `items[0]` or `order.line`, for the hint. + const object = /[\w$.[\]'"]*$/.exec( + body!.slice(0, match.index).trimEnd(), + )![0]; + accept('error', juelKeywordMessage(word, `${object}['${word}']`), target); + } + } +} + +/** The slot `expr` is the whole condition of, or `undefined` for any other position or a nested operand. */ +function conditionSlotOf(expr: Expr): ConditionSlot | undefined { + const container = expr.$container; + if (expr.$containerProperty === 'completion') return 'until'; + if ( + expr.$containerProperty === 'condition' && + (isIfStatement(container) || + isElseIf(container) || + isWhileStatement(container) || + isDoWhileStatement(container) || + isParallelBranch(container)) + ) { + return 'condition'; + } + // A quoted payload is a code string in the condition's place, which + // `checkConditionPayload` already says; it is not a string condition. + if (isParenValue(container) && !isLiteralString(expr)) { + const owner = container.$container; + if ( + (isStartEvent(owner) || + isOnHandler(owner) || + isIntermediateCatchEvent(owner) || + isRaceBranch(owner)) && + owner.trigger === 'condition' + ) { + return 'condition'; + } + } + return undefined; +} + +/** + * What a condition statically is when that is known and not a Boolean, or + * `undefined` where it is a Boolean or cannot be told: an accessor's type, an + * undeclared name and a template body are all open. + */ +function nonBooleanShapeOf( + expr: Expr, + symbols: VariableTable, +): string | undefined { + if (isParen(expr)) return nonBooleanShapeOf(expr.inner, symbols); + if (isLiteralString(expr)) return 'a string'; + if (isLiteralNull(expr)) return 'null'; + if (isLiteralInt(expr) || isLiteralDecimal(expr)) return 'a number'; + if ( + isAdditive(expr) || + isMultiplicative(expr) || + (isUnary(expr) && expr.op === '-') + ) { + return 'an arithmetic expression'; + } + if (isVarRef(expr) && expr.accessors.length === 0) { + // `json` is left out of BOOLEAN_OK here: a `json` variable is a Spin node, + // which `UelExpressionCondition.evaluate` refuses as non-Boolean. + const type = symbols.get(expr.ref.$refText)?.type; + return type !== undefined && type !== 'boolean' && type !== 'any' + ? `a variable of type '${type}'` + : undefined; + } + if (isTernary(expr)) { + return nonBooleanShapeOf(expr.whenTrue, symbols) !== undefined && + nonBooleanShapeOf(expr.whenFalse, symbols) !== undefined + ? 'a ternary with no boolean arm' + : undefined; + } + if (isRawExpr(expr) && singleTemplateBody(expr.raw) === undefined) { + return 'text around a template, which evaluates to a string'; + } + return undefined; +} + /** * The body of the nearest branch enclosing `node` and the keyword its statement * is written with. Both branch nodes have `body` as their only `Block`-typed diff --git a/packages/language/src/bpmn-script-value-converter.ts b/packages/language/src/bpmn-script-value-converter.ts index 84fab2b..444b6c3 100644 --- a/packages/language/src/bpmn-script-value-converter.ts +++ b/packages/language/src/bpmn-script-value-converter.ts @@ -1,15 +1,15 @@ /** - * Normalizes `Listener.time`, which `time=(STRING | RAW_TEMPLATE)` fills with a - * plain duration (`on timeout after "PT1H"`) or an EL template - * (`on timeout at "${dueDate}"`). Langium's `DefaultValueConverter` - * auto-unquotes `STRING` but returns `RAW_TEMPLATE` verbatim, quotes included, - * because the terminal's other use (`RawExpr.raw`) needs them kept for - * `expression-render.ts` to strip, so left alone the two alternatives disagree: - * `PT1H` vs `"${dueDate}"`. + * Reads every `RAW_TEMPLATE` token the way Langium reads `STRING`: quotes + * dropped and the escapes `\"`, `\\`, `\n`, `\r`, `\t` resolved, so + * `"${fn(\"a\")}"` becomes `${fn("a")}`. Langium's `DefaultValueConverter` + * unquotes only the terminal named `STRING`; a string-typed terminal under + * any other name comes back verbatim, and a body kept with its escapes would + * reach the engine with a `\` outside a JUEL string and gain one more each + * time the printer quotes it. + * The printer's quoting is the exact inverse of this read. */ import { - AstUtils, DefaultValueConverter, GrammarAST, ValueConverter, @@ -18,7 +18,6 @@ import { } from 'langium'; const RAW_TEMPLATE_RULE_NAME = 'RAW_TEMPLATE'; -const TIME_FEATURE_NAME = 'time'; export class BpmnScriptValueConverter extends DefaultValueConverter { protected override runConverter( @@ -26,19 +25,9 @@ export class BpmnScriptValueConverter extends DefaultValueConverter { input: string, cstNode: CstNode, ): ValueType { - if ( - rule.name === RAW_TEMPLATE_RULE_NAME && - this.fillsTimeFeature(cstNode) - ) { + if (rule.name === RAW_TEMPLATE_RULE_NAME) { return ValueConverter.convertString(input); } return super.runConverter(rule, input, cstNode); } - - private fillsTimeFeature(cstNode: CstNode): boolean { - const source = cstNode.grammarSource; - const assignment = - source && AstUtils.getContainerOfType(source, GrammarAST.isAssignment); - return assignment?.feature === TIME_FEATURE_NAME; - } } diff --git a/packages/language/src/bpmn-script.langium b/packages/language/src/bpmn-script.langium index 93a535f..d1a67d8 100644 --- a/packages/language/src/bpmn-script.langium +++ b/packages/language/src/bpmn-script.langium @@ -270,9 +270,11 @@ DoWhileStatement: // parallel -> AND fork/join, or an OR fork/join when any branch carries a // condition. An unheaded branch always runs, a conditioned one when its -// condition holds, and the `else` branch only when none held; left out, that -// fallback goes straight to the join. The parens after the keyword hold the -// fork's settings and, `join`-prefixed, the join's; a branch head takes none. +// condition holds, and the `else` branch only when none held. Left out, the +// fallback goes straight to the join when every branch is conditioned, and is +// not written at all beside an unheaded branch, which the engine takes every +// time. The parens after the keyword hold the fork's settings and, +// `join`-prefixed, the join's; a branch head takes none. ParallelStatement: 'parallel' SettingsParens? '{' branches+=ParallelBranch (branches+=ParallelBranch)+ '}'; @@ -474,9 +476,9 @@ Paren: // The escape hatch for expressions outside the JUEL subset and for variables // colliding with a keyword, valid anywhere an Expr is; the renderer emits `raw` -// verbatim. It is told apart from a plain LiteralString purely by content, a -// quoted string whose body begins with `${`, which the RAW_TEMPLATE terminal -// captures. +// as written. It is told apart from a plain LiteralString purely by content, a +// quoted string whose body begins with `${` or `#{`, the two openers the engine +// evaluates, which the RAW_TEMPLATE terminal captures. RawExpr: raw=RAW_TEMPLATE; @@ -501,8 +503,8 @@ terminal INT returns number: /[0-9]+/; terminal FENCED_SCRIPT: /```[a-zA-Z]+[\s\S]*?```/; // Declared before STRING: the two regexes overlap on a body beginning with -// `${`, so declaration order is what makes that input a RawExpr. -terminal RAW_TEMPLATE: /"\$\{(\\.|[^"\\])*"|'\$\{(\\.|[^'\\])*'/; +// `${` or `#{`, so declaration order is what makes that input a RawExpr. +terminal RAW_TEMPLATE: /"[$#]\{(\\.|[^"\\])*"|'[$#]\{(\\.|[^'\\])*'/; terminal STRING: /"(\\.|[^"\\])*"|'(\\.|[^'\\])*'/; diff --git a/packages/language/src/expression-render.ts b/packages/language/src/expression-render.ts index 35c59b6..31164dd 100644 --- a/packages/language/src/expression-render.ts +++ b/packages/language/src/expression-render.ts @@ -39,10 +39,10 @@ export function integerLiteralText(node: Expr): string | undefined { return undefined; } -/** A {@link RawExpr} body is already a complete one and comes back verbatim. */ +/** A {@link RawExpr} is already a complete `${...}` or `#{...}` and comes back as written. */ export function renderExpression(node: Expr): string { if (isRawExpr(node)) { - return unquoteRaw(node.raw); + return node.raw; } return `\${${renderExpressionInner(node)}}`; } @@ -54,7 +54,11 @@ export function renderExpression(node: Expr): string { */ export function renderExpressionInner(node: Expr): string { if (isRawExpr(node)) { - return unquoteRaw(node.raw); + // JUEL has no `${` token once inside an expression, so a raw operand is + // spliced in by its body. A composite raw has no one body to splice and is + // left as written for the validator to refuse. + const body = singleTemplateBody(node.raw); + return body === undefined ? node.raw : `(${body})`; } if (isTernary(node)) { return ( @@ -86,9 +90,11 @@ export function renderExpressionInner(node: Expr): string { return String(node.value); } if (isLiteralString(node)) { - // The lexer stripped the author's quotes. Re-quoting the way `juel.ts` in - // `@bpmn-script/transform` does keeps a parse of this output idempotent. - return `"${node.value.replace(/"/g, '\\"')}"`; + // The lexer stripped the author's quotes. This produces JUEL text, so it + // must double the backslash before escaping the quote, same as `juel.ts` + // in `@bpmn-script/transform` does: those are the only two escapes + // operaton-juel's `Scanner.nextString` accepts. + return `"${node.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; } if (isLiteralBool(node) || isLiteralNull(node)) { return node.value; @@ -114,16 +120,16 @@ function renderAccessor(accessor: Accessor): string { } /** - * Langium auto-unquotes only the default STRING terminal, so RAW_TEMPLATE keeps - * the author's surrounding `"` or `'`. An unquoted body passes through. + * The body of a raw template that is exactly one template, `${body}` or + * `#{body}` with no second opener and no `}` before the last, else + * `undefined`: `${a} and ${b}` and `${a} b}` are composites the engine + * evaluates to text around the one template each holds. A `}` or opener + * inside a JUEL string literal is string text (`Scanner.nextString`), so + * `${map['}']}` is one template. */ -export function unquoteRaw(raw: string): string { - if (raw.length >= 2) { - const first = raw[0]; - const last = raw[raw.length - 1]; - if ((first === '"' && last === '"') || (first === "'" && last === "'")) { - return raw.slice(1, -1); - } - } - return raw; +export function singleTemplateBody(raw: string): string | undefined { + if (!/^[$#]\{[^]*\}$/.test(raw)) return undefined; + const body = raw.slice(2, -1); + const unquoted = body.replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, ''); + return /\}|[$#]\{/.test(unquoted) ? undefined : body; } diff --git a/packages/language/src/paren-items.ts b/packages/language/src/paren-items.ts index 68381a1..d06e57c 100644 --- a/packages/language/src/paren-items.ts +++ b/packages/language/src/paren-items.ts @@ -13,7 +13,6 @@ import { TIMER_PARTICLE_BY_KIND, } from './vocabulary.js'; import type { AstNode } from 'langium'; -import { unquoteRaw } from './expression-render.js'; import { isErrorMapping, isLiteralString, @@ -224,7 +223,7 @@ export function payloadTextOf(items: ParenItem[]): string | undefined { if (value === undefined) return undefined; if (isLiteralString(value)) return value.value; if (isVarRef(value)) return value.ref.$refText; - if (isRawExpr(value)) return unquoteRaw(value.raw); + if (isRawExpr(value)) return value.raw; return undefined; } @@ -275,10 +274,10 @@ export function timerPayloadOf(items: ParenItem[]): TimerPayload | undefined { : { particle: TIMER_PARTICLE_BY_KIND.duration, time, node: bare }; } -/** A time is quoted text; a raw template carries its quotes and is stripped here. */ +/** A time is quoted text or a raw template, both read as the text they carry. */ function timeTextOf(value: Expr): string | undefined { if (isLiteralString(value)) return value.value; - if (isRawExpr(value)) return unquoteRaw(value.raw); + if (isRawExpr(value)) return value.raw; return undefined; } diff --git a/packages/language/src/variable-symbol-provider.ts b/packages/language/src/variable-symbol-provider.ts index f588826..a213b96 100644 --- a/packages/language/src/variable-symbol-provider.ts +++ b/packages/language/src/variable-symbol-provider.ts @@ -9,6 +9,7 @@ import { caughtBindingsOf } from './paren-items.js'; import { FIELD_DIRECTION, formFieldVariableType, + LOOP_VARIABLES, PROPERTY_DIRECTION, } from './vocabulary.js'; import { @@ -50,18 +51,6 @@ export function isRepeated(node: AstNode): node is AstNode & RepeatSlots { ); } -/** - * The variables Operaton sets around a repeated step: three counters on the - * repetition and `loopCounter` on each run (`MultiInstanceActivityBehavior`). - * They exist undeclared, so a process that repeats anything gets them in scope. - */ -const LOOP_VARIABLES = [ - 'nrOfInstances', - 'nrOfActiveInstances', - 'nrOfCompletedInstances', - 'loopCounter', -] as const; - export class DefaultVariableSymbolProvider implements VariableSymbolProvider { collect(process: Process): VariableTable { const table: VariableTable = new Map(); diff --git a/packages/language/src/vocabulary.ts b/packages/language/src/vocabulary.ts index ff36d2c..c78e7d3 100644 --- a/packages/language/src/vocabulary.ts +++ b/packages/language/src/vocabulary.ts @@ -9,7 +9,7 @@ import type { AstNode, Grammar } from 'langium'; import { AstUtils, GrammarAST } from 'langium'; -import { isOnHandler, isSubProcess } from './generated/ast.js'; +import { isSubProcess } from './generated/ast.js'; import type { BusinessRuleTask, CallActivity, @@ -69,6 +69,23 @@ export function formatWordList(words: readonly string[]): string { return formatPlainWordList(words.map((w) => `'${w}'`)); } +/** The two flags that create a continuation job (`BpmnParse.parseAsynchronousContinuation`). */ +export const ASYNC_FLAG_KEYS = ['asyncBefore', 'asyncAfter'] as const; + +/** + * The three settings that only configure a job something else declares, and + * that a timer job takes off the element declaring the timer: the lock and + * the priority in `BpmnParse.parseTimer`, the retry cycle in + * `DefaultFailedJobParseListener.parseStartEvent`, `parseBoundaryEvent` and + * `parseIntermediateCatchEvent`. + */ +export const TIMER_JOB_KEYS = [ + 'exclusive', + 'jobPriority', + 'retryCycle', +] as const; +export type TimerJobKey = (typeof TIMER_JOB_KEYS)[number]; + /** * The engine execution settings Operaton reads off any flow node, gateways * included: `BpmnParse.parseExclusiveGateway`, `parseInclusiveGateway`, @@ -79,13 +96,22 @@ export function formatWordList(words: readonly string[]): string { * same four. */ export const ENGINE_KEYS: readonly string[] = [ - 'asyncBefore', - 'asyncAfter', - 'exclusive', - 'jobPriority', - 'retryCycle', + ...ASYNC_FLAG_KEYS, + ...TIMER_JOB_KEYS, ]; +/** + * The variables Operaton sets around a repeated step: three counters on the + * repetition and `loopCounter` on each run (`MultiInstanceActivityBehavior`). + * They exist undeclared, so a process that repeats anything has them in scope. + */ +export const LOOP_VARIABLES = [ + 'nrOfInstances', + 'nrOfActiveInstances', + 'nrOfCompletedInstances', + 'loopCounter', +] as const; + /** `key` spelled for a second carrier in the same parens: `join` + `asyncBefore` is `joinAsyncBefore`. */ export function prefixedSettingKey(prefix: string, key: string): string { return prefix + key.charAt(0).toUpperCase() + key.slice(1); @@ -116,8 +142,11 @@ export function runSettingKey(key: string): string { * `BpmnParse.parseAsynchronousContinuationForActivity` hands that element to * `parseAsynchronousContinuation`, which reads the three async settings onto * each run, and `DefaultFailedJobParseListener.parseActivity` reads the retry - * cycle there the same way; `createActivityOnScope` reads a job priority off - * the activity element alone, which is why there is no `runJobPriority`. + * cycle there the same way; `BpmnParse.parseActivity` makes the multi-instance + * body activity that `parseMultiInstanceLoopCharacteristics` builds for the + * repeated element the scope before `createActivityOnScope` runs, so the + * plain `jobPriority` in the same parens already prices each run's activity, + * not the whole loop's, which is why there is no `runJobPriority`. */ export const RUN_ENGINE_KEYS: readonly string[] = ENGINE_KEYS.filter( (key) => key !== 'jobPriority', @@ -296,7 +325,11 @@ export const CALL_BINDING_VALUES: readonly string[] = ['latest', 'deployment']; /** * The settings a process header takes, in the order they are offered and * printed. Each attaches to the `bpmn:process` element itself rather than to - * any node inside it. + * any node inside it. `BpmnParse.parseProcess` also reads `jobPriority`, + * `taskPriority`, `isStartableInTasklist`, a process-level execution listener, + * and `potentialStarter` off the same element; this set leaves them out on + * purpose, since giving them authoring surface is a separate addition, not a + * gap in this list. */ export const PROCESS_HEADER_KEYS: readonly string[] = [ 'label', @@ -369,10 +402,13 @@ export const EVENT_BINDING_FIELDS: readonly string[] = ['code', 'message']; /** Legal on every element with a member block, since each lowers to a flow node. */ export const EXECUTION_LISTENER_EVENTS = ['start', 'end'] as const; -/** Legal on a user task alone; `timeout` is the one carrying a timer clause. */ +/** + * Legal on a user task alone, spelled as `BpmnParse.parseTaskListeners` + * accepts them; `timeout` is the one carrying a timer clause. + */ export const TASK_LISTENER_EVENTS = [ 'create', - 'assign', + 'assignment', 'complete', 'update', 'delete', @@ -469,6 +505,28 @@ export const FORM_BOUND_TEXT = /^-?\d+$/; */ export const EXPRESSION_OPEN = /^\s*[$#]\{/; +/** + * The words JUEL's `Scanner.addKeyToken` registers as operators besides + * `true`, `false` and `null`, which the grammar spells as literals. A variable + * or property named one of these renders as `${mod > 1}`, which the JUEL + * parser refuses at deployment. + */ +export const JUEL_RESERVED_WORDS = [ + 'empty', + 'div', + 'mod', + 'not', + 'and', + 'or', + 'le', + 'lt', + 'eq', + 'ne', + 'ge', + 'gt', + 'instanceof', +] as const; + /** * Written to `datePattern`, which `FormTypes.parseFormPropertyType` reads on * a `date` field alone. A type parameter rather than a constraint, so it is @@ -545,9 +603,12 @@ export const RACE_TRIGGERS = [ export const CATCH_TRIGGERS = [...RACE_TRIGGERS, 'link'] as const; /** - * The triggers Operaton dispatches a start behaviour for. It ignores an error, - * escalation, or compensation trigger there and starts as if none were - * written, so those stay off rather than emitting XML the engine disregards. + * The triggers `BpmnParse.parseProcessDefinitionStartEvent` dispatches a start + * behaviour for. It sets `NoneStartEventActivityBehavior` on every + * process-level start regardless, then only checks for a timer, message, + * signal, or conditional definition; an error, escalation, or compensation + * definition there is never inspected, so it starts as if none were written. + * Those three stay off rather than emitting XML the engine disregards. */ export const START_TRIGGERS = [ 'message', @@ -690,10 +751,17 @@ export function namesACode(trigger: string): boolean { /** * Fence-tag aliases and the canonical Operaton `scriptFormat` they normalize * to; the printer emits the canonical tag, so `js` round-trips to `javascript`. + * `juel` is the one engine the jar always registers + * (`META-INF/services/javax.script.ScriptEngineFactory`, + * `ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE`); every other tag resolves a + * JSR-223 engine off the classpath at first evaluation. `ecmascript` is the + * name Operaton also accepts for the JavaScript engine. */ export const SCRIPT_FORMAT_ALIASES: Readonly> = { + juel: 'juel', js: 'javascript', javascript: 'javascript', + ecmascript: 'javascript', groovy: 'groovy', py: 'python', python: 'python', @@ -702,23 +770,37 @@ export const SCRIPT_FORMAT_ALIASES: Readonly> = { feel: 'feel', }; +/** + * The one lookup the validator and every lowering site use: `juel`, + * `JavaScript`, and `ECMAScript` all resolve, matching + * `ScriptingEngines.getScriptEngineForLanguage`, which lowercases the + * language before looking an engine up. + */ +export function scriptFormatOf(tag: string): string | undefined { + return SCRIPT_FORMAT_ALIASES[tag.toLowerCase()]; +} + /** * Split a raw `FENCED_SCRIPT` token into the fence tag * {@link SCRIPT_FORMAT_ALIASES} is keyed by and the code body. The tag is the * maximal run of ASCII letters after the opening fence, and one line terminator * right after it is dropped; nothing else is touched, so indentation and - * trailing newlines inside the body survive verbatim. + * trailing newlines inside the body survive verbatim, except that every + * remaining `\r\n` is normalized to `\n`: the terminal matches `\r` like any + * other character, so a CRLF-checked-out source would otherwise carry a + * carriage return into the written `` body that an LF checkout + * never would. */ export function splitFencedScript(raw: string): { tag: string; code: string } { const inner = raw.slice(3, -3); // strip the opening/closing ``` delimiters const tag = /^[a-zA-Z]+/.exec(inner)?.[0] ?? ''; const rest = inner.slice(tag.length); - const code = rest.startsWith('\r\n') + const afterOpeningLine = rest.startsWith('\r\n') ? rest.slice(2) : rest.startsWith('\n') ? rest.slice(1) : rest; - return { tag, code }; + return { tag, code: afterOpeningLine.replace(/\r\n/g, '\n') }; } /** Each carries `items`, `params`, and `listeners`; all but `call` also carry `forms`. */ @@ -781,7 +863,9 @@ export interface AttributeBlockRule { * Whether the kind takes an external task's extras: `taskPriority` in the * parens, and `property` and `error ... when` lines in the block, each * checked against the binding actually written. A thrown message can bind a - * topic too and takes none of them. + * topic too, and this surface writes none of them for it; + * `BpmnParse.parseServiceTaskLike` reads the priority and the mappings off + * the message definition (the property list off the event element). */ readonly externalExtras: boolean; } @@ -970,6 +1054,10 @@ export const ATTRIBUTE_BLOCK_RULES: Readonly< taskListeners: false, externalExtras: false, }), + // A hosted handler lowers to a boundary event, on which + // `BpmnParse.parseBoundaryEvents` refuses a mapping, and a host-less one to + // an event sub-process, which `BpmnParse.checkActivityInputOutputSupported` + // refuses one on. OnHandler: withKeys({ description: 'an event handler', own: [], @@ -1089,16 +1177,6 @@ export function gatewayStatementRuleOf( return rules[node.$type]; } -/** - * A host-less `on` handler lowers to an event sub-process, so it carries - * parameters as a `subprocess` does. A hosted one lowers to a boundary event, - * which has none, and keeps the plain row. - */ -const EVENT_SUB_PROCESS_RULE: AttributeBlockRule = { - ...ATTRIBUTE_BLOCK_RULES.OnHandler, - parameters: true, -}; - export function listenerEventsFor(rule: AttributeBlockRule): readonly string[] { return rule.taskListeners ? [...EXECUTION_LISTENER_EVENTS, ...TASK_LISTENER_EVENTS] @@ -1133,9 +1211,6 @@ export function attributeBlockRuleOf( const rules: Readonly> = ATTRIBUTE_BLOCK_RULES; const rule = rules[node.$type]; - if (rule && isOnHandler(node) && node.host === undefined) { - return EVENT_SUB_PROCESS_RULE; - } if (rule && isSubProcess(node) && node.transactional) { return ATTEMPT_BLOCK_RULE; } diff --git a/packages/language/test/completion.test.ts b/packages/language/test/completion.test.ts index 230241d..13e53db 100644 --- a/packages/language/test/completion.test.ts +++ b/packages/language/test/completion.test.ts @@ -208,7 +208,7 @@ const STATEMENTS: Item[] = [ [ 'script', CONSTRUCT, - 'script ${1:id} ```${2|javascript,groovy,python,ruby,feel|}\n\t$0\n```', + 'script ${1:id} ```${2|juel,javascript,groovy,python,ruby,feel|}\n\t$0\n```', ], ['step', CONSTRUCT, 'step ${1:id}'], ['send', CONSTRUCT, 'send ${1:id}(class: "${2:com.example.Delegate}")'], @@ -405,13 +405,6 @@ const FIELD_BLOCK_MEMBERS: Item[] = [ ERROR_MAPPING, ]; -/** - * The first brace block of an element that also takes a body is ambiguous while - * it is still open: no member commits it to being the member block, so a - * statement stays possible and both sets are offered. - */ -const BLOCK_OR_BODY: Item[] = [...STATEMENTS, ...PARAMETERS]; - /** The three ways a listener binds; also the whole listener parens. */ const BINDINGS: Item[] = [ ['class', SETTING, 'class: "${1:com.example.Delegate}"'], @@ -559,7 +552,7 @@ const EXECUTION_EVENTS: Item[] = [listenerEvent('start'), listenerEvent('end')]; const TASK_EVENTS: Item[] = [ ...EXECUTION_EVENTS, listenerEvent('create'), - listenerEvent('assign'), + listenerEvent('assignment'), listenerEvent('complete'), listenerEvent('update'), listenerEvent('delete'), @@ -807,13 +800,15 @@ describe('the completions offered at a caret', () => { 'process p {\n user T {\n on create(class: "com.example.L") {\n |\n }\n }\n}', [FIELD], ], + // The engine refuses a mapping on an event sub-process and a boundary + // event carries none, so neither handler form offers a parameter. [ - 'a host-less handler block offers parameters, which a boundary event has none of', - 'process p {\n start S\n on error {\n input x = 1\n |\n } {\n end Failed\n }\n}', - BLOCK_OR_BODY, + 'a host-less handler block offers no parameter', + 'process p {\n start S\n on error {\n |\n } {\n end Failed\n }\n}', + STATEMENTS, ], [ - 'a hosted handler lowers to a boundary event, so its block offers no parameter', + 'a hosted handler block offers no parameter either', 'process p {\n user U\n on U: error {\n |\n } {\n end Failed\n }\n}', STATEMENTS, ], @@ -969,6 +964,11 @@ const FORM_REFERENCE_MODIFIERS = new Set(['binding', 'version']); const pinsAForm = ([label]: Item) => FORM_REFERENCE_MODIFIERS.has(label); const namesAForm = ([label]: Item) => label === 'formRef'; +/** The settings a decision step carries only beside `decision`, so their host binds one. */ +const DECISION_MODIFIERS = new Set(['binding', 'version', 'mapDecisionResult']); + +const pinsADecision = ([label]: Item) => DECISION_MODIFIERS.has(label); + /** * The declaration a statement scaffold needs in the header. Every scaffold * writes its code as the same placeholder name, and a code reaches only the @@ -1046,9 +1046,14 @@ describe('a scaffold parses and validates once accepted', () => { ), ...scaffolds( 'the parens of a decision step', - DECIDE_PARENS.filter((item) => !binds(item)), + DECIDE_PARENS.filter((item) => !binds(item) && !pinsADecision(item)), (setting) => `process p {\n decide D(topic: "t", ${setting})\n}`, ), + ...scaffolds( + 'the parens of a decision step', + DECIDE_PARENS.filter(pinsADecision), + (setting) => `process p {\n decide D(decision: "d", ${setting})\n}`, + ), ...scaffolds( 'the parens of a decision step', DECIDE_PARENS.filter((item) => binds(item) && !bindsBuiltin(item)), diff --git a/packages/language/test/helpers/block-hosts.ts b/packages/language/test/helpers/block-hosts.ts index 5a7e2c1..3db3c32 100644 --- a/packages/language/test/helpers/block-hosts.ts +++ b/packages/language/test/helpers/block-hosts.ts @@ -156,9 +156,10 @@ export const BLOCK_HOSTS: ReadonlyArray< ]; /** - * The element kinds whose block carries `input`/`output` parameters. A - * host-less `on` handler is in the set because it lowers to an event - * sub-process; the hosted form lowers to a boundary event and is not. + * The element kinds whose block carries `input`/`output` parameters. Neither + * handler form is in the set: the hosted one lowers to a boundary event, which + * carries none, and the host-less one to an event sub-process, which + * `BpmnParse.checkActivityInputOutputSupported` refuses a mapping on. */ export const PARAMETER_HOSTS = new Set([ 'user', @@ -171,7 +172,6 @@ export const PARAMETER_HOSTS = new Set([ 'subprocess', 'attempt', 'call', - 'on', ]); /** The element kinds whose block carries a `form` declaration. */ diff --git a/packages/language/test/parsing.test.ts b/packages/language/test/parsing.test.ts index 3a6adf3..4e1097c 100644 --- a/packages/language/test/parsing.test.ts +++ b/packages/language/test/parsing.test.ts @@ -323,7 +323,7 @@ describe('Parsing - control flow and containers', () => { out shipped = confirmed } }`, - '[CallActivity(name="Fulfilment", items=[Setting(key="label", value=LiteralString(value="Fulfil order")), Setting(key="process", value=LiteralString(value="fulfilment-process")), Setting(key="binding", value=VarRef(ref=->deployment)), Setting(key="businessKey", value=RawExpr(raw="\\"${execution.processBusinessKey}\\""))], mappings=[VariableMapping(direction="in", all=true), VariableMapping(direction="in", target="orderId"), VariableMapping(direction="in", target="total", source=Additive(left=VarRef(ref=->amount), op="+", right=VarRef(ref=->tax))), VariableMapping(direction="in", local=true, target="vip", source=VarRef(ref=->vipFlag)), VariableMapping(direction="out", target="shipmentId"), VariableMapping(direction="out", target="shipped", source=VarRef(ref=->confirmed))])]', + '[CallActivity(name="Fulfilment", items=[Setting(key="label", value=LiteralString(value="Fulfil order")), Setting(key="process", value=LiteralString(value="fulfilment-process")), Setting(key="binding", value=VarRef(ref=->deployment)), Setting(key="businessKey", value=RawExpr(raw="${execution.processBusinessKey}"))], mappings=[VariableMapping(direction="in", all=true), VariableMapping(direction="in", target="orderId"), VariableMapping(direction="in", target="total", source=Additive(left=VarRef(ref=->amount), op="+", right=VarRef(ref=->tax))), VariableMapping(direction="in", local=true, target="vip", source=VarRef(ref=->vipFlag)), VariableMapping(direction="out", target="shipmentId"), VariableMapping(direction="out", target="shipped", source=VarRef(ref=->confirmed))])]', ], [ 'an integer attribute value is a LiteralInt', @@ -333,7 +333,7 @@ describe('Parsing - control flow and containers', () => { [ 'a raw-template attribute value is a RawExpr', `process p { call C(version: "\${v}") }`, - '[CallActivity(name="C", items=[Setting(key="version", value=RawExpr(raw="\\"${v}\\""))])]', + '[CallActivity(name="C", items=[Setting(key="version", value=RawExpr(raw="${v}"))])]', ], [ 'a minimal call with only `process` parses and carries no mappings', @@ -451,7 +451,7 @@ describe('Parsing - the event layer', () => { [ 'a quoted raw template in the parens is a RawExpr condition', `process p { on condition("\${bean.check()}") { } }`, - '[OnHandler(trigger="condition", items=[ParenValue(value=RawExpr(raw="\\"${bean.check()}\\""))], body=Block)]', + '[OnHandler(trigger="condition", items=[ParenValue(value=RawExpr(raw="${bean.check()}"))], body=Block)]', ], [ 'alongside is legal on a condition handler', @@ -709,9 +709,19 @@ describe('Parsing - expressions', () => { 'VarRef(ref=->items, accessors=[Accessor(index=LiteralInt(value=0))])', ], [ - 'a method call falls back to the quoted raw template, quotes and all', + 'a method call falls back to the raw template, read without its quotes', '"${bean.method()}"', - 'RawExpr(raw="\\"${bean.method()}\\"")', + 'RawExpr(raw="${bean.method()}")', + ], + [ + 'the `#{` opener is a raw template too, not a string literal', + '"#{bean.method()}"', + 'RawExpr(raw="#{bean.method()}")', + ], + [ + 'a raw template resolves its string escapes the way a literal does', + '"${fn(\\"a\\", \'b\')}"', + 'RawExpr(raw="${fn(\\"a\\", \'b\')}")', ], [ 'a ternary parses to a Ternary node over three expressions', @@ -745,12 +755,12 @@ describe('Parsing - expressions', () => { [ 'the raw-template fallback carries a reserved word as an identifier', '"${version > 2}"', - 'RawExpr(raw="\\"${version > 2}\\"")', + 'RawExpr(raw="${version > 2}")', ], [ 'the raw-template fallback carries a reserved event keyword as an identifier', '"${emit}"', - 'RawExpr(raw="\\"${emit}\\"")', + 'RawExpr(raw="${emit}")', ], [ 'an equality over a soft event word parses', @@ -761,10 +771,25 @@ describe('Parsing - expressions', () => { expect(shape(await parseCondition(expr))).toBe(expected); }); - // The RawExpr row is the one that strips the author's surrounding quotes. + // A raw template at the top is printed as read, opener included and escapes + // resolved; one nested under an operator is a single template spliced in as a + // parenthesised operand, or a composite left as written for the validator. test.each([ ['amount > 1000', '${amount > 1000}'], + ['a == null', '${a == null}'], ['"${bean.method()}"', '${bean.method()}'], + ['"#{bean.method()}"', '#{bean.method()}'], + ["'${a}'", '${a}'], + ['"${fn(\\"a\\")}"', '${fn("a")}'], + ['"a\\\\b" == x', '${"a\\\\b" == x}'], + ['"${a} and ${b}"', '${a} and ${b}'], + ['!"${x}"', '${!(x)}'], + ['"${a.b}" > 1', '${(a.b) > 1}'], + ['"${x}" && x', '${(x) && x}'], + ['!"#{x}"', '${!(x)}'], + ['!"${a} and ${b}"', '${!${a} and ${b}}'], + ['!"${a} b}"', '${!${a} b}}'], + ['!"${map[\'}\']}"', "${!(map['}'])}"], [ 'order.total > 1000 && items[0] == status', '${order.total > 1000 && items[0] == status}', @@ -920,7 +945,7 @@ ${FENCE} } [ 'the start and end statement keywords are usable as listener events', `process p { service S { on start(class: "A") on end(expression: "\${b.m()}") } }`, - '[ServiceTask(name="S", listeners=[Listener(event="start", items=[Setting(key="class", value=LiteralString(value="A"))]), Listener(event="end", items=[Setting(key="expression", value=RawExpr(raw="\\"${b.m()}\\""))])])]', + '[ServiceTask(name="S", listeners=[Listener(event="start", items=[Setting(key="class", value=LiteralString(value="A"))]), Listener(event="end", items=[Setting(key="expression", value=RawExpr(raw="${b.m()}"))])])]', ], [ 'a timeout listener carries a timer particle and its time', @@ -959,7 +984,7 @@ ${FENCE} call C(process: "q") { on start(class: "X") } end E { on end(delegate: "\${bean}") } }`, - '[CallActivity(name="C", items=[Setting(key="process", value=LiteralString(value="q"))], listeners=[Listener(event="start", items=[Setting(key="class", value=LiteralString(value="X"))])]), EndEvent(name="E", listeners=[Listener(event="end", items=[Setting(key="delegate", value=RawExpr(raw="\\"${bean}\\""))])])]', + '[CallActivity(name="C", items=[Setting(key="process", value=LiteralString(value="q"))], listeners=[Listener(event="start", items=[Setting(key="class", value=LiteralString(value="X"))])]), EndEvent(name="E", listeners=[Listener(event="end", items=[Setting(key="delegate", value=RawExpr(raw="${bean}"))])])]', ], [ 'settings, a form block, parameters and listeners mix on one element', @@ -1011,7 +1036,7 @@ describe("Parsing - an external task's extras", () => { [ 'a mapping with a word other than `when` parses, so the validator can name the word', `process p { service V(topic: "t") { error E wenn "\${x}" } }`, - 'Process(name="p", body=[ServiceTask(name="V", items=[Setting(key="topic", value=LiteralString(value="t"))], errorMappings=[ErrorMapping(trigger="error", code=->E, when="wenn", condition=RawExpr(raw="\\"${x}\\""))])])', + 'Process(name="p", body=[ServiceTask(name="V", items=[Setting(key="topic", value=LiteralString(value="t"))], errorMappings=[ErrorMapping(trigger="error", code=->E, when="wenn", condition=RawExpr(raw="${x}"))])])', ], ])('%s', async (_title, source, expected) => { await expectProcess(source, expected); @@ -1050,12 +1075,12 @@ describe('Parsing - task kinds, service bindings and fenced scripts', () => { [ 'an expression binding parses; the value is a raw template', `process p { service S(expression: "\${bean.method(execution)}") }`, - '[ServiceTask(name="S", items=[Setting(key="expression", value=RawExpr(raw="\\"${bean.method(execution)}\\""))])]', + '[ServiceTask(name="S", items=[Setting(key="expression", value=RawExpr(raw="${bean.method(execution)}"))])]', ], [ 'a delegate binding parses; the value is a raw template', `process p { service S(delegate: "\${beanName}") }`, - '[ServiceTask(name="S", items=[Setting(key="delegate", value=RawExpr(raw="\\"${beanName}\\""))])]', + '[ServiceTask(name="S", items=[Setting(key="delegate", value=RawExpr(raw="${beanName}"))])]', ], [ 'a topic binding parses with a label', @@ -1096,7 +1121,7 @@ ${FENCE} y = 2 ${FENCE} }`, - '[ServiceTask(name="Auto", items=[Setting(key="class", value=VarRef(ref=->com, accessors=[Accessor(prop="acme"), Accessor(prop="X")]))]), UserTask(name="Review", items=[Setting(key="assignee", value=RawExpr(raw="\\"${bean.pick()}\\""))]), ScriptTask(name="total", body="```js\\ny = 2\\n```")]', + '[ServiceTask(name="Auto", items=[Setting(key="class", value=VarRef(ref=->com, accessors=[Accessor(prop="acme"), Accessor(prop="X")]))]), UserTask(name="Review", items=[Setting(key="assignee", value=RawExpr(raw="${bean.pick()}"))]), ScriptTask(name="total", body="```js\\ny = 2\\n```")]', ], [ 'DSL-looking text inside a fence is captured, not parsed as DSL', @@ -1147,7 +1172,7 @@ describe('Parsing - repeat clause', () => { [ 'the clause and the settings parse together in that order', `process p { user U for each line in "\${order.lines}" sequentially until (done) (label: "Label", assignee: "demo") }`, - '[UserTask(name="U", element="line", collection=RawExpr(raw="\\"${order.lines}\\""), sequential=true, completion=VarRef(ref=->done), items=[Setting(key="label", value=LiteralString(value="Label")), Setting(key="assignee", value=LiteralString(value="demo"))])]', + '[UserTask(name="U", element="line", collection=RawExpr(raw="${order.lines}"), sequential=true, completion=VarRef(ref=->done), items=[Setting(key="label", value=LiteralString(value="Label")), Setting(key="assignee", value=LiteralString(value="demo"))])]', ], [ 'the clause on the statement after a start event is not swallowed', diff --git a/packages/language/test/validating.test.ts b/packages/language/test/validating.test.ts index 0a01366..c10d42b 100644 --- a/packages/language/test/validating.test.ts +++ b/packages/language/test/validating.test.ts @@ -18,6 +18,7 @@ import { createBpmnScriptServices, ENGINE_KEYS, joinSettingKey, + JUEL_RESERVED_WORDS, } from '@bpmn-script/language'; import { BLOCK_HOSTS, @@ -93,6 +94,18 @@ const typeMismatch = ( op: string, ) => `Variable '${name}' of type '${type}' cannot be used in ${context} (operator '${op}').`; +const juelKeyword = (word: string, key: string) => + `'${word}' is a JUEL keyword (Scanner.addKeyToken), so the engine refuses any expression naming it. Reach the variable through a string key instead: "\${${key}}".`; +const hyphenName = (name: string, key: string) => + `'${name}' carries a hyphen, which JUEL scans as a minus (Scanner.nextIdentifier), so the engine reads a subtraction. Reach the variable through a string key instead: "\${${key}}".`; +const nonBooleanCondition = (shape: string) => + `A condition must be boolean, but this one is ${shape}: the engine throws 'condition expression returns non-Boolean' when it evaluates it (UelExpressionCondition.evaluate).`; +const nonBooleanUntil = (shape: string) => + `An 'until' condition must be boolean, but this one is ${shape}: the engine throws when a completion condition evaluates to anything else (MultiInstanceActivityBehavior.completionConditionSatisfied).`; +const COMPOSITE_OPERAND = + "A composite template cannot be spliced into the surrounding expression: JUEL has no '${' token once inside an expression (Scanner.nextEval), so only a raw that is exactly one '${...}' with no '}' outside a string literal in its body can be an operand. Write the whole expression as one raw template instead."; +const DEAD_LOOP = + "This loop can never repeat: every path through the 'do' body ends or redirects the flow, so the condition is never evaluated and the loop gateway would lower to a disconnected node with no incoming flow, which is invalid BPMN. End after the loop, or keep one path through the body."; const ONE_PROCESS_ONLY = 'Only one process is supported per file. ' + 'Move additional processes into separate files.'; @@ -102,16 +115,23 @@ const blockNoFlowSteps = (kind: string, name: string) => `${capitalized(kind)} named '${name}' has no flow steps: ${kind} needs at least one step on its main flow (handlers alone do not start it).`; /** The shapes are derived from the patterns, so this pins the pair. */ const RESERVED_SHAPES = - `'Gateway_..._(split|join|fork|loop|race)', 'Flow_..._...', 'StartEvent_', ` + - `'EndEvent_', 'Throw_', 'EventSubProcess_', 'Boundary_', and 'Catch_'`; + `'Gateway_..._(split|join|fork|loop|race)', 'Flow_..._...', 'Throw_', ` + + `'EventSubProcess_', 'Boundary_', 'Catch_', 'EndEvent_Boundary_', ` + + `'StartEvent_EventSubProcess_', 'EndEvent_EventSubProcess_', '_di', ` + + `'BPMNDiagram_', and 'BPMNPlane_'`; const reservedName = (name: string) => `Statement name '${name}' matches a reserved synthesized-id pattern. ` + - `Prefixes ${RESERVED_SHAPES} are reserved for ids generated by the ` + - 'BPMNscript desugarer.'; + `Prefixes ${RESERVED_SHAPES} are reserved for ids the desugarer or the ` + + 'layouter generates.'; +const mintedTerminal = (name: string, role: string, container: string) => + `Statement name '${name}' is the id the compiler generates for the ` + + `implicit ${role} event of '${container}'; choose another name.`; const duplicateVariable = (name: string, process: string) => `Variable '${name}' is already declared in process '${process}'.`; const duplicateStepName = (name: string, process: string) => `Step name '${name}' is already used by another step in process '${process}'; 'goto ${name}' would be ambiguous.`; +const stepNameEqualsProcess = (name: string) => + `Step name '${name}' equals the process id; the compiled document can hold only one element with that id.`; const catchBindingTypeClash = (name: string, prior: string) => `Catch-binding variable '${name}' is typed 'string', but '${name}' is already declared as '${prior}'; the types must agree.`; const formFieldTypeClash = (id: string, type: string, prior: string) => @@ -126,7 +146,7 @@ const flagNotValidOn = (flag: string, description: string) => `Flag '${flag}' is not valid on ${description}.`; const loopJoinKey = (key: string, description: string, base: string) => `Setting '${key}' is not valid on ${description}: a loop has one gateway, so write '${base}'.`; -const gatewaySettingsOnly = (description: string) => +const settingsOnly = (description: string) => `The parens of ${description} take only settings, written 'key: value'.`; const AWAIT_ASYNC_AFTER = "Setting 'asyncAfter' is not valid on an await block: Operaton refuses it " + @@ -180,6 +200,27 @@ const RUN_JOB_PRIORITY = "Setting 'runJobPriority' does not exist: Operaton reads a job priority off " + "the step alone (BpmnParse.createActivityOnScope), so 'jobPriority' applies " + "to every run's job."; +const noJob = (key: string, pairing: string, description: string) => + `Setting '${key}' on ${description} configures no job: Operaton creates ` + + `one only when ${pairing} is also set ` + + '(BpmnParse.parseAsynchronousContinuation, DefaultFailedJobParseListener.parseActivity).'; +const noPerRunJob = (key: string, pairing: string, description: string) => + `Setting '${key}' on ${description} prices the per-run job Operaton ` + + 'creates in the multi-instance body (BpmnParse.parseActivity), which ' + + `exists only when ${pairing} is also set.`; +const REPEAT_COUNT = + 'A repeat count must be a non-negative whole number, a variable, or a ' + + '"${...}" expression yielding one; the engine reads a constant as text ' + + 'with Integer.parseInt and truncates any other number with intValue() ' + + '(MultiInstanceActivityBehavior.resolveLoopCardinality).'; +const emptyBinding = (key: string, noun: string) => + `Setting '${key}' cannot be empty; name the ${noun}.`; +const literalElBinding = (key: string, effect: string) => + `Setting '${key}' takes a "\${...}" template or a bare name, never quoted text: ${effect}. Write '${key}: "\${...}"' or '${key}: '.`; +const NO_DELEGATE = 'a string resolves to no delegate to run'; +const EVALUATES_ITSELF = 'the string evaluates to itself and runs nothing'; +const templateAsClass = (key: string, alternative: string) => + `Setting '${key}' takes a class name, loaded as written (ClassDelegateUtil.instantiateDelegate); a "\${...}" template there is not evaluated. Write '${key}: com.example.X', or '${alternative}: "\${bean}"' to resolve one at runtime.`; const bindingRequired = (subject: string, keys: string, alternative = '') => `${subject} must declare a ${keys} setting${alternative}.`; const bindingConflict = (subject: string, written: string, keys: string) => @@ -194,6 +235,41 @@ const resultVariableBinding = ( const MAP_DECISION_RESULT = `Setting 'mapDecisionResult' must be 'singleEntry', 'singleResult', ` + `'collectEntries', or 'resultList'.`; +const resultVariableUnread = (binding: string) => + `Setting 'resultVariable' has no effect beside '${binding}': the engine hands it to an 'expression' binding alone (BpmnParse.parseServiceTaskLike), so nothing writes the variable.`; +const MAP_DECISION_RESULT_UNREAD = + "Setting 'mapDecisionResult' has no effect without 'resultVariable': the engine applies the mapping only when storing the result into that variable (DecisionEvaluationUtil.evaluateDecision)."; +const decisionModifierWithoutDecision = (key: string) => + `Setting '${key}' stands only beside 'decision': the engine reads 'binding', 'version' and 'mapDecisionResult' on a step answering a decision table alone (BpmnParse.parseBusinessRuleTask).`; +const VERSION_SHAPE = + `Setting 'version' takes a positive whole number, quoted or not, or a "\${...}" expression yielding one; ` + + 'the engine parses the value as an integer when the step runs (BaseCallableElement.getVersion, TaskEntity.initializeFormRefFromTaskDefinition) and fails the instance on anything else.'; +const dueDateShape = (key: string) => + `Setting '${key}' takes a period starting with 'P' or an ISO date-time such as "2026-01-01T09:00:00", or a "\${...}" expression; ` + + 'the engine parses a constant with DueDateBusinessCalendar.resolveDuedate when the task is created and fails the instance on anything else.'; +const RETRY_CYCLE_SHAPE = + "Setting 'retryCycle' takes an ISO 8601 duration, an 'R/' repeat, or a comma " + + 'list of durations; a single interval the engine cannot read is logged and dropped, so the ' + + 'job keeps its default retries (ParseUtil.parseRetryIntervals), and a bad member of a list ' + + 'is stored unchecked and, when its turn comes, drops that retry to the default strategy ' + + 'with no wait (DefaultJobRetryCmd.execute).'; +const USER_PRIORITY_SHAPE = + `Setting 'priority' takes an integer or a "\${...}" expression; ` + + 'the engine parses a constant with Integer.parseInt when the task is created (TaskDecorator.initializeTaskPriority) and fails the instance on anything else.'; + +// The process header. + +const HISTORY_TIME_TO_LIVE_SHAPE = + "Setting 'historyTimeToLive' takes a quoted number of days, 'PD' or ''; " + + 'the engine reads the attribute as text, never as an expression, and refuses to deploy anything else (ParseUtil.parseHistoryTimeToLive).'; +const VERSION_TAG_LITERAL = + "Setting 'versionTag' takes a quoted string; the engine stores the tag as written, never evaluated (BpmnParse.parseProcess)."; +const VERSION_TAG_LENGTH = + "Setting 'versionTag' is longer than 64 characters, the width of the column it is stored in (ACT_RE_PROCDEF.VERSION_TAG_), so the deployment fails."; +const candidateStarterTemplate = (key: string) => + `Setting '${key}' takes ids as written: the engine stores each entry as a candidate identity link without evaluating it (BpmnDeployer.addAuthorizations), so a "\${...}" template names the id spelled by its text.`; +const headerLiteral = (key: string) => + `Setting '${key}' takes a quoted string on a process header; the engine stores it as written, never evaluated (BpmnParse.parseProcess).`; // Forms. @@ -204,9 +280,6 @@ const oneFormBlock = (description: string) => const duplicateFormField = (id: string) => `Duplicate form field '${id}'.`; const formFieldType = (id: string, type: string) => `Form field '${id}' has type '${type}', which a form cannot use. Use string, number, boolean, date, or enum.`; -const HANDLER_START_FORM = - `The start of an event-handler body has no form; the event's data is bound ` + - `by the handler's own '(...)' bindings, not by a form.`; const FORM_FIELD_SETTINGS = `'required', 'readonly', 'min', 'max', 'minlength', 'maxlength', 'validator', or 'pattern'`; const formFieldSettingsOnly = (id: string, text: string) => `Form field '${id}' takes 'key: value' settings in its parens; '${text}' is not one.`; @@ -228,6 +301,14 @@ const flagNotTrue = (key: string) => const integerBound = (key: string) => `Setting '${key}' takes an integer literal or a quoted integer such as "-5".`; const PATTERN_VALUE = `Setting 'pattern' takes a non-empty quoted date pattern such as "dd/MM/yyyy".`; +const PATTERN_LETTERS = + "Setting 'pattern' may hold only SimpleDateFormat letters " + + '(G, y, Y, M, L, w, W, D, d, F, E, u, a, H, k, K, h, m, s, S, z, Z, X), ' + + 'quoted literal runs, and non-letters; the engine builds a java.text.SimpleDateFormat ' + + 'from it and throws on any other letter (DateFormType).'; +const VALIDATOR_EMPTY = + "Setting 'validator' cannot be empty; name the class or the expression it " + + 'resolves (FormValidators.createValidator).'; const valuesOnNonEnum = (id: string, type: string) => `Value lines belong on an 'enum' field; '${id}' is a ${type} field.`; const emptyEnum = (id: string) => @@ -235,6 +316,17 @@ const emptyEnum = (id: string) => const duplicateValue = (id: string) => `Duplicate value '${id}'.`; const enumDefault = (id: string, value: string, ids: string) => `The default "${value}" of enum field '${id}' names none of its values; write ${ids}.`; +const numberDefault = (id: string, text: string) => + `The default ${text} of number field '${id}' is not an integer; the engine ` + + 'converts it with Long.valueOf every time the form renders (LongFormType.convertValue) ' + + 'and throws on anything else.'; +const booleanDefault = (id: string, text: string) => + `The default ${text} of boolean field '${id}' is not "true" or "false"; the engine ` + + 'reads any other spelling as false (BooleanFormType.convertValue).'; +const isoDateDefault = (id: string, value: string) => + `The default "${value}" of date field '${id}' is an ISO date, but the engine's default ` + + 'pattern is "dd/MM/yyyy" (ProcessEngineConfigurationImpl.initFormTypes); add a \'pattern\' ' + + 'setting, or write the date to fit it.'; const formFieldDirection = (id: string, direction: string, isEnum = false) => `Unknown member direction '${direction}' in form field '${id}': its block takes 'property = ""' lines${isEnum ? ' and value lines' : ''}.`; const propertyValue = (name: string) => @@ -245,8 +337,8 @@ const propertyValue = (name: string) => const PARAMETER_HOSTS_SENTENCE = 'parameters belong on a user task, a service task, a script task, a step, ' + - 'a send task, a receive task, a decision step, a subprocess, a call, an ' + - "attempt block, and an 'on' handler with no host."; + 'a send task, a receive task, a decision step, a subprocess, a call, and ' + + 'an attempt block.'; const noParameters = (description: string) => `${capitalized(description)} cannot declare an 'input' or 'output' parameter; ${PARAMETER_HOSTS_SENTENCE}`; const unknownDirection = (word: string, legal = `'input' or 'output'`) => @@ -277,19 +369,28 @@ const scriptListenerField = (subject: string) => const fieldValue = (name: string) => `Field '${name}' takes a quoted string or a "\${...}" expression; ` + 'put the value in quotes.'; +const escapedFieldLiteral = (name: string) => + `Field '${name}' cannot carry quoted text opening with '\${' or '#{': the expression slot is picked by that opening, so the text would be evaluated rather than injected as written. Drop the backslash to write an expression.`; +const emptyField = (name: string) => + `Field '${name}' cannot be empty: the engine reads an empty value as absent, so the ` + + 'field declares neither a fixed value nor an expression and the deployment fails ' + + `(BpmnParse.parseFieldDeclaration). Write '\${""}' for an actual empty string.`; const REPEATED_OUTPUT = "A repeated step cannot map an 'output' parameter: the engine refuses to " + - 'deploy it. Move the mapping to a step after the repetition.'; + 'deploy it (BpmnParse.checkActivityOutputParameterSupported). Move the ' + + 'mapping to a step after the repetition.'; const EMPTY_MAP_KEY = "A map entry's key cannot be empty; name the key its value is looked up by."; +const EMPTY_STRING_VALUE = + 'An empty or blank string writes no value at all: the engine trims it and ' + + 'reads it as absent rather than as an empty string (BpmnParseUtil.getElValueProvider). ' + + 'Write \'${""}\' for an actual empty string.'; const taskListenerOnly = (event: string, description: string) => `'on ${event}' is a task listener, which only a user task has; ` + `${description} takes 'start' or 'end'.`; const unknownListenerEvent = (event: string, events: string) => `Unknown listener event '${event}'; write ${events}.`; -const USER_LISTENER_EVENTS = `'start', 'end', 'create', 'assign', 'complete', 'update', 'delete', or 'timeout'`; -const duplicateListener = (event: string) => - `Duplicate 'on ${event}' listener.`; +const USER_LISTENER_EVENTS = `'start', 'end', 'create', 'assignment', 'complete', 'update', 'delete', or 'timeout'`; const LISTENER_PARTICLE_ONLY = "Only 'on timeout' takes a particle."; // The extras of a step handed to an external worker. @@ -319,17 +420,51 @@ const codeNotDeclared = (trigger: string, name: string) => const unsupportedScriptTag = (subject: string, tag: string) => `${subject} has an unsupported language tag '${tag}'. ` + - "Use 'javascript'/'js', 'groovy', 'python'/'py', 'ruby'/'rb', or 'feel'."; + "Use 'juel', 'js', 'javascript', 'ecmascript', 'groovy', 'py', 'python', " + + "'rb', 'ruby', or 'feel'."; const emptyScript = (subject: string) => `${subject} has an empty script body.`; const unterminatedScript = (name: string) => `Script task '${name}' has a malformed or unterminated fenced script body; ` + 'a script must be a closed ``` ... ``` block.'; +/** + * `juel` (the engine's own default), a mixed-case alias, and `ecmascript` + * resolve the same as their canonical spelling; every other tag is refused, + * `ScriptingEngines.getScriptEngineForLanguage` lowercasing the language + * before it looks an engine up. + */ +const SCRIPT_TAG_MATRIX: ReadonlyArray< + readonly [tag: string, refused: boolean] +> = [ + ['juel', false], + ['JavaScript', false], + ['ecmascript', false], + ['cobol', true], +]; + +function scriptTagCases( + buildSource: (tag: string) => string, + subject: string, +): Case[] { + return SCRIPT_TAG_MATRIX.map(([tag, refused]) => [ + `${subject} ${refused ? 'refuses' : 'accepts'} the '${tag}' tag`, + buildSource(tag), + refused ? [unsupportedScriptTag(subject, tag)] : [], + ]); +} + // Control flow. const emptyBranch = (what: string) => `The ${what} has no steps.`; const emptyNumberedBranch = (index: number, keyword: string) => `Branch ${index} of the '${keyword}' statement has no steps.`; +const emptyLoopBody = (keyword: string) => + `The '${keyword}' body has no steps, so the loop and its condition would be dropped: with nothing to loop over, the gateway keeps only its exit and the condition is never written. Put a step in the body, or remove the loop.`; +const blockMember = (what: string, verb: string) => + `${what} written inside a body belongs to nothing and is dropped: put it in the attribute block of the element it ${verb}, the braces before that element's body.`; +const blockParameter = (direction: string) => + blockMember(`An '${direction}' parameter`, 'configures'); +const BLOCK_LISTENER = blockMember('A listener', 'observes'); const PARALLEL_SECOND_ELSE = "A 'parallel' statement takes one 'else' branch at most; the first one " + 'already runs when no condition held. Fold this branch into it or give it a ' + @@ -360,7 +495,6 @@ const reservedWord = (word: string) => const CALL_PROCESS_REQUIRED = 'A call must name the process it starts: add process: "".'; -const CALL_PROCESS_EMPTY = `A call's 'process' setting cannot be empty; name the process to start.`; const BINDING_VALUE = `Setting 'binding' must be 'latest' or 'deployment'.`; const BINDING_IS_VERSION = `Write 'version: ' instead of 'binding: version'.`; const bindingVersionClash = (subject: string) => @@ -402,6 +536,42 @@ const INITIATOR_SHADOWED = 'The engine keeps one initiator per process: whichever start is parsed ' + 'last wins, so this setting is never written. Move it to the last ' + 'start, or drop it.'; +const SECOND_DEFAULT_START = + 'A process takes one plain or timer start: Operaton refuses a second one ' + + '(BpmnParse.selectInitial), so the deployment fails. Keep one, or give ' + + 'this start a message, signal, or condition trigger.'; +const START_AFTER_IMPLICIT_START = + 'A body that does not open with a start gets a plain start of its own, ' + + "so this is the process's second plain or timer start, which Operaton " + + 'refuses (BpmnParse.selectInitial), so the deployment fails. Open the ' + + 'body with this start, or give it a message, signal, or condition trigger.'; +const duplicateNamedStart = (trigger: string, name: string) => + `Another start already subscribes to ${trigger} '${name}': Operaton keeps one ${trigger} start subscription per name and process (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails.`; +const duplicateConditionStart = (text: string) => + `Another start already carries the condition '${text}': Operaton keeps one conditional start per condition text and process (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails.`; +const NESTED_START_FORM = + 'A start inside a subprocess, attempt block, or handler body takes no ' + + "form: the engine reads a start form off the process's own start alone " + + '(BpmnParse.parseStartFormHandlers) and BpmnParse.parseScopeStartEvent ' + + 'reads none, so this form is never shown.'; +const NESTED_START_INITIATOR = + 'A start inside a subprocess, attempt block, or handler body takes no ' + + "'initiator': the engine reads it off the process's own start alone " + + '(BpmnParse.parseProcessDefinitionStartEvent) and ' + + 'BpmnParse.parseScopeStartEvent reads none, so nothing is written.'; +/** The three scopes a nested start opens, each wrapping one body. */ +const NESTED_START_CONTAINERS: ReadonlyArray< + [kind: string, wrap: (body: string) => string] +> = [ + ['a subprocess', (body) => `process p { subprocess S { ${body} } }`], + ['an attempt block', (body) => `process p { attempt S { ${body} } }`], + [ + 'a handler body', + (body) => `process p { error X user T on error(X) { ${body} } }`, + ], +]; +const timerJobKeyTwice = (key: string) => + `Setting '${key}' is already written on the 'on timer' head, and both land on this start event, the element the engine reads the timer job's settings from (BpmnParse.parseTimer, DefaultFailedJobParseListener.parseStartEvent); keep one.`; const hostedHandlerStart = (name: string) => `'start ${name}' cannot open a handler that names a host: the body runs ` + "inside the host's own container and is entered from the boundary event, " + @@ -450,8 +620,23 @@ const LISTENER_TIMER_PAYLOAD = `'at "2026-08-01T09:00:00"', or 'every "R/PT10M"'.`; const unknownParticle = (word: string) => `Unknown timer particle '${word}'; write 'after', 'at', or 'every'.`; -const AFTER_SHAPE = "'after' expects a duration such as PT1H."; -const AT_SHAPE = "'at' expects a point in time such as 2026-08-01T09:00:00."; +const timerShape = (particle: string, expected: string, calendar: string) => + `'${particle}' takes ${expected}; the engine reads it as text and fails when it does not fit (${calendar}).`; +const AFTER_SHAPE = timerShape( + 'after', + 'an ISO 8601 duration such as "PT1H", on its own or beside a start or end date-time ("2026-01-01T00:00:00/PT1H", "PT1H/2026-12-31T00:00:00")', + 'DurationBusinessCalendar.resolveDuedate', +); +const AT_SHAPE = timerShape( + 'at', + 'an ISO date-time such as "2026-08-01T09:00:00", or a duration counted from now', + 'DueDateBusinessCalendar.resolveDuedate', +); +const EVERY_SHAPE = timerShape( + 'every', + 'an ISO 8601 repeat such as "R/PT10M", or a six-field cron expression or one of its nicknames such as "@daily"', + 'CycleBusinessCalendar.resolveDuedate', +); const REPEATING_INTERRUPTS = 'A repeating timer that interrupts its scope fires at most once: ' + "add 'alongside' to let it repeat, or give it a duration instead."; @@ -522,15 +707,29 @@ const noBindings = (trigger: string) => `'(code: c)' bindings belong to error and escalation handlers; a ${trigger} carries no code.`; const ESCALATION_HAS_NO_MESSAGE = 'An escalation carries a code but no message.'; -const handlerDuplicate = ( - trigger: string, - caught: string, - host: string | undefined = undefined, -) => - `Another 'on ${trigger}' handler ${host ? `attached to '${host}'` : 'in this scope'} already catches ${caught}; a duplicate catch is ambiguous to the engine.`; -/** What a duplicate handler is said to catch: a code, or every event. */ +/** Why a second catch of one thing on one scope is refused, per trigger. */ +const HANDLER_DUPLICATE_RULE: Readonly> = { + message: + 'Operaton keeps one message subscription per name and scope (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails', + signal: + 'Operaton keeps one signal subscription per name and scope (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails', + escalation: + 'Operaton keeps one escalation catch per code and scope (BpmnParse.addEscalationEventDefinition), so the deployment fails', + cancel: + 'Operaton keeps one cancel handler per attempt block (BpmnParse.parseBoundaryCancelEventDefinition), so the deployment fails', + error: + 'the engine refuses nothing here and takes the first match after sorting its handlers (BpmnParse.addErrorEventDefinition), so this surface keeps one handler per code', +}; +const handlerDuplicate = (trigger: string, caught: string, scope: string) => + `Another 'on ${trigger}' handler already catches ${caught} on scope '${scope}': ${HANDLER_DUPLICATE_RULE[trigger]}.`; +/** What a duplicate handler is said to catch: a code, a name, or every event. */ const everyEvent = 'every event of this kind'; const code = (value: string) => `code '${value}'`; +const eventName = (value: string) => `name '${value}'`; +const escalationCatchAllBesideCoded = (scope: string) => + `An 'on escalation' handler with no code cannot sit beside one with a code on scope '${scope}': the code-less one would catch every escalation, and Operaton refuses the pair (BpmnParse.addEscalationEventDefinition). Give both a code, or keep one.`; +const raceDuplicate = (trigger: string, value: string) => + `Another branch of this 'await' already catches ${trigger} '${value}': every branch subscribes on the gateway's own scope (BpmnParse.parseIntermediateCatchEvent), which keeps one ${trigger} subscription per name (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails.`; // Boundary hosts. @@ -714,16 +913,6 @@ checks('Validation - variables in expressions', [ `process p { if (amount > 1000) { user A } end Done }`, [warn(undeclared('amount'))], ], - [ - 'a bareword assignee reads as the variable holding the user', - `process p { user T(assignee: someUndeclared) }`, - [warn(undeclared('someUndeclared'))], - ], - [ - 'a declared assignee variable is clean', - `process p { var reviewer: string user T(assignee: reviewer) }`, - [], - ], [ 'a dotted formKey names a form, not a variable', `process p { user T(formKey: forms.review) }`, @@ -784,6 +973,11 @@ checks('Validation - variables in expressions', [ ], ]); +/** + * Revert check for the `assignee` rows: dropping the key from + * `NON_VARIABLE_ATTR_KEYS` brings the undeclared warning back on `demo` and + * the hyphen error beside it on `john-doe`. + */ checks('Validation - barewords in engine-side attribute values', [ [ 'a bareword candidateGroups reaches the engine as written', @@ -795,9 +989,19 @@ checks('Validation - barewords in engine-side attribute values', [ `process p { user U(candidateUsers: approvers) }`, [], ], + [ + 'a bareword assignee is the user id, as the compiler and the engine read it', + `process p { user U(assignee: demo, candidateUsers: demo) }`, + [], + ], + [ + 'a hyphenated assignee is a user id too, never scanned as a subtraction', + `process p { user U(assignee: john-doe) }`, + [], + ], [ 'a bareword resultVariable names the variable to fill, not one to read', - `process p { service V(topic: "t", resultVariable: approvers) }`, + `process p { service V(expression: "\${bean.run()}", resultVariable: approvers) }`, [], ], [ @@ -812,7 +1016,7 @@ checks('Validation - barewords in engine-side attribute values', [ ], [ 'a bareword retryCycle asks for quotes the same way', - `process p { user U(retryCycle: approvers) }`, + `process p { user U(asyncBefore: true, retryCycle: approvers) }`, [unquotedText('retryCycle')], ], [ @@ -822,7 +1026,7 @@ checks('Validation - barewords in engine-side attribute values', [ ], [ 'a bareword jobPriority warns the same way', - `process p { user U(jobPriority: deadline) }`, + `process p { user U(asyncBefore: true, jobPriority: deadline) }`, [warn(undeclared('deadline'))], ], [ @@ -832,6 +1036,362 @@ checks('Validation - barewords in engine-side attribute values', [ ], ]); +/** + * Revert checks: dropping the word set from the spelling check turns the + * keyword rows red, dropping the hyphen test the `my-flag` row. + */ +checks('Validation - a JUEL keyword or a hyphen in a rendered name', [ + ...JUEL_RESERVED_WORDS.flatMap((word): Case[] => [ + [ + `'${word}' as a variable is an operator to the engine, at the declaration and at the use`, + `process p { var ${word}: boolean if (${word}) { user A } }`, + [ + juelKeyword(word, `execution.getVariable('${word}')`), + juelKeyword(word, `execution.getVariable('${word}')`), + ], + ], + [ + `'${word}' as a property is an operator to the engine`, + `process p { var order: json if (order.${word}) { user A } }`, + [juelKeyword(word, `order['${word}']`)], + ], + [ + `'${word}' as a property inside a raw template is the same operator`, + `process p { if ("\${order.${word}}") { user A } }`, + [juelKeyword(word, `order['${word}']`)], + ], + ]), + [ + 'a keyword read off a bracketed object in a raw template is keyed on that object', + `process p { if ("\${items[0].and}") { user A } }`, + [juelKeyword('and', "items[0]['and']")], + ], + [ + 'a keyword inside a JUEL string literal or between two templates is text, not a read', + `process p { user A(assignee: "\${map['x.and'] == 'a.or'} or.and \${b}") }`, + [], + ], + [ + 'a keyword read in a listener timeout template is the same operator', + `process p { user A { on timeout after "\${order.and}"(class: "c.X") } }`, + [juelKeyword('and', "order['and']")], + ], + [ + 'a hyphenated variable scans as a subtraction', + `process p { var my-flag: boolean if (my-flag) { user A } }`, + [hyphenName('my-flag', "execution.getVariable('my-flag')")], + ], + [ + 'a hyphenated property scans the same way, keyed on its object', + `process p { var order: json if (order.line.is-paid) { user A } }`, + [hyphenName('is-paid', "order.line['is-paid']")], + ], + [ + 'an undeclared hyphenated variable keeps its warning beside the error', + `process p { if (my-flag) { user A } }`, + [ + hyphenName('my-flag', "execution.getVariable('my-flag')"), + warn(undeclared('my-flag')), + ], + ], + [ + 'a bare collection name is looked up as a variable, not evaluated', + `process p { var check-close: json user U for each x in check-close }`, + [], + ], + [ + 'a bare in-mapping source is copied by name, not evaluated', + `process p { var check-close: json call C(process: "q") { in y = check-close } }`, + [], + ], + [ + 'a collection under an accessor is evaluated, so its spelling counts', + `process p { var order: json user U for each x in order.line-items }`, + [hyphenName('line-items', "order['line-items']")], + ], + [ + 'a class name and a topic reach the engine as written', + `process p { service S(class: com.example.mod) service T(topic: order-events) }`, + [], + ], +]); + +/** Each position wrapping the same number variable, so the rule is pinned per position. */ +const CONDITION_POSITIONS: Array<[title: string, at: (c: string) => string]> = [ + ['if', (c) => `process p { var n: number if (${c}) { user A } }`], + [ + 'else if', + (c) => + `process p { var n: number var f: boolean if (f) { user A } else if (${c}) { user B } }`, + ], + ['while', (c) => `process p { var n: number while (${c}) { user A } }`], + [ + 'do ... while', + (c) => `process p { var n: number do { user A } while (${c}) }`, + ], + [ + 'a parallel branch head', + (c) => + `process p { var n: number parallel { if (${c}) { user A } { user B } } }`, + ], + [ + 'a condition handler', + (c) => `process p { var n: number user A on condition(${c}) { user B } }`, + ], + [ + 'a condition start', + (c) => `process p { var n: number start S condition(${c}) user A }`, + ], + [ + 'an awaited condition', + (c) => `process p { var n: number await condition(${c}) user A }`, + ], + [ + 'a race branch', + (c) => + `process p { var n: number await { condition(${c}) { user A } timer("PT1H") { user B } } }`, + ], +]; + +/** + * Revert checks: dropping the `Additive` arm turns the `n + 1` row red, + * dropping the position list the per-position rows. + */ +checks('Validation - a condition must be boolean', [ + ...CONDITION_POSITIONS.map(([title, at]): Case => [ + `${title} refuses a number variable`, + at('n'), + [nonBooleanCondition("a variable of type 'number'")], + ]), + [ + "an 'until' clause cites the multi-instance behaviour", + `process p { user U for 3 until (nrOfCompletedInstances) }`, + [nonBooleanUntil("a variable of type 'number'")], + ], + [ + 'a string literal', + `process p { if ("yes") { user A } }`, + [nonBooleanCondition('a string')], + ], + [ + 'a json variable is a Spin node, never a Boolean', + `process p { var order: json if (order) { user A } }`, + [nonBooleanCondition("a variable of type 'json'")], + ], + [ + 'a null literal cites the null check', + `process p { if (null) { user A } }`, + [ + "A condition must be boolean, but this one is null: the engine throws 'condition expression returns null' when it evaluates it (UelExpressionCondition.evaluate).", + ], + ], + [ + 'a number literal', + `process p { if (1) { user A } }`, + [nonBooleanCondition('a number')], + ], + [ + 'an arithmetic expression', + `process p { var n: number if (n + 1) { user A } }`, + [nonBooleanCondition('an arithmetic expression')], + ], + [ + 'a negation', + `process p { var n: number if (-n) { user A } }`, + [nonBooleanCondition('an arithmetic expression')], + ], + [ + 'a ternary with no boolean arm', + `process p { var n: number if (n > 1 ? "a" : 2) { user A } }`, + [nonBooleanCondition('a ternary with no boolean arm')], + ], + [ + 'text around a template evaluates to a string', + `process p { if ("\${a} and \${b}") { user A } }`, + [ + nonBooleanCondition( + 'text around a template, which evaluates to a string', + ), + ], + ], + [ + 'parentheses do not change the shape', + `process p { if (("yes")) { user A } }`, + [nonBooleanCondition('a string')], + ], + [ + 'a boolean variable, a comparison and a template body are clean', + `process p { var flag: boolean if (flag) { user A } if (flag && "\${a.b}") { user B } user U for 3 until (nrOfCompletedInstances > 1) }`, + [], + ], + [ + 'an undeclared variable keeps its warning and nothing more', + `process p { if (x) { user A } }`, + [warn(undeclared('x'))], + ], + [ + 'a property and a ternary with one boolean arm have no known type', + `process p { var order: json var f: boolean if (order.paid) { user A } if (f ? f : 1) { user B } }`, + [], + ], +]); + +/** Revert check: dropping `singleTemplateBody` from the nested check turns the negated row red. */ +checks('Validation - a composite raw template cannot be an operand', [ + [ + 'negated', + `process p { if (!"\${a} and \${b}") { user A } }`, + [COMPOSITE_OPERAND], + ], + [ + 'compared', + `process p { if ("\${a} b" == "x") { user A } }`, + [COMPOSITE_OPERAND], + ], + [ + 'as an index', + `process p { var m: json if (m["\${a} \${b}"]) { user A } }`, + [COMPOSITE_OPERAND], + ], + [ + 'in a ternary arm', + `process p { var f: boolean if (f ? "\${a} \${b}" : true) { user A } }`, + [COMPOSITE_OPERAND], + ], + [ + 'one template under an operator splices in', + `process p { if (!"\${a.b}") { user A } }`, + [], + ], + [ + 'a brace or opener inside a JUEL string literal is string text (Scanner.nextString), so "${map[\'}\']}" and "${fn(\'${\')}" are one template', + `process p { if (!"\${map['}']}" && !"\${fn('\${')}") { user A } }`, + [], + ], + [ + 'a composite at the top of its position is carried as written', + `process p { service S(class: "com.acme.D") { field subject = "\${a} and \${b}" } }`, + [], + ], +]); + +/** + * Revert checks: dropping the `LiteralString` arm turns the quoted-delegate + * row red, dropping the blank test the empty rows. + */ +checks('Validation - binding values', [ + [ + 'a quoted delegate on a service task never resolves', + `process p { service S(delegate: "bean") }`, + [literalElBinding('delegate', NO_DELEGATE)], + ], + [ + 'a quoted expression on a listener evaluates to its own text', + `process p { user U { on start(expression: "bean.run()") } }`, + [literalElBinding('expression', EVALUATES_ITSELF)], + ], + [ + 'a quoted mapperDelegate on a call never resolves', + `process p { call C(process: "q", mapperDelegate: "bean") }`, + [literalElBinding('mapperDelegate', NO_DELEGATE)], + ], + [ + 'a quoted expression on a thrown message evaluates to its own text', + `process p { start S throw message("Ack", expression: "b") }`, + [literalElBinding('expression', EVALUATES_ITSELF)], + ], + [ + 'a template as a class is loaded as a class name', + `process p { service S(class: "\${cls}") }`, + [templateAsClass('class', 'delegate')], + ], + [ + 'a template as a listener class is loaded the same way', + `process p { user U { on start(class: "\${cls}") } }`, + [templateAsClass('class', 'delegate')], + ], + [ + 'a template as a mapper is loaded the same way', + `process p { call C(process: "q", mapper: "\${cls}") }`, + [templateAsClass('mapper', 'mapperDelegate')], + ], + ...( + [ + ['class', 'class to load'], + ['expression', 'expression to evaluate'], + ['delegate', 'delegate to resolve'], + ['topic', 'topic a worker subscribes to'], + ] as const + ).map(([key, noun]): Case => [ + `an empty ${key} on a service task`, + `process p { service S(${key}: "") }`, + [emptyBinding(key, noun)], + ]), + ...( + [ + ['class', 'class to load'], + ['expression', 'expression to evaluate'], + ['delegate', 'delegate to resolve'], + ] as const + ).map(([key, noun]): Case => [ + `a blank ${key} on a listener`, + `process p { user U { on start(${key}: " ") } }`, + [emptyBinding(key, noun)], + ]), + [ + 'an empty decision on a decide step', + `process p { decide D(decision: "") }`, + [emptyBinding('decision', 'decision table to evaluate')], + ], + [ + 'an empty mapper and mapperDelegate on a call', + `process p { call C(process: "q", mapper: "") call D(process: "q", mapperDelegate: "") }`, + [ + emptyBinding('mapper', 'mapping class to load'), + emptyBinding('mapperDelegate', 'mapping delegate to resolve'), + ], + ], + [ + 'a bare or dotted name and a template are the shapes the keys take', + `process p { service S(delegate: bean) service T(expression: bean.method) service U(class: "com.acme.D") call C(process: "q", mapper: com.acme.M) }`, + [], + ], +]); + +/** Revert check: dropping the `EXPRESSION_OPEN` test turns the escaped row red. */ +checks('Validation - an escaped literal as a field value', [ + [ + 'quoted text opening with an escaped template has no field slot', + `process p { service S(class: "com.acme.D") { field x = "\\\${y" } }`, + [escapedFieldLiteral('x')], + ], + [ + 'the hash opener and leading whitespace are the same slot', + `process p { service S(class: "com.acme.D") { field x = "\\#{y}" field z = " \\\${y}" } }`, + [escapedFieldLiteral('x'), escapedFieldLiteral('z')], + ], + [ + 'on a listener as well', + `process p { user U { on start(class: "com.acme.L") { field x = "\\\${y" } } }`, + [escapedFieldLiteral('x')], + ], + [ + 'an opener further in is plain text', + `process p { service S(class: "com.acme.D") { field x = "cost: \\\${y}" } }`, + [], + ], + [ + 'an empty literal has neither a fixed value nor an expression', + `process p { service S(class: "com.acme.D") { field x = "" } }`, + [emptyField('x')], + ], + [ + 'an empty literal on a listener as well', + `process p { user U { on start(class: "com.acme.L") { field x = "" } } }`, + [emptyField('x')], + ], +]); + checks('Validation - attribute keys and value shapes', [ [ 'a repeated key in one block is one error naming it', @@ -846,7 +1406,33 @@ checks('Validation - attribute keys and value shapes', [ [ 'the refusal counts one per extra value, whatever the element', `process p { start S user T("a", "b", "c") }`, - [SECOND_PAREN_VALUE, SECOND_PAREN_VALUE], + [settingsOnly('a user task'), SECOND_PAREN_VALUE, SECOND_PAREN_VALUE], + ], + ...( + [ + [ + 'a start with no trigger', + 'start S("PT30M") user T', + 'a start event with no trigger', + ], + [ + 'an end with no trigger', + 'user T end E(false)', + 'an end event with no trigger', + ], + ['a user task', 'user T("a")', 'a user task'], + ['a subprocess', 'subprocess S("a") { user T }', 'a subprocess'], + ['a listener', 'user T { on start("a", class: "c.X") }', 'a listener'], + ] as const + ).map(([title, body, description]): Case => [ + `an unkeyed value on ${title} is nothing the lowering reads, so it is refused`, + `process p { ${body} }`, + [settingsOnly(description)], + ]), + [ + 'an unkeyed value on the process header is refused the same way', + `process p("a") { user T }`, + [settingsOnly('a process header')], ], [ 'two different keys are clean', @@ -933,7 +1519,7 @@ checks('Validation - attribute keys and value shapes', [ ], [ 'a quoted boolean is one error naming the unquoted form', - `process p { user U(exclusive: "true") }`, + `process p { user U(asyncBefore: true, exclusive: "true") }`, [quotedBoolean('exclusive')], ], [ @@ -953,31 +1539,14 @@ checks('Validation - attribute keys and value shapes', [ ], [ 'every engine-side text attribute takes a quoted string or an expression', - `process p(versionTag: "1.0.0") { - user U(retryCycle: "R3/PT10M", dueDate: "\${due}", followUpDate: "\${due}") -}`, + `process p { user U(asyncBefore: true, retryCycle: "R3/PT10M", dueDate: "\${due}", followUpDate: "\${due}") }`, [], ], - [ - 'a number in versionTag asks for quotes, and reads as no variable', - `process p(versionTag: 3) { start S }`, - [unquotedText('versionTag')], - ], [ 'a number in retryCycle asks for quotes', - `process p { user U(retryCycle: 3) }`, + `process p { user U(asyncBefore: true, retryCycle: 3) }`, [unquotedText('retryCycle')], ], - [ - 'a bareword in versionTag asks for quotes and still reads as a variable', - `process p(versionTag: deadline) { start S }`, - [warn(undeclared('deadline')), unquotedText('versionTag')], - ], - [ - 'a bareword in historyTimeToLive asks for quotes and names no variable', - `process p(historyTimeToLive: P90D) { start S }`, - [unquotedText('historyTimeToLive')], - ], [ 'a bareword starter list names a principal, not a variable', `process p(candidateStarterUsers: demo, candidateStarterGroups: adjusters) { start S }`, @@ -990,7 +1559,7 @@ checks('Validation - attribute keys and value shapes', [ ], [ 'an expression in a numeric attribute carries no value-shape rule', - `process p { user U(jobPriority: "\${weight}") }`, + `process p { user U(asyncBefore: true, jobPriority: "\${weight}") }`, [], ], [ @@ -1125,6 +1694,119 @@ checks('Validation - service, send, and decision bindings', [ ], ]); +/** + * Settings the engine deploys and then reads at the step, or never. Revert + * checks: the `topic`/`type` arm of the result-variable check removed turns + * the two warned rows clean, the mapping warning removed the `singleEntry` + * row, the decision-modifier check removed the three refused rows. + */ +checks('Validation - settings the engine reads under one binding alone', [ + [ + 'resultVariable beside a topic warns that nothing writes it', + `process p { service V(topic: "t", resultVariable: "r") }`, + [warn(resultVariableUnread('topic'))], + ], + [ + 'resultVariable beside a topic on a decision step warns the same way', + `process p { decide D(topic: "t", resultVariable: "r") }`, + [warn(resultVariableUnread('topic'))], + ], + [ + 'mapDecisionResult with no resultVariable is built and never applied', + `process p { decide D(decision: "riskRating", mapDecisionResult: singleEntry) }`, + [warn(MAP_DECISION_RESULT_UNREAD)], + ], + [ + 'mapDecisionResult with resultVariable is applied when the result is stored', + `process p { decide D(decision: "riskRating", mapDecisionResult: singleEntry, resultVariable: "r") }`, + [], + ], + ...[ + ['binding', 'binding: latest'], + ['version', 'version: 2'], + ['mapDecisionResult', 'mapDecisionResult: singleEntry'], + ].map(([key, setting]): Case => [ + `${key} on a decision step bound by code is read by nothing, so it is refused`, + `process p { decide D(class: "com.example.Rate", ${setting}) }`, + [decisionModifierWithoutDecision(key!)], + ]), + [ + 'two modifiers beside a topic are each refused once', + `process p { decide D(topic: "t", binding: latest, mapDecisionResult: singleEntry) }`, + [ + decisionModifierWithoutDecision('binding'), + decisionModifierWithoutDecision('mapDecisionResult'), + ], + ], +]); + +/** One user task carrying the setting alone, so each row is one shape and its whole list. */ +const userWith = (setting: string) => `process p { user U(${setting}) }`; + +/** The three elements pinning a deployed version, each wrapping one `version` value. */ +const VERSION_CARRIERS: Array<[carrier: string, at: (v: string) => string]> = [ + ['a call', (v) => `process p { call C(process: "q", version: ${v}) }`], + [ + 'a decision step', + (v) => `process p { decide D(decision: "riskRating", version: ${v}) }`, + ], + [ + 'a form reference', + (v) => userWith(`formRef: "review-form", version: ${v}`), + ], +]; + +/** + * Values the engine deploys as written and parses when the token reaches the + * step. Revert checks: `priority` dropped from `PRIORITY_ATTR_KEYS` turns its + * refused rows clean, the `version` rule removed its refused rows on all + * three carriers, the due-date text check removed the `"tomorrow"` row. + */ +checks('Validation - values the engine parses when the step runs', [ + ...['7', '"7"', '"${p}"', 'weight'].map((value): Case => [ + `priority ${value} parses or evaluates to an integer`, + `process p { var weight: number user U(priority: ${value}) }`, + [], + ]), + ...['1.5', '"high"'].map((value): Case => [ + `priority ${value} fails the task creation, so it is refused`, + userWith(`priority: ${value}`), + [USER_PRIORITY_SHAPE], + ]), + ...VERSION_CARRIERS.flatMap(([carrier, at]): Case[] => [ + ...['2', '"2"', '"${v}"'].map((value): Case => [ + `version ${value} on ${carrier} converts to an integer`, + at(value), + [], + ]), + ...['1.5', '"abc"', '-1', '0', 'v'].map((value): Case => [ + `version ${value} on ${carrier} fails at the step, so it is refused`, + at(value), + [VERSION_SHAPE], + ]), + ]), + ...['"P2D"', '"2026-01-01T00:00:00"', '"${d}"'].map((value): Case => [ + `dueDate ${value} is a shape the business calendar resolves`, + userWith(`dueDate: ${value}`), + [], + ]), + [ + 'a due date the calendar cannot resolve is refused on both keys', + userWith('dueDate: "tomorrow", followUpDate: "next week"'), + [dueDateShape('dueDate'), dueDateShape('followUpDate')], + ], + ...['"PT10M"', '"R3/PT10M"', '"PT5M,PT10M"', '"${r}"'].map((value): Case => [ + `retryCycle ${value} is a shape the retry parser resolves`, + userWith(`asyncBefore: true, retryCycle: ${value}`), + [], + ]), + [ + 'a retryCycle the parser cannot resolve is dropped, so it only warns', + userWith('asyncBefore: true, retryCycle: "bogus"'), + [warn(RETRY_CYCLE_SHAPE)], + ], +]); + /** * The three checks `BpmnParse.parseServiceTaskLike` runs before it builds a * mail or shell behaviour, each mirrored so a clean script deploys. Revert @@ -1287,9 +1969,9 @@ checks('Validation - the mail and shell bindings', [ [notValidOn('type', 'a user task')], ], [ - 'a result variable beside a type is accepted and ignored, as beside a topic', + 'a result variable beside a type warns that nothing writes it, as beside a topic', `process p { service R(type: "shell", resultVariable: "r") { field command = "ls" } }`, - [], + [warn(resultVariableUnread('type'))], ], ]); @@ -1322,6 +2004,10 @@ checks('Validation - script tasks and fenced scripts', [ `process p { script total ${FENCE}js\nx = 1\n${FENCE} }`, [], ], + ...scriptTagCases( + (tag) => `process p { script total ${FENCE}${tag}\nx = 1\n${FENCE} }`, + `Script task 'total'`, + ), ]); checks('Validation - goto', [ @@ -1430,14 +2116,29 @@ checks('Validation - empty branches and bodies', [ [warn(emptyBranch(`'else' branch`))], ], [ - "an empty 'while' body is one warning", + "an empty 'while' body would lose the loop's condition", `process p { var flag: boolean while (flag == true) { } }`, - [warn(emptyBranch(`'while' body`))], + [emptyLoopBody('while')], ], [ - "an empty 'do ... while' body is one warning", + "an empty 'do ... while' body would lose the loop's condition", `process p { var flag: boolean do { } while (flag == true) }`, - [warn(emptyBranch(`'do' body`))], + [emptyLoopBody('do')], + ], + [ + 'a parameter written in a handler body configures nothing', + `process p { user T on message("Late") { input who = "x" end LateEnd } }`, + [blockParameter('input')], + ], + [ + 'a listener written first in a subprocess body observes nothing', + `process p { subprocess S { on start(class: "x.L") user A } }`, + [BLOCK_LISTENER], + ], + [ + 'a parameter written in a loop body configures nothing', + `process p { var c: boolean while (c) { user A output y = 1 } }`, + [blockParameter('output')], ], [ 'an empty parallel branch is one warning naming its position', @@ -1494,8 +2195,6 @@ checks('Validation - reserved synthesized-id names', [ 'every reserved prefix, and the two-segment Flow_ shape, is rejected', `process p { start Flow_A_B - user StartEvent_p - user EndEvent_p user Boundary_X_error user Throw_p_1 user EventSubProcess_x @@ -1503,25 +2202,85 @@ checks('Validation - reserved synthesized-id names', [ }`, [ reservedName('Flow_A_B'), - reservedName('StartEvent_p'), - reservedName('EndEvent_p'), reservedName('Boundary_X_error'), reservedName('Throw_p_1'), reservedName('EventSubProcess_x'), reservedName('Catch_p_1'), ], ], + [ + // The desugarer mints these off a handler's own coordinate, never the + // enclosing process or subprocess name, so only a prefix catches them. + 'a boundary escape end and a handler body start/end are rejected under any container', + `process p { + user EndEvent_Boundary_T_error + user StartEvent_EventSubProcess_p_1 + user EndEvent_EventSubProcess_p_1 +}`, + [ + reservedName('EndEvent_Boundary_T_error'), + reservedName('StartEvent_EventSubProcess_p_1'), + reservedName('EndEvent_EventSubProcess_p_1'), + ], + ], [ 'a subprocess and a call carry the same rule', `process p { subprocess Gateway_x_split { user A } - subprocess StartEvent_foo { user B } - call StartEvent_x(process: "p") + subprocess Throw_foo { user B } + call Catch_x(process: "p") }`, [ reservedName('Gateway_x_split'), - reservedName('StartEvent_foo'), - reservedName('StartEvent_x'), + reservedName('Throw_foo'), + reservedName('Catch_x'), + ], + ], + // The implicit start and end are reserved as the exact ids the compiler + // mints for the body the statement sits in, not as prefixes: a modelling + // tool's default `StartEvent_1` is an ordinary name. + [ + 'the exact implicit start and end of the process body are reserved', + `process p { user StartEvent_p user EndEvent_p }`, + [ + mintedTerminal('StartEvent_p', 'start', 'p'), + mintedTerminal('EndEvent_p', 'end', 'p'), + ], + ], + [ + "a modelling tool's default start id is an ordinary name", + `process p { start StartEvent_1 user EndEvent_1 }`, + [], + ], + [ + 'a subprocess body reserves its own implicit end, under either head word', + `process p { subprocess S { user EndEvent_S } attempt T { user StartEvent_T } }`, + [ + mintedTerminal('EndEvent_S', 'end', 'S'), + mintedTerminal('StartEvent_T', 'start', 'T'), + ], + ], + [ + "another container's implicit end is free outside that container", + `process p { user EndEvent_S subprocess S { user A } }`, + [], + ], + [ + 'the subprocess statement itself sits in the enclosing body', + `process p { subprocess StartEvent_p { user A } }`, + [mintedTerminal('StartEvent_p', 'start', 'p')], + ], + [ + "the layouter's own diagram, plane, and per-shape ids are reserved too", + `process p { + user S_di + user BPMNDiagram_p + user BPMNPlane_p +}`, + [ + reservedName('S_di'), + reservedName('BPMNDiagram_p'), + reservedName('BPMNPlane_p'), ], ], // A single-segment `Flow_` name cannot match `Flow__`; the rest @@ -1557,9 +2316,9 @@ checks('Validation - one process per file', [ checks('Validation - where a start may sit', [ [ - 'a start after a chain that still flows on interrupts it', + 'a start after a chain that still flows on interrupts it, and is a second plain start beside the minted one', `process p { user A start S end E }`, - [startAfterLiveChain('S')], + [startAfterLiveChain('S'), START_AFTER_IMPLICIT_START], ], [ 'starts written back to back open one chain', @@ -1650,14 +2409,63 @@ checks('Validation - the default start among several', [ [warn(noDefaultStart('p')), warn(FORM_NEVER_OFFERED)], ], [ - 'initiator on more than one start warns on every start but the last', - `process p { start A(initiator: "a") start B message("M", initiator: "b") start C signal("S", initiator: "c") user T end E }`, - [warn(INITIATOR_SHADOWED), warn(INITIATOR_SHADOWED)], + 'initiator on more than one start warns on every start but the last', + `process p { start A(initiator: "a") start B message("M", initiator: "b") start C signal("S", initiator: "c") user T end E }`, + [warn(INITIATOR_SHADOWED), warn(INITIATOR_SHADOWED)], + ], + [ + 'initiator on one start beside a start with none shadows nothing', + `process p { start A(initiator: "a") start B message("M") user T end E }`, + [], + ], + ...( + [ + ['two plain starts', 'start A start B'], + ['a plain start beside a timer start', 'start A start B timer("PT1H")'], + [ + 'two timer starts', + 'start A timer(every: "R/PT1H") start B timer("PT1H")', + ], + ] as const + ).map(([title, starts]): Case => [ + `${title} is a second default start the engine refuses`, + `process p { ${starts} user T end E }`, + [SECOND_DEFAULT_START], + ]), + ...( + [ + ['a plain start', 'start S'], + ['a timer start', 'start S timer(every: "R/PT1H")'], + ] as const + ).map(([title, start]): Case => [ + `${title} after a body that does not open with a start is the second default start`, + `process invoice_batch { end Done ${start} }`, + [START_AFTER_IMPLICIT_START], + ]), + [ + 'a message start after such a body leaves the compiler-minted start the default, so its form is never offered', + `process p { user T end Done start S message("M") { form { x: number "X" } } }`, + [warn(FORM_NEVER_OFFERED)], + ], + [ + 'two message starts of one name are one subscription too many', + `process p { start A message("M") start B message("M") user T end E }`, + [warn(noDefaultStart('p')), duplicateNamedStart('message', 'M')], + ], + [ + 'two signal starts of one name are one subscription too many', + `process p { start A signal("S") start B signal("S") user T end E }`, + [warn(noDefaultStart('p')), duplicateNamedStart('signal', 'S')], ], [ - 'initiator on one start beside a start with none shadows nothing', - `process p { start A(initiator: "a") start B message("M") user T end E }`, - [], + 'two condition starts on one condition are one subscription too many', + `process p { var x: number start A condition(x > 1) start B condition(x > 1) user T end E }`, + [warn(noDefaultStart('p')), duplicateConditionStart('${x > 1}')], + ], + [ + 'starts of one kind with different payloads coexist', + `process p { var x: number start A message("M") start B message("N") start C signal("M") start D condition(x > 1) start E condition(x > 2) user T end F }`, + [warn(noDefaultStart('p'))], ], ]); @@ -1683,6 +2491,11 @@ checks('Validation - duplicate declarations', [ `process p { user Review user Review }`, [duplicateStepName('Review', 'p')], ], + [ + 'a step named like the process id collides with it in the compiled document', + `process p { user p }`, + [stepNameEqualsProcess('p')], + ], [ 'two steps with different names are clean', `process p { user Review user Approve }`, @@ -1860,7 +2673,7 @@ checks('Validation - gateway settings', [ [ 'a bare word is refused, the parens taking settings alone, and names no variable', ifHead('wibble'), - [gatewaySettingsOnly('an if statement')], + [settingsOnly('an if statement')], ], [ 'a repeated key is one duplicate', @@ -1874,27 +2687,27 @@ checks('Validation - gateway settings', [ ], [ 'a quoted boolean in a join key names the unquoted form', - ifHead('joinExclusive: "false"'), + ifHead('joinAsyncBefore: true, joinExclusive: "false"'), [quotedBoolean('joinExclusive')], ], [ 'a decimal in joinJobPriority is refused as on an element', - ifHead('joinJobPriority: 1.5'), + ifHead('joinAsyncBefore: true, joinJobPriority: 1.5'), [priorityShape('joinJobPriority')], ], [ 'a number in joinRetryCycle asks for quotes', - ifHead('joinRetryCycle: 3'), + ifHead('joinAsyncBefore: true, joinRetryCycle: 3'), [unquotedText('joinRetryCycle')], ], [ 'a bareword in joinRetryCycle asks for quotes and names no variable', - ifHead('joinRetryCycle: R3'), + ifHead('joinAsyncBefore: true, joinRetryCycle: R3'), [unquotedText('joinRetryCycle')], ], [ 'a bareword jobPriority on a head lowers to an expression, so it warns when undeclared', - ifHead('jobPriority: prio'), + ifHead('asyncBefore: true, jobPriority: prio'), [warn(undeclared('prio'))], ], [ @@ -1903,7 +2716,7 @@ checks('Validation - gateway settings', [ [warn(prunedJoin('if statement', 'joinAsyncBefore'))], ], [ - 'a join key on a parallel whose every branch ends has no join to set', + 'a join key on a parallel whose every branch ends has no join to set, and draws no pairing warning on top', `process p { parallel (asyncBefore: true, joinJobPriority: 5) { { end A } { end B } } }`, [warn(prunedJoin('parallel statement', 'joinJobPriority'))], ], @@ -1914,6 +2727,185 @@ checks('Validation - gateway settings', [ ], ]); +/** + * `DefaultFailedJobParseListener.parseActivity` stores a retry cycle only + * once the carrier is async, and `BpmnParse.parseAsynchronousContinuation` + * folds `exclusive` into the async flag rather than declaring a job of its + * own, so `retryCycle`/`exclusive`/`jobPriority` (and their `join`/`run` + * spellings) configure nothing without the matching async flag beside them. + * Revert: dropping the rule clears every bare row below to `[]`. + */ +checks('Validation - job-setting pairing', [ + [ + "a task's and a handler's retryCycle, exclusive and jobPriority need their own async flag", + `process p { + service A(class: "x", retryCycle: "PT1M", exclusive: false, jobPriority: 5) + on message("M", retryCycle: "PT1M") { user B } +}`, + [ + warn( + noJob('retryCycle', "'asyncBefore' or 'asyncAfter'", 'a service task'), + ), + warn( + noJob('exclusive', "'asyncBefore' or 'asyncAfter'", 'a service task'), + ), + warn( + noJob('jobPriority', "'asyncBefore' or 'asyncAfter'", 'a service task'), + ), + warn( + noJob( + 'retryCycle', + "'asyncBefore' or 'asyncAfter'", + 'an event handler', + ), + ), + ], + ], + [ + 'the same settings beside their async flag configure a job cleanly', + `process p { + service A(class: "x", asyncBefore: true, retryCycle: "PT1M", exclusive: false, jobPriority: 5) + on message("M", asyncAfter: true, retryCycle: "PT1M") { user B } +}`, + [], + ], + [ + "each of the five gateway statements' bare and join families need their own async flag", + `process p { + var flag: boolean + if (flag) (retryCycle: "PT1M", joinJobPriority: 5) { user A } else { user B } + while (flag) (exclusive: false) { user C } + do { user D } while (flag) (jobPriority: 5) + parallel (retryCycle: "PT1M", joinExclusive: false) { { user E } { user F } } + await (jobPriority: 5, joinRetryCycle: "PT1M") { message("M") { user G } signal("S") { user H } } +}`, + [ + warn( + noJob('retryCycle', "'asyncBefore' or 'asyncAfter'", 'an if statement'), + ), + warn( + noJob( + 'joinJobPriority', + "'joinAsyncBefore' or 'joinAsyncAfter'", + 'an if statement', + ), + ), + warn(noJob('exclusive', "'asyncBefore' or 'asyncAfter'", 'a while loop')), + warn( + noJob( + 'jobPriority', + "'asyncBefore' or 'asyncAfter'", + 'a do-while loop', + ), + ), + warn( + noJob( + 'retryCycle', + "'asyncBefore' or 'asyncAfter'", + 'a parallel statement', + ), + ), + warn( + noJob( + 'joinExclusive', + "'joinAsyncBefore' or 'joinAsyncAfter'", + 'a parallel statement', + ), + ), + warn( + noJob('jobPriority', "'asyncBefore' or 'asyncAfter'", 'an await block'), + ), + warn( + noJob( + 'joinRetryCycle', + "'joinAsyncBefore' or 'joinAsyncAfter'", + 'an await block', + ), + ), + ], + ], + [ + 'paired with their async flags, every gateway statement configures its jobs cleanly', + `process p { + var flag: boolean + if (flag) (asyncBefore: true, retryCycle: "PT1M", joinAsyncBefore: true, joinJobPriority: 5) { user A } else { user B } + while (flag) (asyncBefore: true, exclusive: false) { user C } + do { user D } while (flag) (asyncAfter: true, jobPriority: 5) + parallel (asyncBefore: true, retryCycle: "PT1M", joinAsyncAfter: true, joinExclusive: false) { { user E } { user F } } + await (asyncBefore: true, jobPriority: 5, joinAsyncBefore: true, joinRetryCycle: "PT1M") { message("M") { user G } signal("S") { user H } } +}`, + [], + ], + [ + "a repeated step's plain retryCycle/exclusive price the whole loop and jobPriority/run keys price each run, both needing their own async flag", + `process p { step T for 3(retryCycle: "PT1M", exclusive: false, jobPriority: 5, runRetryCycle: "PT2M", runExclusive: false) }`, + [ + warn(noJob('retryCycle', "'asyncBefore' or 'asyncAfter'", 'a step')), + warn(noJob('exclusive', "'asyncBefore' or 'asyncAfter'", 'a step')), + warn( + noPerRunJob( + 'jobPriority', + "'runAsyncBefore' or 'runAsyncAfter'", + 'a repeated step', + ), + ), + warn( + noPerRunJob( + 'runRetryCycle', + "'runAsyncBefore' or 'runAsyncAfter'", + 'a repeated step', + ), + ), + warn( + noPerRunJob( + 'runExclusive', + "'runAsyncBefore' or 'runAsyncAfter'", + 'a repeated step', + ), + ), + ], + ], + [ + 'paired with their own async flags, both the whole-loop and the per-run settings configure a job', + `process p { step T for 3(asyncBefore: true, retryCycle: "PT1M", exclusive: false, jobPriority: 5, runAsyncBefore: true, runRetryCycle: "PT2M", runExclusive: false) }`, + [], + ], + [ + 'a timer start, an awaited timer, a timer race branch, and a host-less timer handler need no async flag for any of the three', + `process p { + start Applied timer(every: "R/PT1H", jobPriority: 5, exclusive: false, retryCycle: "R1/PT1M") + await timer("PT1H", jobPriority: 5) + await { timer("PT1H", jobPriority: 5) { user A } message("M") { user B } } + on timer("PT2H", jobPriority: 7) { start ES(exclusive: false, retryCycle: "PT1M") user C } +}`, + [], + ], + [ + 'a signal start, an awaited signal and a signal race branch price their subscription job with jobPriority alone; exclusive and retryCycle still need the flag, and a signal handler declares no such job', + `process p { + start Begin signal("S1", jobPriority: 5, exclusive: false, retryCycle: "PT1M") + await signal("S2", jobPriority: 5) + await { signal("S3", jobPriority: 5) { user A } message("M") { user B } } + on signal("S4", jobPriority: 5) { user C } +}`, + [ + warn( + noJob('exclusive', "'asyncBefore' or 'asyncAfter'", 'a start event'), + ), + warn( + noJob('retryCycle', "'asyncBefore' or 'asyncAfter'", 'a start event'), + ), + warn( + noJob( + 'jobPriority', + "'asyncBefore' or 'asyncAfter'", + 'an event handler', + ), + ), + ], + ], +]); + checks('Validation - form fields', [ [ 'a form on a start event and on a user task is clean', @@ -1933,6 +2925,11 @@ checks('Validation - form fields', [ `process p { start Begin { form { blob: json "Blob" } } }`, [formFieldType('blob', 'json')], ], + [ + 'an unrecognized field type beside a constraint draws only the type error, the fit check never runs', + `process p { start Begin { form { blob: json "Blob" (minlength: 2) } } }`, + [formFieldType('blob', 'json')], + ], [ 'a form block on a service task belongs elsewhere', `process p { service S(class: "com.x.Y") { form { a: number } } }`, @@ -1968,11 +2965,18 @@ checks('Validation - form fields', [ `process p { var plan: string start Begin { form { plan: enum { a } } } }`, [], ], - [ - "a handler body's start reads the event through the handler's bindings, not a form", - `process p { error X on error(X) { start In { form { a: string } } user A } }`, - [noFlowSteps('p'), HANDLER_START_FORM], - ], + ...NESTED_START_CONTAINERS.flatMap(([kind, wrap]): Case[] => [ + [ + `a form on the start of ${kind} is never shown`, + wrap('start In { form { a: string } } user A'), + [NESTED_START_FORM], + ], + [ + `an initiator on the start of ${kind} is never written`, + wrap('start In(initiator: "who") user A'), + [NESTED_START_INITIATOR], + ], + ]), [ 'a constraint name outside the six and validator names the eight settings', `process p { start S { form { amount: number (minimum: 0) } } }`, @@ -2008,6 +3012,9 @@ checks('Validation - form fields', [ ['min: "-5"', 'number', []], ['maxlength: "80"', 'string', []], ['validator: com.example.Check', 'string', []], + ['validator: ""', 'string', [VALIDATOR_EMPTY]], + ['pattern: "dd-xx-yyyy"', 'date', [PATTERN_LETTERS]], + [`pattern: "'T'HH:mm"`, 'date', []], ]), [ "a bare word in a field's parens is not a setting", @@ -2054,6 +3061,69 @@ checks('Validation - form fields', [ `process p { start S { form { p: enum { a input x = "1" } } } }`, [formFieldDirection('p', 'input', true)], ], + [ + "an io direction in a non-enum field's block names no value lines", + `process p { start S { form { p: string { input x = "1" } } } }`, + [formFieldDirection('p', 'input')], + ], +]); + +checks('Validation - form field defaults', [ + [ + 'a decimal default on a number field is not an integer', + `process p { start S { form { n: number = 1.5 } } }`, + [numberDefault('n', '1.5')], + ], + [ + 'a quoted non-digit default on a number field is not an integer', + `process p { start S { form { n: number = "high" } } }`, + [numberDefault('n', '"high"')], + ], + [ + 'an integer default on a number field is clean', + `process p { start S { form { n: number = 5 } } }`, + [], + ], + [ + 'a quoted word default on a boolean field is neither true nor false', + `process p { start S { form { b: boolean = "yes" } } }`, + [booleanDefault('b', '"yes"')], + ], + [ + 'the literal true default on a boolean field is clean', + `process p { start S { form { b: boolean = true } } }`, + [], + ], + [ + 'a quoted "false" default on a boolean field is clean', + `process p { start S { form { b: boolean = "false" } } }`, + [], + ], + [ + 'an ISO default on a date field with no pattern reads against the engine default instead', + `process p { start S { form { d: date = "2026-01-01" } } }`, + [isoDateDefault('d', '2026-01-01')], + ], + [ + 'an ISO default fit to an explicit matching pattern is clean', + `process p { start S { form { d: date = "2026-01-01" (pattern: "yyyy-MM-dd") } } }`, + [], + ], + [ + 'a default already fit to the engine default pattern is clean', + `process p { start S { form { d: date = "01/01/2026" } } }`, + [], + ], + [ + 'a raw expression default passes on every type, since it is evaluated at render', + `process p { var x: any start S { form { n: number = "\${x}" } } }`, + [], + ], + [ + 'a variable default passes on every type, for the same reason', + `process p { var x: number start S { form { n: number = x } } }`, + [], + ], ]); checks('Validation - unreachable statements', [ @@ -2098,10 +3168,15 @@ checks('Validation - unreachable statements', [ [UNREACHABLE], ], [ - 'a conditioned parallel with no else keeps a path to its join', - `process p { var c: boolean parallel { if (c) { end A } { end B } } user Dead }`, + 'a parallel with every branch conditioned keeps a fallback to its join', + `process p { var c: boolean parallel { if (c) { end A } if (!c) { end B } } user Alive }`, [], ], + [ + 'a parallel mixing a conditioned and a plain branch has no fallback, so every branch ending kills the step after it', + `process p { var c: boolean parallel { if (c) { end A } { end B } } user Dead }`, + [UNREACHABLE], + ], [ 'a conditioned parallel with an else terminates once every branch does', `process p { var c: boolean parallel { if (c) { end A } else { end B } } user Dead }`, @@ -2122,6 +3197,21 @@ checks('Validation - unreachable statements', [ `process p { while (cond) { end A } user Alive }`, [warn(undeclared('cond'))], ], + [ + 'a step after a do-while whose body always ends can never run, and neither can the loop', + `process p { var c: boolean do { end X } while (c) user Dead }`, + [UNREACHABLE, DEAD_LOOP], + ], + [ + 'a last do-while whose body always ends leaves its gateway with no incoming flow', + `process p { var c: boolean start S do { end X } while (c) }`, + [DEAD_LOOP], + ], + [ + 'a do-while whose body ends on one branch only keeps its loop', + `process p { var c: boolean start S do { if (c) { end X } else { user A } } while (c) }`, + [], + ], [ 'a step after an end inside a subprocess can never run', `process p { subprocess S { start In end Out user Dead } }`, @@ -2221,7 +3311,7 @@ checks('Validation - call activities', [ [ 'a call with an empty process attribute is one error', `process p { call X(process: "") }`, - [CALL_PROCESS_EMPTY], + [emptyBinding('process', 'process to start')], ], [ "'binding: version' points at the version setting", @@ -2392,17 +3482,17 @@ checks('Validation - event handlers', [ [ 'two handlers with one trigger and code are ambiguous', `process p { error X on error(X) { user A } on error(X) { user B } }`, - [noFlowSteps('p'), handlerDuplicate('error', code('X'))], + [noFlowSteps('p'), handlerDuplicate('error', code('X'), 'p')], ], [ 'two catch-all handlers of one trigger are ambiguous', `process p { on error { user A } on error { user B } }`, - [noFlowSteps('p'), handlerDuplicate('error', everyEvent)], + [noFlowSteps('p'), handlerDuplicate('error', everyEvent, 'p')], ], [ 'alongside does not separate two handlers catching one code', `process p { escalation X on escalation(X) { user A } on escalation(X, alongside) { user B } }`, - [noFlowSteps('p'), handlerDuplicate('escalation', code('X'))], + [noFlowSteps('p'), handlerDuplicate('escalation', code('X'), 'p')], ], [ 'two bindings with one field is one error', @@ -2462,13 +3552,33 @@ checks('Validation - event handlers', [ [ 'two message handlers sharing one name are ambiguous', `process p { on message("X") { user A } on message("X") { user B } }`, - [noFlowSteps('p'), handlerDuplicate('message', code('X'))], + [noFlowSteps('p'), handlerDuplicate('message', eventName('X'), 'p')], ], [ 'two message handlers with different names coexist', `process p { on message("X") { user A } on message("Y") { user B } }`, [noFlowSteps('p')], ], + ...(['message', 'signal'] as const).map((trigger): Case => [ + `a ${trigger} start beside a process-level ${trigger} handler of one name is keyed apart by the start flag`, + `process p { start S ${trigger}("M") user A on ${trigger}("M") { user B } }`, + [], + ]), + [ + 'an escalation catch-all beside a coded catch at process level is refused', + `process p { escalation X on escalation(X) { user A } on escalation { user B } }`, + [noFlowSteps('p'), escalationCatchAllBesideCoded('p')], + ], + [ + "a timer job key on a timer handler's start repeats the head's", + `process p { user T on timer("PT1H", jobPriority: 5) { start ES(jobPriority: 7, exclusive: false) user A } }`, + [timerJobKeyTwice('jobPriority')], + ], + [ + 'a timer job key on the head alone, or on the start alone, is written once', + `process p { user T on timer("PT1H", jobPriority: 5) { start ES(exclusive: false) user A } on timer("PT2H") { start ES2(retryCycle: "R3/PT1M") user B } }`, + [], + ], [ 'one signal name in two containers is no duplicate', `process p { subprocess S { user T on signal("X") { user B } } on signal("X") { user A } }`, @@ -2554,21 +3664,11 @@ checks('Validation - event handlers', [ `process p { on condition("X", at: "2026-01-01") { user A } }`, [noFlowSteps('p'), CONDITION_NO_CODE, PARTICLE_ONLY], ], - [ - "'after' with no duration is a shape warning", - `process p { on timer("banana") { user A } }`, - [noFlowSteps('p'), warn(AFTER_SHAPE)], - ], [ "'after' with an expression passes through", `process p { start S on timer("\${dueDate}") { user A } }`, [], ], - [ - "'at' with a duration is a shape warning", - `process p { on timer(at: "PT1H") { user A } }`, - [noFlowSteps('p'), warn(AT_SHAPE)], - ], [ "an interrupting 'every' timer fires at most once", `process p { on timer(every: "R/PT10M") { user A } }`, @@ -2576,6 +3676,138 @@ checks('Validation - event handlers', [ ], ]); +/** + * Every carrier that reads a timer clause, held to the shape its particle's + * calendar parses. A timer start deploys the expression up front + * (`BpmnDeployer.adjustStartEventSubscriptions`), so its bad shape is an + * error; every other carrier reads it only when its scope is entered, so its + * bad shape is a warning. Revert check: the parse reverted to `startsWith('P')` + * leaves the 'after' bad value ("Pbogus") clean on every carrier. + */ +const TIMER_CARRIERS: ReadonlyArray<{ + name: string; + severity: 'error' | 'warning'; + source: (particle: string, value: string) => string; +}> = [ + { + name: 'a timer start', + severity: 'error', + source: (particle, value) => + `process p { start S timer(${timerClause(particle, value)}) user A }`, + }, + { + name: 'an awaited timer', + severity: 'warning', + source: (particle, value) => + `process p { await timer(${timerClause(particle, value)}) }`, + }, + { + name: 'a race branch timer', + severity: 'warning', + source: (particle, value) => + `process p { await { message("M") { user A } timer(${timerClause(particle, value)}) { user B } } }`, + }, + { + // Only an `on` handler is told to run alongside, so its 'every' rows carry + // the flag: without it every 'every' row would also draw the unrelated + // "fires at most once" warning that "Validation - event handlers" already pins. + name: 'an on-handler timer', + severity: 'warning', + source: (particle, value) => + `process p { start S on timer(${timerClause(particle, value)}${particle === 'every' ? ', alongside' : ''}) { user A } }`, + }, + { + name: 'a timeout listener', + severity: 'warning', + source: (particle, value) => + `process p { user U { on timeout ${particle} "${value}"(class: "com.example.T") } }`, + }, +]; + +/** `after` is always bare; `at` and `every` are always written under their key. */ +function timerClause(particle: string, value: string): string { + return particle === 'after' ? `"${value}"` : `${particle}: "${value}"`; +} + +/** + * Revert checks: `ISO_DURATION_BODY` with `W` beside the other fields turns + * 'after' `P1W2D` clean, and `JODA_PERIOD_BODY` without it turns 'at' `P1W2D` + * red; `ISO_DATE_TIME_BODY` without its zone, month-only, hour-only, ordinal, + * week or bare-`T` arm turns the matching 'at' values red; `INTERVAL_BODY` + * without its end-date arms turns the dated 'after' and 'every' values red; + * `AFTER_TIME_TEXT` without `REPEAT_BODY` turns `R3/PT1H` red; `CRON_BODY` + * back to `{5,6}` fields turns the seven-field cron clean and without its + * nickname alternative turns `@daily` red. + */ +const TIMER_PARTICLE_SHAPES: ReadonlyArray<{ + particle: string; + bad: readonly string[]; + good: readonly string[]; + message: string; +}> = [ + { + particle: 'after', + bad: ['Pbogus', 'P1W2D'], + good: [ + 'PT1H', + 'P1W', + 'PT1H/2026-12-31T00:00:00', + '2026-01-01T00:00:00/PT1H', + 'R3/PT1H', + ], + message: AFTER_SHAPE, + }, + { + particle: 'at', + bad: ['tomorrow', '2026-12-01Z'], + good: [ + '2026-08-01T09:00:00', + '2026-08-01T09:00:00Z', + '2026-08-01T09:00+02:00', + '2026-12', + '2027-12-01T10:00+01', + '2026-100', + '2026-W10-1T10:00Z', + '2026-12-01T', + 'P1W2D', + ], + message: AT_SHAPE, + }, + { + particle: 'every', + bad: ['bogus', '0 0 12 * * ? 2026', '@reboot'], + good: [ + 'R/PT10M', + '0 0 12 * * ?', + '@daily', + 'R3/2026-01-01T00:00:00/PT1H', + 'R/PT1H/2026-12-31T00:00:00', + 'R2/2026-01-01T00:00:00/2026-01-02T00:00:00', + ], + message: EVERY_SHAPE, + }, +]; + +checks( + "Validation - the timer clause's shape, per carrier and per particle", + TIMER_CARRIERS.flatMap(({ name, severity, source }) => + TIMER_PARTICLE_SHAPES.flatMap( + ({ particle, bad, good, message }): Case[] => [ + ...bad.map((value): Case => [ + `${name} rejects '${particle}: "${value}"'`, + source(particle, value), + [severity === 'error' ? message : warn(message)], + ]), + ...good.map((value): Case => [ + `${name} accepts '${particle}: "${value}"'`, + source(particle, value), + [], + ]), + ], + ), + ), +); + checks('Validation - boundary hosts', [ [ 'compensation has no attached form, so it refuses a host', @@ -2686,6 +3918,41 @@ ${FENCE} `process p { error X user Pack on error(X) { user A } on Pack: error(X) { user B } }`, [], ], + ...(['message', 'signal'] as const).map((trigger): Case => [ + `a ${trigger} handler inside a block and one attached to that block subscribe on one scope`, + `process p { subprocess Sub { user A on ${trigger}("M") { user B } } on Sub: ${trigger}("M") { user C } }`, + [handlerDuplicate(trigger, eventName('M'), 'Sub')], + ]), + [ + 'a message handler inside a repeated block subscribes one scope below the boundary on it', + `process p { var xs: json subprocess Sub for each x in xs { user A on message("M") { user B } } on Sub: message("M") { user C } }`, + [], + ], + [ + 'an error handler inside a block and one attached to that block catch on their own kinds', + `process p { error X subprocess Sub { user A on error(X) { user B } } on Sub: error(X) { user C } }`, + [], + ], + [ + 'an escalation catch-all beside a coded catch, both attached to one host, is refused', + `process p { escalation X subprocess Sub { user A } on Sub: escalation(X) { user B } on Sub: escalation { user C } }`, + [escalationCatchAllBesideCoded('Sub')], + ], + [ + 'an escalation catch-all beside a coded catch, both inside one block, is refused', + `process p { escalation X subprocess Sub { user A on escalation { user B } on escalation(X) { user C } } }`, + [escalationCatchAllBesideCoded('Sub')], + ], + [ + 'an escalation catch-all inside a block beside a coded catch attached to it is the legal mix', + `process p { escalation X subprocess Sub { user A on escalation { user B } } on Sub: escalation(X) { user C } }`, + [], + ], + [ + 'two escalation catch-alls attached to one host are refused', + `process p { subprocess Sub { user A } on Sub: escalation { user B } on Sub: escalation { user C } }`, + [handlerDuplicate('escalation', everyEvent, 'Sub')], + ], [ 'two hosted timers on one host are legal', `process p { user Pack on Pack: timer("PT1H") { user A } on Pack: timer("PT2H") { user B } }`, @@ -2694,13 +3961,18 @@ ${FENCE} [ 'a handler nested in a hosted body still lands in the host container', `process p { user A on A: message("M") { user B on A: message("M") { user C } } }`, - [handlerDuplicate('message', code('M'), 'A')], + [handlerDuplicate('message', eventName('M'), 'A')], ], [ 'an unresolved host reports only the resolution error', `process p { start S on message("M") { user A } on Missing: message("M") { user B } }`, [unresolvedStatement('Missing')], ], + [ + 'two host-less handlers of an unknown trigger word draw the unknown-kind error alone', + `process p { user T on foo { user A } on foo { user B } }`, + [unknownOnKind('foo'), unknownOnKind('foo')], + ], [ 'a hosted error handler still cannot run alongside', `process p { error X user Pack on Pack: error(X, alongside) { user A } }`, @@ -3046,6 +4318,11 @@ checks('Validation - throw and emit', [ `process p { error X start S throw error(X, class: "c") }`, [noImplementation('class', 'a thrown error')], ], + [ + 'a binding key beside an engine setting on a thrown error still draws only the binding error', + `process p { error X start S throw error(X, class: "c", asyncBefore: true) }`, + [noImplementation('class', 'a thrown error')], + ], [ 'an emitted signal carries none either', `process p { start S emit signal("Ready", class: "c") }`, @@ -3053,7 +4330,7 @@ checks('Validation - throw and emit', [ ], [ 'a thrown message with two implementations names both', - `process p { start S throw message("Ack", class: "a", expression: "b") }`, + `process p { start S throw message("Ack", class: "a", expression: "\${b}") }`, [bindingConflict('A thrown message', 'class, expression', THROW_BINDINGS)], ], [ @@ -3137,6 +4414,16 @@ checks('Validation - awaited events', [ `process p { await { message("M", assignee: "u") { user A } signal("S") { user B } } }`, [notValidOn('assignee', 'a branch of an await block')], ], + ...(['message', 'signal'] as const).map((trigger): Case => [ + `two ${trigger} branches of one race on one name subscribe twice on the gateway`, + `process p { await { ${trigger}("Dup") { user A } timer("PT1H") { user T } ${trigger}("Dup") { user B } } }`, + [raceDuplicate(trigger, 'Dup')], + ]), + [ + 'a message branch and a signal branch of one name are two subscriptions', + `process p { await { message("Dup") { user A } signal("Dup") { user B } } }`, + [], + ], ]); describe('Validation - a race branch runs the plain await rules', () => { @@ -3235,6 +4522,11 @@ checks('Validation - link events', [ `process p { step A emit link T("L") }`, [linkNoCatch('L')], ], + [ + 'a binding key on an emit link draws its own not-valid error alone, not the never-runs message too', + `process p { step A emit link T("L", topic: "shipping") await link C("L") }`, + [noImplementation('topic', 'an emitted link')], + ], [ 'a link catch in another container is out of reach', `process p { subprocess S { step A emit link T("L") } end E await link C("L") step B }`, @@ -3423,6 +4715,11 @@ checks('Validation - start triggers', [ `process p { error X start S on error(X) { start In message("M") user A end Out } }`, [START_TRIGGER_IN_HANDLER], ], + [ + 'a triggered start nested in a plain branch draws no block-specific message, just the ordinary positional one', + `process p { start S if (true) { start In message("M") } end E }`, + [startNotFirst('In')], + ], ]); checks('Validation - end triggers', [ @@ -4028,6 +5325,15 @@ checks('Validation - input and output parameters', [ `process p { service V(topic: "t") { input items = [1, 2] input rows = { k: "v" } input computed = ${FENCE}groovy\n1 + 1\n${FENCE} } }`, [], ], + ...scriptTagCases( + (tag) => `process p { user U { input x = ${FENCE}${tag}\n1\n${FENCE} } }`, + `Input 'x'`, + ), + [ + 'a script nested in a list is checked the same as a top-level one', + `process p { user U { input xs = [${FENCE}cobol\n1\n${FENCE}] } }`, + [unsupportedScriptTag(`Input 'xs'`, 'cobol')], + ], [ 'an empty map key says what to write', `process p { user U { input m = { "": "empty" } } }`, @@ -4038,6 +5344,26 @@ checks('Validation - input and output parameters', [ `process p { user U { input m = { rows: [{ cells: { "": 1 } }] } } }`, [EMPTY_MAP_KEY], ], + [ + 'an empty string value writes no value at all', + `process p { user U { input a = "" } }`, + [warn(EMPTY_STRING_VALUE)], + ], + [ + 'an empty string reached through a list item warns the same way', + `process p { user U { input a = ["x", ""] } }`, + [warn(EMPTY_STRING_VALUE)], + ], + [ + "an empty string reached through a map entry's value warns the same way", + `process p { user U { input a = { k: "" } } }`, + [warn(EMPTY_STRING_VALUE)], + ], + [ + 'a string of only whitespace is trimmed to the same nothing', + `process p { user U { input a = " " } }`, + [warn(EMPTY_STRING_VALUE)], + ], [ 'a parameter name is in scope for the collection it feeds', `process p { start S subprocess B { input lines = ["a", "b"] } { user U for each line in lines } end E }`, @@ -4294,7 +5620,7 @@ checks( ).map(([key, value, expected]) => [ `${key}: ${value}`, key === 'jobPriority' - ? `process p { user U(${key}: ${value}) }` + ? `process p { user U(asyncBefore: true, ${key}: ${value}) }` : `process p { service V(topic: "t", ${key}: ${value}) }`, expected, ]), @@ -4364,7 +5690,7 @@ checks('Validation - listeners', [ `process p { user U { on create(class: "com.example.A") - on assign(expression: "\${bean.assign(task)}") + on assignment(expression: "\${bean.assign(task)}") on complete(delegate: "\${listenerBean}") on update ${FENCE}groovy log(task) @@ -4386,9 +5712,9 @@ checks('Validation - listeners', [ [flagNotValidOn('alongside', 'a listener')], ], [ - 'an unknown event word lists both sets on a user task', - `process p { user U { on wibble(class: "com.example.C") } }`, - [unknownListenerEvent('wibble', USER_LISTENER_EVENTS)], + "'on assign' is refused with the list, whose word is 'assignment' (BpmnParse.parseTaskListeners)", + `process p { user U { on assign(class: "com.example.C") } }`, + [unknownListenerEvent('assign', USER_LISTENER_EVENTS)], ], [ 'an unknown event word lists only the execution events elsewhere', @@ -4445,9 +5771,9 @@ checks('Validation - listeners', [ [unknownParticle('whenever')], ], [ - 'a repeated event on one element is one error', - `process p { user U { on start(class: "com.example.A") on start(class: "com.example.B") } }`, - [duplicateListener('start')], + 'listeners repeat freely and fire in written order (CoreModelElement.addListenerToMap appends)', + `process p { user U { on start(class: "a.B") on start(class: "c.D") on timeout after "PT1H"(class: "x.Y") on timeout after "P1D"(class: "x.Z") } }`, + [], ], [ "a script binding follows the script task's fence rules", @@ -4459,6 +5785,11 @@ checks('Validation - listeners', [ `process p { user U { on start ${FENCE}groovy\n${FENCE} } }`, [emptyScript(`The 'on start' listener`)], ], + ...scriptTagCases( + (tag) => + `process p { user U { on start ${FENCE}${tag}\nx = 1\n${FENCE} } }`, + `The 'on start' listener`, + ), ]); describe('Validation - the timer clause, reported in place', () => { @@ -4516,6 +5847,84 @@ checks('Validation - process header attributes', [ ], ]); +/** A header setting's value, alone, so each row is one shape and its whole list. */ +const header = (setting: string) => `process p(${setting}) { start S }`; + +/** + * The engine reads no header value as an expression. Revert checks: each + * rule's arm removed from `PROCESS_HEADER_VALUE_RULES` turns its refused rows + * clean; the length check dropped turns the 65-character row clean alone. + */ +checks('Validation - process header value shapes', [ + ...['"30"', '"P0D"', '"P30D"'].map((value): Case => [ + `historyTimeToLive ${value} is a day count the engine parses`, + header(`historyTimeToLive: ${value}`), + [], + ]), + ...['"PT8H"', '"P1M"', '"-5"', '""', '"P30d"', '" P30D"', '"${ttl}"'].map( + (value): Case => [ + `historyTimeToLive ${value} fails the deployment, so it is refused`, + header(`historyTimeToLive: ${value}`), + [HISTORY_TIME_TO_LIVE_SHAPE], + ], + ), + [ + 'a bareword historyTimeToLive is refused the same way and names no variable', + header('historyTimeToLive: P90D'), + [HISTORY_TIME_TO_LIVE_SHAPE], + ], + [ + 'a versionTag template is stored as its own text, so it is refused', + header('versionTag: "${v}"'), + [VERSION_TAG_LITERAL], + ], + [ + 'a number in versionTag asks for quotes', + header('versionTag: 3'), + [VERSION_TAG_LITERAL], + ], + [ + 'a bareword in versionTag asks for quotes and names no variable', + header('versionTag: deadline'), + [VERSION_TAG_LITERAL], + ], + [ + 'a 64-character versionTag fits its column', + header(`versionTag: "${'v'.repeat(64)}"`), + [], + ], + [ + 'a 65-character versionTag overflows its column', + header(`versionTag: "${'v'.repeat(65)}"`), + [VERSION_TAG_LENGTH], + ], + [ + 'a template anywhere in a starter list is stored as a literal id, so it is refused', + header( + 'candidateStarterUsers: "demo, ${starter}", candidateStarterGroups: "#{g}"', + ), + [ + candidateStarterTemplate('candidateStarterUsers'), + candidateStarterTemplate('candidateStarterGroups'), + ], + ], + [ + 'a header label or documentation is a quoted string, never a number or a template', + header('label: 3, documentation: "${d}"'), + [headerLiteral('label'), headerLiteral('documentation')], + ], + [ + 'a bareword header label names no variable', + header('label: foo'), + [headerLiteral('label')], + ], + [ + 'a label on a step still takes the expression a task name evaluates', + `process p { user U(label: "\${who}") }`, + [], + ], +]); + /** The form carrying every optional part, the one a nested placement must still take. */ const DECORATED_CLAUSE = 'for each line in lines sequentially until (nrOfCompletedInstances >= 2)'; @@ -4578,6 +5987,24 @@ describe('Validation - the repeat clause', () => { ).toEqual([]); }); + test.each(['for 1.5', 'for -3', 'for "high"'])( + '`%s` is not a repeat count', + async (clause) => { + expect(await diagnosticsOf(`process p { user U ${clause} }`)).toEqual([ + REPEAT_COUNT, + ]); + }, + ); + + test.each(['for "3"', 'for count', `for "\${count}"`])( + '`%s` is a clean repeat count', + async (clause) => { + expect( + await diagnosticsOf(`process p { var count: number user U ${clause} }`), + ).toEqual([]); + }, + ); + test.each([ ['a subprocess', `subprocess Outer { user U ${DECORATED_CLAUSE} }`], ['an on handler body', `start S on error { user U ${DECORATED_CLAUSE} }`], @@ -4704,17 +6131,17 @@ checks('Validation - per-run job settings', [ ], [ 'a quoted boolean in a run key names the unquoted form', - `process p { service V for 3(topic: "t", runExclusive: "false") }`, + `process p { service V for 3(topic: "t", runAsyncBefore: true, runExclusive: "false") }`, [quotedBoolean('runExclusive')], ], [ 'a number in runRetryCycle asks for quotes', - `process p { service V for 3(topic: "t", runRetryCycle: 3) }`, + `process p { service V for 3(topic: "t", runAsyncBefore: true, runRetryCycle: 3) }`, [unquotedText('runRetryCycle')], ], [ 'a bareword in runRetryCycle asks for quotes and names no variable', - `process p { service V for 3(topic: "t", runRetryCycle: R2) }`, + `process p { service V for 3(topic: "t", runAsyncBefore: true, runRetryCycle: R2) }`, [unquotedText('runRetryCycle')], ], [ diff --git a/packages/language/test/vocabulary-sync.test.ts b/packages/language/test/vocabulary-sync.test.ts index ffa1aba..063ddec 100644 --- a/packages/language/test/vocabulary-sync.test.ts +++ b/packages/language/test/vocabulary-sync.test.ts @@ -19,6 +19,7 @@ import { END_TRIGGERS, EXECUTION_LISTENER_EVENTS, ON_TRIGGERS, + splitFencedScript, START_TRIGGERS, TASK_LISTENER_EVENTS, THROW_TRIGGERS, @@ -80,3 +81,18 @@ describe('lists that must not drift apart', () => { expect(listenerEvents.filter((event) => triggers.has(event))).toEqual([]); }); }); + +describe('splitFencedScript', () => { + test('a CRLF-checked-out source normalizes the body to LF', () => { + expect( + splitFencedScript('```groovy\r\ndef a = 1\r\ndef b = 2\r\n```'), + ).toEqual({ tag: 'groovy', code: 'def a = 1\ndef b = 2\n' }); + }); + + test('a one-line fence with no newline after the tag still splits it from the body', () => { + expect(splitFencedScript('```groovy1 + 1```')).toEqual({ + tag: 'groovy', + code: '1 + 1', + }); + }); +}); diff --git a/packages/transform/README.md b/packages/transform/README.md index 1dd1a2d..22f6fd4 100644 --- a/packages/transform/README.md +++ b/packages/transform/README.md @@ -3,7 +3,7 @@ The conversion layer: the IR type definitions and the four transforms that move a process between formats. It's the bulk of the project's hand-written code, where the `language` package is mostly generated. -Everything pivots on the IR, a small set of plain TypeScript objects in `src/ir/types.ts` that describe a process without committing to any one file format ([ADR-0006](../../docs/decisions/0006-engine-agnostic-intermediate-representation.md)). +Everything pivots on the IR, a small set of plain TypeScript objects in `src/ir/types.ts` that describe a process without committing to any one file format ([ADR-0006](../../docs/decisions/0006-intermediate-representation-between-ast-and-bpmn.md)). Each transform converts between the IR and one neighboring format, so none of them has to know about any of the others. ```mermaid @@ -40,8 +40,8 @@ interface BpmnProcess { isExecutable: true; // always true (executable process) versionTag?: string; // operaton:versionTag, an author-supplied version label historyTimeToLive?: string; // operaton:historyTimeToLive, absent means the exporter's default (P30D) - candidateStarterUsers?: string; // operaton:candidateStarterUsers, a comma-separated list of user ids - candidateStarterGroups?: string; // operaton:candidateStarterGroups, a comma-separated list of group ids + candidateStarterUsers?: string; // operaton:candidateStarterUsers, comma-separated user ids listed as candidate starters (Tasklist's process list; BpmnDeployer.addAuthorizations stores each entry as written) + candidateStarterGroups?: string; // operaton:candidateStarterGroups, comma-separated group ids, listed and stored the same way flowElements: FlowElement[]; sequenceFlows: SequenceFlow[]; errorDecls?: { name: string; code: string; message?: string }[]; // every error code the process raises, catches, or declares @@ -76,6 +76,7 @@ interface SequenceFlow { Every event and activity kind above also carries the flat engine settings Operaton reads off a flow node: `asyncBefore`, `asyncAfter`, `exclusive`, `jobPriority`, and `retryCycle` (the `operaton:failedJobRetryTimeCycle` element body), plus `executionListeners`. Only non-default values are stored, so `asyncBefore` and `asyncAfter` are `true` or absent and `exclusive` is `false` or absent. +On a node that carries a timer, `exclusive` is written on the `bpmn:timerEventDefinition` as well as on the event tag, since `BpmnParse.parseTimer` locks the timer job from the definition and the tag's copy reaches only the async continuation job; the importer takes the definition's value, falls back to the tag's, and warns once when the two are written apart ([ADR-0047](../../docs/decisions/0047-timer-job-settings-on-the-element-the-engine-reads.md)). Every gateway kind carries the five settings too, on the statement head that synthesizes it, but none of the listeners. `ExclusiveGateway`, `ParallelGateway`, `InclusiveGateway`, and `EventBasedGateway` extend `JobSettings` directly rather than the wider `EngineAttributes`, since a listener needs the textual identity a synthesized gateway has none of ([ADR-0022](../../docs/decisions/0022-engine-attributes-as-named-ir-fields.md), [ADR-0040](../../docs/decisions/0040-engine-settings-on-synthesized-gateways.md)). A `StartEvent` and a `UserTask` also carry `formFields`, the `operaton:formData` block: a `FormField` has a type (`string`, `number`, `boolean`, `date`, or `enum`), a label, a default, the `datePattern` a date is parsed with, an enum's `values`, its `constraints` in the order the engine validates them, each a name from the closed `FormConstraintName` union and a `config`, and its `properties`, the `operaton:property` entries ([ADR-0037](../../docs/decisions/0037-form-field-constraints-values-and-properties.md)). @@ -130,49 +131,54 @@ const { source, warnings: printWarnings } = irToDsl(ir); // sync; warns for what ## The import contract `xmlToIr` never discards an element without saying so. -What it cannot represent falls into two buckets, and the reasoning is in [ADR-0014](../../docs/decisions/0014-honest-bpmn-import-contract.md). +What it cannot represent falls into two buckets; the reasoning is in [ADR-0014](../../docs/decisions/0014-honest-bpmn-import-contract.md), and the rule that draws the line where the engine's own parse draws it is in [ADR-0054](../../docs/decisions/0054-import-tiers-follow-the-engine-parse.md). Content the IR cannot express at all is refused: `xmlToIr` throws a subclass of `UnsupportedConstructError` before producing any IR, so there is no partial output. +Content Operaton refuses to deploy is refused the same way, since there is nothing that runs to write back, and the message names the `BpmnParse` method that fails the deployment. Content the IR does not carry (an extra Operaton extension attribute, a lane, a text annotation) comes back through the `warnings` array instead. +Every drop of a setting the engine reads names the method that reads it and says the document written back runs without it, so a reader can tell a cosmetic drop from one that changes what runs. The diagram interchange data aside ([ADR-0003](../../docs/decisions/0003-auto-layout-for-diagram-interchange.md)), no element is dropped in silence. Every BPMN element and every extension element `xmlToIr` cannot carry is reported, named by the tag the document spells and by its own id, and attributed to the element it sat on. A construct is reported whole, so a dropped `bpmn:ioSpecification` names itself rather than each data input inside it. The lane structure goes the same way, reported lane by lane, with anything hung on a lane or a lane set leaving with it. -`camunda:` is not a foreign namespace here. -`xmlToIr` reads a `camunda:` extension attribute under its local name the way Operaton does, so `camunda:assignee` imports exactly as `operaton:assignee` does and `camunda:formHandlerClass` drops exactly as `operaton:formHandlerClass` does. -The alias covers attributes only, so a `camunda:` extension element drops whole, except for a connector, which is refused under either prefix wherever it sits: beside a service, send, or business rule task's implementation, and on a thrown message. +The `camunda` namespace is read as the `operaton` namespace, attributes and elements alike, since `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` falls back to the camunda URI wherever the operaton spelling is absent. +The namespace URI is swapped in the document's `xmlns` declarations before it is parsed (the same URI inside a documentation body is text and stays), one warning per document says so, and the document written back carries `operaton:` alone. +A file declaring both prefixes reads both as one namespace, and the one setting written under both prefixes on one element keeps the later one. +A connector is refused under either prefix wherever it sits: beside a service, send, or business rule task's implementation, and on a thrown message. What is dropped without a warning is an attribute `xmlToIr` does not read, and it comes in two shapes. One is an attribute in a foreign namespace written directly on a mapped BPMN element, which is where an editor parks its own bookkeeping. The same attribute on an Operaton extension element the IR reads is reported, a whole foreign-namespace extension element is reported too, and an attribute written in no namespace at all that BPMN does not declare is reported as well, since no editor writes there. -The other is a BPMN attribute this surface neither reads nor reports: a sequence flow's `name`, or a process's `processType` or `isClosed`. +The other is a BPMN attribute this surface neither reads nor reports: a process's `processType` or `isClosed`. Each of those is content left out. A lone `operaton:resource` on a sequence flow's condition expression or a conditional event definition's condition is the one exception that is reported rather than silently dropped: Operaton reads it only alongside a `language`, so on its own it reaches nothing, and the condition still imports as the expression its body writes. -`isExecutable="false"`, whose import changes what the document says, is reported instead of left for the reader to notice. +`isExecutable="false"`, whose import changes what the document says, is reported instead of left for the reader to notice, and so is an absent `isExecutable`, which `BpmnParse.parseProcessDefinitions` skips in a new deployment. ### Refusals Every class below extends `UnsupportedConstructError`, so catching that one classifies any refusal. `src/errors.ts` carries the per-class detail and `src/xml-to-ir.ts` the exact conditions. -| Class | Refused because | -| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `UnsupportedElementError` | An element kind outside the supported subset (`bpmn:adHocSubProcess`, `bpmn:complexGateway`, ...) | -| `UnsupportedServiceTaskFormError` | A service, send, or business rule task that carries no execution binding the engine would reach, that carries an `operaton:type` outside `external`, `mail`, and `shell`, or that carries an `` element beside its implementation attributes, which the Connect plugin runs in their place; a mail or shell task missing a field its behaviour's parse requires, naming a field its behaviour class does not declare, or carrying a shell field as an expression or a flag outside `true`/`false`; or a thrown message whose binding shape it cannot read, a `mail`/`shell` type included, since a thrown message has no member block to carry the fields either requires ([ADR-0042](../../docs/decisions/0042-mail-and-shell-as-a-type-binding.md)); or a service, send, or business rule task without a decision reference carrying `operaton:resultVariable`, or the older `operaton:resultVariableName`, beside `operaton:class` or `operaton:delegateExpression`, which `BpmnParse.parseServiceTaskLike` refuses to deploy ([ADR-0043](../../docs/decisions/0043-result-variable-beside-an-expression-binding.md)) | -| `UnsupportedFormFieldTypeError` | A form field typed outside `string`/`long`/`boolean`/`date`/`enum` | -| `UnsupportedFormFieldConstraintError` | A form field constraint the engine refuses to deploy or the script cannot spell: a name outside the seven the surface takes, since `FormValidators.createValidator` fails the deployment on any name it has not registered, a `validator` or a bound with no `config`, or a name repeated on one field | -| `UnsupportedFormReferenceError` | A user task naming both a form key and a form reference, or a form reference with no binding or one outside `latest`, `deployment`, and `version` | -| `UnsupportedCallActivityError` | A call activity the engine could not resolve or execute as written | -| `UnsupportedEventDefinitionError` | An event definition of the wrong kind for its position | -| `UnsupportedEventFeatureError` | An event of the right kind in a shape the surface cannot express, such as a process start carrying an error, escalation, or compensation trigger, a message start whose name is an expression, a second start event in a subprocess or transaction, or a thrown message carrying a connector element. Also a compensation boundary event, or an `isForCompensation` activity, since BPMN attaches compensation through a boundary event and an association rather than the `subprocess`/`on compensation` block this surface writes; when the two pair up through a `bpmn:association`, the message names the compensated activity, the boundary event, and the handler, and prints the `subprocess`/`on compensation` rewrite to write by hand. Also a cancel definition where Operaton refuses to deploy one: on an end event outside a `bpmn:transaction`, on a boundary event whose host is not one, or on a second cancel boundary of the same block. Also a cancel boundary carrying `cancelActivity="false"`, which Operaton deploys and lets run beside the block instead of in place of it, a shape this surface has no spelling for. Also a branch of a wait with several branches that leads to anything other than another wait, and a branch-opening wait that another path also flows into. Also a link definition naming no link, and a sequence flow leaving a link throw or entering a link catch, a branch of a wait included: a throw ends its path and a catch is entered by the throw of its name, so neither flow has a spelling. Also a wait with several branches carrying `operaton:asyncAfter`, which `BpmnParse.parseEventBasedGateway` refuses to deploy ([ADR-0040](../../docs/decisions/0040-engine-settings-on-synthesized-gateways.md)) | -| `UnsupportedLoopCharacteristicsError` | A multi-instance repetition on an event handler, which its trigger enters rather than repeats, or a multi-instance repetition anywhere in a shape this surface cannot write back: an `operaton:outputParameter` beside it, a `bpmn:loopCardinality` body or an element name it cannot spell, or a combination Operaton refuses to deploy | -| `UnsupportedCollaborationError` | A collaboration, meaning pools or message flows; the IR holds one process | -| `UnsupportedExtensionFormError` | An input/output value or a listener in a shape this surface cannot write, a script task naming a deployment resource, which runs in place of its body, or an injected field naming no value slot or both of its literal ones, neither of which Operaton deploys | -| `UnsupportedErrorMappingError` | An `operaton:errorEventDefinition` on an external task carrying `errorRef` and no `expression`, which `parseOperatonErrorEventDefinitions` fails the deployment on, or an `errorRef` naming no error root or one with no code, the refusal a throw's dangling reference draws; moddle deletes an unresolved reference, so a definition written with no `errorRef`, which the engine skips, arrives the same way | -| `UnsupportedAssignmentError` | A user task carrying a `bpmn:humanPerformer` beside `operaton:assignee`, which `parseUserTaskCustomExtensions` refuses as a duplicate assignee, or more than one `bpmn:humanPerformer`, which `parseHumanPerformer` refuses | -| `UnsupportedConditionExpressionError` | A sequence flow's condition or a conditional event definition's condition carrying a `language`, which Operaton evaluates in a script engine rather than as the UEL expression this tool writes | +| Class | Refused because | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `UnsupportedDocumentError` | A document Operaton refuses to deploy whole, or one this tool cannot read as one process: a `` declaration, which `Parser.setXxeProcessing` disallows; an entity reference XML does not predefine, which the engine's parse fails on; an `isSequential` or `triggeredByEvent` value outside `true` and `false`, which the validating parse against `BPMN20.xsd` refuses; an id written on two elements, which the same validation refuses as a duplicate `xs:ID`; an id outside the ASCII letters, digits, `_`, `-`, and `.` this tool reads, where the schema admits any letter; a `bpmn:import`, which `BpmnParse.parseImports` fails on for every type but WSDL; no `bpmn:process` at all; or several processes of which none is marked `isExecutable="true"`, which `parseProcessDefinitions` deploys none of | +| `UnsupportedCollaborationError` | Two or more processes marked `isExecutable="true"`; `parseProcessDefinitions` deploys each of them and the IR holds one | +| `UnsupportedElementError` | An element kind outside the supported subset (`bpmn:adHocSubProcess`, `bpmn:complexGateway`, ...) | +| `UnsupportedServiceTaskFormError` | A service, send, or business rule task that carries no execution binding the engine would reach, that carries an `operaton:type` outside `external`, `mail`, and `shell`, or that carries an `` element beside its implementation attributes, which the Connect plugin runs in their place; a mail or shell task missing a field its behaviour's parse requires, naming a field its behaviour class does not declare, or carrying a shell field as an expression or a flag outside `true`/`false`; a thrown message whose binding shape it cannot read, a `mail`/`shell` type included, since a thrown message has no member block to carry the fields either requires ([ADR-0042](../../docs/decisions/0042-mail-and-shell-as-a-type-binding.md)); a service, send, or business rule task without a decision reference carrying `operaton:resultVariable`, or the older `operaton:resultVariableName`, beside `operaton:class` or `operaton:delegateExpression`, which `BpmnParse.parseServiceTaskLike` refuses to deploy ([ADR-0043](../../docs/decisions/0043-result-variable-beside-an-expression-binding.md)); or a business rule task carrying `operaton:decisionRefTenantId`, which pins the tenant `BpmnParse.parseTenantId` resolves the decision against, an `operaton:mapDecisionResult` outside the four words the engine maps, or a `decisionRefBinding` of `version` without a version or of `versionTag` | +| `UnsupportedFormFieldTypeError` | A form field typed outside `string`/`long`/`boolean`/`date`/`enum` | +| `UnsupportedFormFieldConstraintError` | A form field constraint the engine refuses to deploy or the script cannot spell: a constraint with no name, or a name outside the seven the surface takes, since `FormValidators.createValidator` fails the deployment on any name it has not registered; a `validator` or a bound with no `config`; or a name repeated on one field, which `DefaultFormHandler.parseValidation` deploys and this script holds once per field | +| `UnsupportedFormReferenceError` | A user task naming both a form key and a form reference, or a form reference with no binding, one outside `latest`, `deployment`, and `version`, or a `version` binding naming no version | +| `UnsupportedCallActivityError` | A call activity naming neither a `calledElement` nor an `operaton:caseRef`, or both, which `BpmnParse.parseCallActivity` refuses to deploy; one naming a `caseRef` alone, which the engine runs through `CaseCallActivityBehavior` and this surface has no form for; a `calledElementTenantId`, which pins the tenant `parseTenantId` resolves the called process against; `calledElementBinding="version"` without a version, or `="versionTag"`, which this surface has no setting for; or an `operaton:in`/`out` carrying a `variables` other than `all`, a `source=""`, which `parseCallableElementProvider` refuses, a `source` or `sourceExpression` with no `target`, or none of the recognized shapes | +| `UnsupportedEventDefinitionError` | An event definition of the wrong kind for its position | +| `UnsupportedEventFeatureError` | An event of the right kind in a shape the surface cannot express or the engine refuses to deploy. On the surface's side: a process start carrying an error, escalation, or compensation trigger, which Operaton ignores; a message start whose name is an expression; a sub-process start carrying a trigger; a second start event in a sub-process or transaction; a thrown message carrying a connector; a thrown message's `resultVariable` beside `operaton:expression`, under which `parseServiceTaskLike` stores the expression's value and the script's throw has no slot for it; a link definition naming no link, and a sequence flow leaving a link throw or entering a link catch; a branch of a wait with several branches that leads to anything other than another wait, and a branch-opening wait that another path also flows into; a compensation boundary event or an `isForCompensation` activity, with the `subprocess`/`on compensation` rewrite named when the two pair up through a `bpmn:association`; a cancel boundary carrying `cancelActivity="false"`; and a `messageRef` or `signalRef` naming no root, as one written with a prefix does, since `BpmnParse.resolveName` maps a prefix through the xmlns table and this tool matches the id as written. On the engine's side, each naming the method: two or more plain or timer starts on the process (`selectInitial`); a process or a sub-process with no start (`parseStartEvents`); a sequence flow into or out of an event handler (`parseSequenceFlow`); a thrown message's `resultVariable` beside `operaton:class` or `operaton:delegateExpression` (`parseServiceTaskLike`); an error end with no `errorRef`, and an error or escalation throw whose root carries no code (`parseEndEvents`, `parseIntermediateThrowEvent`); an escalation throw with no `escalationRef` or one naming no root, and an escalation catch naming no root (`findEscalationForEscalationEventDefinition`, `createEscalationEventDefinitionForEscalationHandler`); two `bpmn:signal` roots sharing a name (`parseSignals`); a cancel definition on an end outside a `bpmn:transaction`, on a boundary whose host is not one, or on a second cancel boundary of one block; and a wait with several branches carrying `operaton:asyncAfter` (`parseEventBasedGateway`, [ADR-0040](../../docs/decisions/0040-engine-settings-on-synthesized-gateways.md)) | +| `UnsupportedGatewayShapeError` | An exclusive gateway whose outgoing flows `BpmnParse.validateExclusiveGateway` fails the deployment on, quoting the engine's own sentence: no outgoing flow; one outgoing flow carrying a condition; a default flow carrying a condition; or a flow without a condition that is not the default, beside a default or beside another unconditioned flow. One conditioned flow beside one plain flow with no default imports, since the engine deploys it and takes the plain flow as the default | +| `UnsupportedLoopCharacteristicsError` | A multi-instance repetition on an event handler, which its trigger enters rather than repeats, or a multi-instance repetition anywhere in a shape this surface cannot write back: an `operaton:outputParameter` beside it, which `BpmnParse.checkActivityOutputParameterSupported` fails the deployment on, a `bpmn:loopCardinality` body or an element name it cannot spell, or a combination Operaton refuses to deploy | +| `UnsupportedExtensionFormError` | Extension content in a shape this surface cannot write or the engine refuses. On the surface's side: an input/output value form it cannot write, a listener naming no binding or an event outside its position's, a `timeout` listener with no timer or two, an injected field naming no value slot or both of its literal ones, a form field `id` or an input/output parameter `name` the script cannot spell (letters, digits, `_`, an inner `-`, no keyword), since the engine sets the variable under that name; two input or two output parameters sharing a name, which `IoMapping.executeInputParameters` runs both of with the last write winning; a script naming a deployment resource, which `ScriptUtil.getScript` runs in place of the body; and a script body containing three consecutive backticks, which no fence this language has can enclose. On the engine's side, each naming the method: an `operaton:inputOutput` on a start event or a boundary event (`ensureNoIoMappingDefined`), on any gateway or on an event handler (`checkActivityInputOutputSupported`); an `operaton:outputParameter` on an end event (`checkActivityOutputParameterSupported`); a second `operaton:inputOutput`, `operaton:formData`, `operaton:properties`, or `operaton:failedJobRetryTimeCycle` on one element, which `Element.elementNS` throws on (the retry cycle's reader runs on an async step, a timer-driven event or a typed throw alone, and the refusal says so rather than gating on it); an execution listener with `class=""` or `delegateExpression=""` (`parseExecutionListener`); a `jobPriority` or `taskPriority` that is neither an integer nor an expression (`parsePriority`); and a script task with an empty `scriptFormat` or with neither a body nor a resource (`ScriptUtil.getScript`) | +| `UnsupportedErrorMappingError` | An `operaton:errorEventDefinition` on an external task carrying an `errorRef` and no `expression`, which `parseOperatonErrorEventDefinitions` fails the deployment on, or an `errorRef` naming an error root with no code | +| `UnsupportedAssignmentError` | A user task carrying a `bpmn:humanPerformer` beside `operaton:assignee`, which `parseUserTaskCustomExtensions` refuses as a duplicate assignee, or more than one `bpmn:humanPerformer`, which `parseHumanPerformer` refuses | +| `UnsupportedConditionExpressionError` | A sequence flow's condition or a conditional event definition's condition carrying a `language`, which Operaton evaluates in a script engine rather than as the UEL expression this tool writes, or typed through `xsi:type` as anything but `tFormalExpression`, which `parseConditionExpression` fails the deployment on | ### Import warnings @@ -193,27 +199,46 @@ interface ImportWarning { } ``` -`extensionAttribute` covers an Operaton or camunda extension the IR does not read off the element carrying it: an `operaton:` element the moddle extension does not declare, a foreign vendor namespace, and an attribute with no IR field at all such as `operaton:formHandlerClass`. -An injected field drops the same way in two shapes: one riding a binding other than `class`, `delegate`, or a built-in `type`, since Operaton hands no field list to any other, and one whose stored value disagrees with the `${` prefix that decides whether it is written back as a literal or as an expression. -An `operaton:string` child is carried rather than dropped: its text lands on the field the same way a `stringValue` attribute's would, and the warning names only that this tool writes it back as a `stringValue` attribute on export. -The question is asked per owner kind, so a listener or an input/output parameter found on a gateway, an `operaton:formData` on a service task, and an `operaton:inputOutput` on an event are reported too: no IR node reads them there. -It is asked again of every extension child the IR does read, so an unread attribute there is reported rather than leaving with the element that imports: an `operaton:taskListener`'s `id`, which Operaton addresses a timeout listener's job by, an undeclared `operaton:` or foreign-namespace attribute on a listener or an input/output parameter, and the `id`/`name` decoration on an `operaton:value` list item. +`extensionAttribute` covers an Operaton extension the IR does not read off the element carrying it: an `operaton:` element the moddle extension does not declare, a foreign vendor namespace, and an attribute with no IR field at all such as `operaton:formHandlerClass`. +The question is asked per owner kind, so an `operaton:formData` on a service task is reported: no IR node reads it there. +Where the owner is one the engine does read the setting on, the warning names the reader and says the document written back runs without it. +That is an execution listener on the process (`BpmnParse.parseExecutionListenersOnScope`), on a sequence flow (`parseExecutionListenersOnTransition`), or on any gateway kind. +It is an `operaton:inputOutput` on an intermediate catch, an intermediate throw, or an end event (`parseActivityInputOutput`). +It is `formKey`, `formRef`, its binding and version, or `formHandlerClass` on the process's own start (`parseStartFormHandlers`), and `jobPriority`, `taskPriority`, or `isStartableInTasklist` on the process (`parseProcess`). +And it is `async` or an `operaton:in` payload on a thrown signal (`parseSignalEventDefinition`). +A `failedJobRetryTimeCycle` written as an attribute is reported with its own sentence: the engine reads the element form alone. +An `operaton:initiator` or an `operaton:formData` on a start that is not the process's own is reported the same way: `parseScopeStartEvent` reads no `operaton:` attribute there and `parseStartFormHandlers` runs for the process's own start alone. +It is asked again of every extension child the IR does read, so an unread attribute there is reported rather than leaving with the element that imports: an undeclared `operaton:` or foreign-namespace attribute on a listener or an input/output parameter, and the `id`/`name` decoration on an `operaton:value` list item. +An `operaton:taskListener`'s `id` is consumed without a word: on a `timeout` listener it is the key `BpmnParse.parseTimeoutTaskListener` requires and the export mints one per timeout listener from the task id, and on every other event `parseTaskListener` never reads it. The same `operaton:value` tag on a form field is an enum value the IR reads, `id` and `name` included. +An injected field drops in three shapes: one riding a binding other than `class`, `delegate`, or a built-in `type`, since Operaton hands no field list to any other; one whose stored value disagrees with the `${` or `#{` opening that decides whether it is written back as a literal or as an expression; and one whose `operaton:expression` child opens with whitespace before the `${`, as a pretty-printer indents it, since a raw template opens directly after its quote and a quoted literal opening with an expression is refused. +An `operaton:string` child is carried rather than dropped: its text lands on the field the same way a `stringValue` attribute's would, and the warning names only that this tool writes it back as a `stringValue` attribute on export. A form field drops three things the engine never reads, each warning naming the method that ignores it: a `datePattern` off a `date` field, `operaton:value` children off an `enum` field, and a `config` on a `required` or `readonly` constraint. -Three more are carried as written, with a warning that the printed script draws an error at the field, since the engine deploys each and dropping it would change what runs: a bound on a type its validator refuses, a bound whose `config` is not an integer, and a literal enum default naming none of the values. +What the engine deploys and then fails on is carried as written, with a warning that the printed script draws an error at the field, since dropping it would change what runs. +That is a bound on a type its validator refuses, a bound whose `config` is not an integer, and a literal enum default naming none of the values. +A literal default the field's type cannot convert goes the same way: a `number` default that is not an integer, which `LongFormType.convertValue` throws on, a `boolean` default outside `true`/`false`, which `BooleanFormType.convertValue` reads as false, and an ISO date default on a `date` field naming no pattern, which `DateFormType` parses under `dd/MM/yyyy`. A shell task's `wait`, `redirectError`, or `cleanEnv` flag spelled in any case but lowercase, such as `wait="TRUE"`, is carried the same way: it deploys, and `ShellActivityBehavior.readFields` compares it with `"true"` case-sensitively, so the engine reads it as false. +A script task with no `scriptFormat` at all is imported as `juel`, the `ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE` that `BpmnParse.parseScriptTaskElement` substitutes, and the warning says so; one carrying a `scriptFormat` outside the fence tags this surface knows is imported as written, with a warning that the printed script draws an error there. +A `bpmn:script` or `operaton:script` with an empty body is carried the same way, since `ScriptUtil.getScript` checks the source for null and not for emptiness, and the printed fence draws the empty-body error. A repeated enum value id is rewritten the way `FormTypes.parseFormPropertyType` keeps it, once at its first position with its last `name`, and the warning names the rewrite. An external task's `operaton:taskPriority`, `operaton:properties`, or `operaton:errorEventDefinition` on a service, send, or business rule task bound by class, expression, delegate expression, decision, or a built-in mail or shell type is reported here too, one warning per item, since `parseExternalServiceTask` alone reads them and the step runs without them either way. +On a thrown message the same three, and an injected field, are reported by their reader: with `operaton:type="external"` the sentence says `parseExternalServiceTask` reads it off the message definition (or off the event, for `operaton:properties`) and this surface's throw has no position for it, so the document written back runs without it; with any other binding, that the engine never reads it there. +A `resultVariable` beside an `external` or a built-in `type` binding on a task, or beside `external` or no binding on a thrown message, is carried on the task and dropped on the throw, either way with a warning that `parseServiceTaskLike` hands it to an expression binding alone. The `errorCodeVariable` and `errorMessageVariable` on such a definition warn as they do on a thrown error: the engine stores them and reads them off the catching definition alone. A task's `operaton:property` is keyed by `name`, as `BpmnParseUtil.parseOperatonExtensionProperties` reads it, and a form field's by `id`, as `DefaultFormHandler.parseProperties` reads it, so each side reports the other attribute as unread; an entry missing its key or its value is skipped with a warning, and a repeated key is kept once at its first position with its last value, as both readers keep it. -A second `operaton:inputOutput`, `operaton:formData`, `operaton:properties`, or `operaton:failedJobRetryTimeCycle` on one element is reported here as well, since Operaton reads one of each and the first is kept. -A repetition naming its collection or its element variable in both the BPMN and the `operaton:` spelling goes the same way, since the BPMN spelling is the one Operaton keeps and the `operaton:` one is dropped. +A repetition naming its collection in both the BPMN and the `operaton:` spelling keeps the one `MultiInstanceActivityBehavior.resolveNrOfInstances` reads and reports the other: when exactly one of the two is an expression (a value containing `{`), that one, since `parseMultiInstanceLoopCharacteristics` stores an expression and a variable name in two fields and the expression field is read first; otherwise the `bpmn:loopDataInputRef`, which the engine writes into the one field second. +An element variable named in both spellings keeps the `bpmn:inputDataItem`, written second into the engine's one field. An implementation attribute that a higher-ranked one shadows is reported here too, on a service, send, or business rule task, on a thrown message, and on a call activity naming both of its variable-mapping attributes, since Operaton never reads past the binding it resolves. +An `operaton:in`/`out` on a call activity naming more than one shape keeps the one `parseCallableElementProvider` reads first and reports the rest: `variables="all"` ahead of a `source`, `sourceExpression`, or `target`, a `source` ahead of a `sourceExpression`, and a `businessKey` ahead of them all; a second `businessKey` keeps the last, as `setBusinessKeyValueProvider` overwrites. +Listeners repeat freely: several on one event import in document order, which is the order `CoreModelElement.addListenerToMap` and `TaskDefinition.addTaskListener` run them in. +A listener naming two or more bindings keeps the first in the order `parseExecutionListener` and `parseTaskListener` read them (`class`, `expression`, `delegateExpression`, then an `operaton:script` child) and reports each loser. +An execution listener with `expression=""` and a task listener with `class=""`, `expression=""`, or `delegateExpression=""` carry the empty text with a warning, since the engine deploys each (an empty expression evaluates to the empty text; an empty class or delegate expression fails when the listener's event fires) and the printed script draws an error there. +A timer under a task listener that is not a `timeout` is dropped with a warning, since `parseTaskListener` reads no event definition there. +An input/output parameter carrying body text beside one nested value keeps the nested value and reports the text, as `BpmnParseUtil.parseNestedParamValueProvider` reads it. An engine setting or a listener on a link throw is reported here as well, one warning per item: Operaton creates no activity for a link throw and so never reads them, and the re-exported document runs the same without them. Attribution is exact wherever moddle ties the content to its owning element; the few undeclared `operaton:` elements it cannot pin down are reported once against the process id instead, coarser but still reported. On a call activity, `calledElementTenantId` is execution-affecting rather than cosmetic, so it is refused instead of warned about: it pins which tenant the engine resolves the called process against, so dropping it changes which process runs. Neither variable-mapping attribute falls on that side of the boundary, since Operaton runs a mapping delegate after the declared `in` and `out` mappings rather than in place of them, so both import into the IR's `mapper` field instead ([ADR-0033](../../docs/decisions/0033-call-activity-variable-mapping.md)). -An input/output value or a listener the surface cannot write is refused for the same reason: dropping a value form, a second listener firing at the same event, or a second parameter binding the same name would change what the process runs. `lane` covers a `bpmn:Lane`, one warning per lane, a lane nested in a `bpmn:childLaneSet` included. The flat IR has no lane concept, so every step lands in one process and the assignment goes. @@ -221,37 +246,50 @@ The flat IR has no lane concept, so every step lands in one process and the assi `label` covers a distinct `name` on an event handler, a typed end event, an intermediate throw, an intermediate catch, or a boundary event. Those read from their trigger and code, so a differing label has nowhere to render. A link event's `name` is written from its link name on export, so a `name` equal to the link name is derived and a differing one alone is reported. -It also covers a label on a start or an end whose id carries a synthesized-id prefix, since a script cannot spell that id back: the statement that would have carried the label is left out whole for a start, and for an end only at its block's tail; elsewhere the end prints under its reserved id and the print reports it. +A sequence flow's `name` is reported here when it differs from the label the rebuilt document derives, which is the condition's body inside its `${...}` on a conditioned flow and nothing on a plain one; `name=""`, which the Modeler writes on every unconditioned flow, is not a label. +It also covers a label on a start or an end whose id is the one the compiler mints for its container (`StartEvent_`, `EndEvent_`, or `EndEvent_` for a boundary escape), since a script cannot spell that id back: the statement that would have carried the label is left out whole for a start, and for an end only at its block's tail; elsewhere the end prints under its reserved id and the print reports it. +Any other id prints, respelled if the script cannot spell it, with the print reporting the rename ([ADR-0050](../../docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md)). `unreferencedRoot` covers a `bpmn:Message` or `bpmn:Signal` root that nothing in the process references, a receive task's `messageRef` included. It also covers an error or escalation root carrying no code, which nothing can key it by; one carrying a code imports as a declaration whether or not anything raises it. -`documentation` covers `bpmn:documentation` at every position where `label` above is warned, for the same reason: an event handler, a typed end event, an intermediate throw, an intermediate catch, a boundary event, and a start or an end whose id carries a synthesized-id prefix. +`documentation` covers `bpmn:documentation` at every position where `label` above is warned, for the same reason: an event handler, a typed end event, an intermediate throw, an intermediate catch, a boundary event, and a start or an end whose id is the one the compiler mints for its container. It also covers a `bpmn:documentation` on a position that reads no `name` at all: the definitions root, an event definition, an external task's `operaton:errorEventDefinition`, an Error/Escalation/Message/Signal root, a multi-instance loop characteristics element, and a sequence flow. A second `bpmn:documentation` child on one element, or one whose `textFormat` is set to anything but plaintext, falls under the category regardless of position. Everywhere else the text is carried onto the IR node alongside its `name` ([ADR-0031](../../docs/decisions/0031-carry-bpmn-documentation.md)). `unmappedConstruct` covers BPMN content no reader on this transform reads, one warning per construct. +Beside the process: a `bpmn:collaboration`'s participants and message flows, which `BpmnParse.parseCollaboration` records for the diagram alone, one warning per pool naming the process it wraps and one per message flow naming its ends; a process not marked `isExecutable="true"` beside the one that is, which `parseProcessDefinitions` does not deploy either; and a root element other than the error, escalation, message, and signal roots the events resolve against, such as a `bpmn:category`, a `bpmn:dataStore`, a `bpmn:itemDefinition`, or a `bpmn:interface`. +A process holding no flow element at all imports with a warning that the printed script draws an error, since a process needs a step. On an element it touches: an artifact on a process or sub-process (a `bpmn:textAnnotation`, its `bpmn:association`, a `bpmn:group`), a `bpmn:ioSpecification`, a `bpmn:property`, a data association, a `bpmn:auditing` or `bpmn:monitoring` block, and a resource role the engine reads nothing of. On a user task a `bpmn:humanPerformer` and a `bpmn:potentialOwner` are read instead, the way `BpmnParse.parseTaskDefinition` reads them: the performer's formal expression is the assignee, each owner's is split as `parseCommaSeparatedList` splits it into `user(x)` candidate users and `group(x)` or bare candidate groups, and the role-derived entries come before the `operaton:` attribute's own, in the engine's order. One warning per role names the rewrite, since the script writes `assignee`, `candidateUsers`, and `candidateGroups` and the exported document carries the Operaton attributes alone; the engine builds the same identity links from either spelling ([ADR-0039](../../docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md)). +An `operaton:potentialStarter` on the process is read the same way, as `parseStartAuthorization` reads it: its formal expression is split onto `candidateStarterUsers` and `candidateStarterGroups` after the attributes' own, with one warning per element naming the rewrite; one carrying no formal expression is dropped, since the engine reads nothing else off it. What the engine reads nothing of stays a drop: a `bpmn:performer` or a bare `bpmn:resourceRole`, a role with no `bpmn:formalExpression` child (a `bpmn:expression` typed `tFormalExpression` through `xsi:type` included, since the engine fetches the child by tag), a role's `resourceRef` or parameter binding, and any role on an activity other than a user task. A `startQuantity` or `completionQuantity` other than `1` on an activity is reported here too: BPMN declares both with a default of `1` and `BpmnParse` never reads either, so the step runs as if it read `1`, which is what the source document runs. -On a repetition: the `bpmn:loopDataOutputRef`, `bpmn:oneBehaviorEventRef`, and `bpmn:noneBehaviorEventRef` references and a `behavior` other than `All`, all of which Operaton parses and then never reads. +On a repetition: the `bpmn:loopDataOutputRef`, `bpmn:oneBehaviorEventRef`, and `bpmn:noneBehaviorEventRef` references and a `behavior` other than `All`, all of which Operaton parses and then never reads, and a `language` on a `bpmn:completionCondition`, which `parseMultiInstanceLoopCharacteristics` hands to `createExpression` as bare text. An `operaton:jobPriority` there joins them, since Operaton reads a job priority off the step and never off the repetition element. A `bpmn:standardLoopCharacteristics`, with its `bpmn:loopCondition` child, drops whole rather than importing as a repetition: Operaton's own parser looks only for a multi-instance child there, so the activity deploys through its ordinary path and runs once regardless of what the source declared. As a flow element in its own right: a `bpmn:dataObject` (with anything nested under it, such as a `bpmn:dataState`), a `bpmn:dataObjectReference`, or a `bpmn:dataStoreReference`, at process level and inside a sub-process alike; none of the three is something a `bpmn:sequenceFlow` can point at, so dropping it leaves no hole in the graph, and Operaton keeps process variables in its own store regardless. -Beside the process: a root element other than the error, escalation, message, and signal roots the events resolve against, such as a `bpmn:category`, a `bpmn:dataStore`, a `bpmn:itemDefinition`, or a `bpmn:interface`. On a `bpmn:transaction`: the `method` and `protocol` attributes, which Operaton never reads, and `triggeredByEvent="true"`, which it ignores on that tag and runs the block as an ordinary step of the surrounding flow. +A `bpmn:transaction` with no start event imports with one added, since Operaton deploys it and `SubProcessActivityBehavior.execute` fails on entering it; a `bpmn:process` or a `bpmn:subProcess` with none, an empty process included, is refused, since `parseStartEvents` fails the deployment there. On a wait with several branches: an `instantiate="true"`, and an `eventGatewayType` other than `Exclusive`, neither of which Operaton's own parser ever reads. -On any step: a `default`, which names the route Operaton takes when no other route out of the step is taken, and which this surface carries on a split alone, so the imported step is left with no route to fall back on. +On any step: a `default` naming one of the step's own outgoing flows is carried, since `BpmnActivityBehavior.handleNoTransitions` takes it when no other route holds, and the printed script writes it as the fallback of the block the step's routes print as; a `default` naming any other flow is dropped with a warning, since `handleNoTransitions` finds no flow to take and fails the step. +An `operaton:errorEventDefinition` on an external task written with no `errorRef` is skipped with a warning, as `parseOperatonErrorEventDefinitions` skips it. The category also covers an attribute written without a namespace that BPMN does not declare, attributed to the element carrying it. -Three members of the category report a changed value rather than a dropped construct. -One is an expression body opening with the `#{...}` delimiter, on a `bpmn:loopCardinality`, a `bpmn:completionCondition`, a `bpmn:conditionExpression`, or a `bpmn:condition`: this surface writes `${...}` only, so the body comes back inside that delimiter, which Operaton evaluates identically. -A `#{` later in the body is left alone, since the printer returns such a body character for character. -The second is `isExecutable="false"` on the process: the IR holds an executable process and nothing else, so the import and the file written back from it are both executable and an engine will run what the source document held back. -An absent `isExecutable` needs no warning, an engine reading that as executable too. -The third is a `bpmn:manualTask`, imported through the same mapper as `bpmn:task`: `ManualTaskActivityBehavior` and `TaskActivityBehavior` differ in nothing Operaton reads, so token flow, waiting, listeners, async, and job configuration are unchanged, but `HistoricActivityInstance.getActivityType()` reports `task` where the source wrote `manualTask`, and the warning names that rewrite. +Several members of the category report a changed value or a rewrite rather than a dropped construct. +One is an expression body opening with `#{` on a `bpmn:loopCardinality`, a `bpmn:completionCondition`, a `bpmn:conditionExpression`, a `bpmn:condition`, or an `operaton:errorEventDefinition`'s `expression` that the printer spells as bare DSL: the rebuilt document writes that body inside `${...}`, which Operaton evaluates identically. +Everywhere else a `#{...}` body is carried and printed back as written, a body the printer keeps quoted included. +A `bpmn:conditionExpression` or `bpmn:completionCondition` body with no `${` or `#{` opener at all goes the same way, with a warning that says more: `UelExpressionCondition.evaluate` (or `MultiInstanceActivityBehavior.completionConditionSatisfied`) reads the bare text as a string and fails on every run, where the script writes it inside `${...}`, which evaluates it; an empty condition body is carried as no condition with the same warning. +`isExecutable="false"` on the process: the IR holds an executable process and nothing else, so the import and the file written back from it are both executable and an engine will run what the source document held back. +An absent `isExecutable` on a lone process is reported the same way, since `parseProcessDefinitions` skips it in a new deployment. +The `camunda` namespace, when the document declares it, is reported once against the process: it was read as the `operaton` namespace and the document written back carries `operaton:` alone. +A `bpmn:manualTask` is imported through the same mapper as `bpmn:task`: `ManualTaskActivityBehavior` and `TaskActivityBehavior` differ in nothing Operaton reads, so token flow, waiting, listeners, async, and job configuration are unchanged, but `HistoricActivityInstance.getActivityType()` reports `task` where the source wrote `manualTask`, and the warning names that rewrite. +A `bpmn:intermediateThrowEvent` carrying no event definition, the Modeler's milestone marker, imports as a plain step the same way: `parseIntermediateThrowEvent` gives it `IntermediateThrowNoneEventActivityBehavior`, which only leaves, and history reports `task` where the document said `intermediateThrowEvent`. +A spelling the engine reads as one of its own is imported under the spelling this tool writes, with a warning naming the rewrite. +`operaton:async="true"` imports as `asyncBefore` (`BpmnParse.isAsyncBefore`), and `operaton:resultVariableName` as `resultVariable` (`parseResultVariable`). +A `calledElementBinding` or `decisionRefBinding` word outside the four `parseBinding` matches imports as `latest`, since `BaseCallableElement.isLatestBinding` reads no binding as latest. +An `errorRef` naming no `bpmn:error` root imports as the error code spelled by its text, on a catch, a throw, and an external task's mapping alike: every engine reader takes a dangling reference's text as the code, and the rebuilt document declares an error root carrying it. Two more members report neither a drop nor a change, but a construct that arrived half-written. A cancel end and the cancel boundary event that catches it are wired together when Operaton parses the boundary, and nothing but such an end ever reaches such a boundary, so either half alone deploys and then goes wrong at run time. @@ -298,11 +336,13 @@ The Operaton namespace comes from `src/operaton-moddle.json`, a trimmed fork of Every synthesized gateway, flow, and boundary-event id comes from `synthesize-ids.ts`. Gateway and flow ids are positional, derived from the element's structural coordinate. +An unnamed throw or catch is minted a positional id the same way (`Throw__`, `Catch__`); compiling the same unnamed statement again after the printer reordered it (a multi-start chain, a hoisted branch) renumbers the id, which is cosmetic and does not change what the process runs. A boundary event's id is host-derived instead (`Boundary__`), so it survives a round trip unmoved no matter where the decompiler places the handler. The templates are frozen: changing one means updating the round-trip normalizer and regenerating `tests/golden/invoice-approval-generated.bpmn` ([ADR-0010](../../docs/decisions/0010-deterministic-structural-ids.md), [ADR-0019](../../docs/decisions/0019-boundary-events-attached-to-an-activity.md)). `juel.ts` is a hand-rolled recursive-descent parser mirroring the Langium expression sub-grammar in `bpmn-script.langium`. It runs on the import path: `xmlToIr` reads raw `${...}` bodies, and `irToDsl` decides between native syntax and the quoted fallback. +A JUEL string literal always prints double-quoted, whichever quote character the source used; the two are equivalent to the engine (`Scanner.nextString` accepts either). ## Dependencies on other packages diff --git a/packages/transform/src/ast-to-ir.ts b/packages/transform/src/ast-to-ir.ts index f440bab..87ee4a6 100644 --- a/packages/transform/src/ast-to-ir.ts +++ b/packages/transform/src/ast-to-ir.ts @@ -52,7 +52,7 @@ import { integerLiteralText, renderExpression, formatPlainWordList, - SCRIPT_FORMAT_ALIASES, + scriptFormatOf, splitFencedScript, CALL_MAPPER_KEY_BY_KIND, CATCH_TRIGGERS, @@ -161,6 +161,7 @@ import { eventIdentities, ioMapped, jobSettings, + splitTimerJobSettings, } from './ir/types.js'; import { makeGatewaySplitId, @@ -790,18 +791,19 @@ function lowerServiceTask(builder: Builder, stmt: AstServiceTask): Frontier { * Build the {@link CodeBinding} whichever of `class`/`expression`/`delegate` * the block names, in that order. `class` reads through {@link attrValue}, * which strips the `${...}` wrapper so a bareword stays a dotted Java path; the - * other two keep it, that text being what Operaton evaluates as EL. + * other two read through {@link elAttrValue}, which keeps it, that text + * being what Operaton evaluates as EL. */ function codeBinding(attrs: KeyValueAttr[]): CodeBinding | undefined { const className = attrValue(attrs, 'class'); if (className !== undefined) { return { kind: 'class', className }; } - const expression = rawExpressionAttrValue(attrs, 'expression'); + const expression = elAttrValue(attrs, 'expression'); if (expression !== undefined) { return { kind: 'expression', expression }; } - const delegate = rawExpressionAttrValue(attrs, 'delegate'); + const delegate = elAttrValue(attrs, 'delegate'); if (delegate !== undefined) { return { kind: 'delegateExpression', expression: delegate }; } @@ -929,7 +931,7 @@ function lowerScriptTask(builder: Builder, stmt: AstScriptTask): Frontier { kind: 'scriptTask', id: stmt.name, ...namedAttrs(stmt), - format: SCRIPT_FORMAT_ALIASES[tag] ?? tag, + format: scriptFormatOf(tag) ?? tag, code, ...(resultVariable !== undefined ? { resultVariable } : {}), ...readLoop(stmt), @@ -1071,7 +1073,7 @@ function lowerWhile( addFlow(builder, loopId, body.entry, condition); } if (body.exit !== null) { - addFlow(builder, body.exit, loopId); + addFlow(builder, body.exit, loopId, undefined, body.exitFlowId); } // The loop gateway's one non-back-edge outgoing flow is its unconditioned @@ -1125,11 +1127,14 @@ function lowerDoWhile( * semantics the unconditioned siblings always run, so the fallback would be * dead. * - * The inclusive fork always gets a default flow, stamped exactly as `if` stamps - * its own: onto the `else` branch when one is written, otherwise straight to - * the join. A gateway whose every branch is conditioned and that carries no - * default deploys and runs, then throws a stuck execution in Operaton the first - * time no condition holds. + * The inclusive fork gets a default flow only where the engine can take it: + * onto the `else` branch when one is written, or straight to the join when + * every branch is conditioned, since such a fork with no default deploys and + * runs, then throws a stuck execution the first time no condition holds. + * `InclusiveGatewayActivityBehavior.execute` takes the default only when no + * other flow was taken, and a flow with no condition always is, so beside an + * unconditioned branch the fallback would be dead, and the printer would spell + * it as an `else` the validator refuses. */ function lowerParallel( builder: Builder, @@ -1143,15 +1148,22 @@ function lowerParallel( const fork = readJobSettings(settings); const join = readJobSettings(settings, joinSettingKey); - // Reserved only where a fallback is named: an AND split pushes no flow under - // the id, so claiming it would move an authored collider off it for nothing. + const fallback = + inclusive && + (stmt.branches.some((b) => b.otherwise) || + stmt.branches.every((b) => b.condition !== undefined)); + + // Reserved only where a fallback is written: a fork that pushes no flow under + // the id would move an authored collider off it for nothing. let defaultFlowId: string | undefined; - if (inclusive) { + if (fallback) { defaultFlowId = reserveDefaultFlowId(builder, forkId); + } + if (inclusive) { builder.flowElements.push({ kind: 'inclusiveGateway', id: forkId, - defaultFlowId, + ...(defaultFlowId === undefined ? {} : { defaultFlowId }), ...fork, }); builder.flowElements.push({ @@ -1272,9 +1284,11 @@ function lowerSubProcess( * is also invalid BPMN without its trigger start, so an empty body still gets * start -> flow -> end for {@link ensureHandlerStart} to attach the trigger to. * - * The handler's own settings land on this sub-process node, never on the - * trigger start it wraps: that start is elided on print, so anything stored - * there would be unrecoverable on the way back. + * The handler's own settings land on this sub-process node, except the three + * a timer job takes off the start event that declares it + * ({@link splitTimerJobSettings}); none of their readers looks at the + * enclosing sub-process. The printer lifts the three back into the head when + * the start prints no statement of its own. */ function lowerOnHandler( builder: Builder, @@ -1298,14 +1312,20 @@ function lowerOnHandler( start.isInterrupting = false; } + let settings = readEngineAttributes(stmt); + if (start.eventDefinition.kind === 'timer') { + const { timer, continuation } = splitTimerJobSettings(settings); + Object.assign(start, timer); + settings = continuation; + } + builder.flowElements.push({ kind: 'subProcess', id, triggeredByEvent: true, flowElements: nested.flowElements, sequenceFlows: nested.sequenceFlows, - ...readIoParameters(stmt.params), - ...readEngineAttributes(stmt), + ...settings, }); } @@ -1656,10 +1676,7 @@ function namedTriggerDefinition( function lowerCallActivity(builder: Builder, stmt: AstCallActivity): Frontier { const calledElement = attrValue(settingsOf(stmt.items), 'process') ?? ''; const binding = versionBinding(settingsOf(stmt.items)); - const businessKey = rawExpressionAttrValue( - settingsOf(stmt.items), - 'businessKey', - ); + const businessKey = elAttrValue(settingsOf(stmt.items), 'businessKey'); const mapper = callVariableMapper(settingsOf(stmt.items)); const { inMappings, outMappings } = lowerCallMappings(stmt.mappings); @@ -1684,8 +1701,8 @@ function lowerCallActivity(builder: Builder, stmt: AstCallActivity): Frontier { * The {@link CallVariableMapper} a call's settings name, class first, matching * the order Operaton resolves the two attributes in. The class reads through * {@link attrValue}, which strips the `${...}` wrapper so a bareword stays a - * dotted Java path; the delegate keeps it, that text being what Operaton - * evaluates as EL. + * dotted Java path; the delegate reads through {@link elAttrValue}, which + * keeps it, that text being what Operaton evaluates as EL. */ function callVariableMapper( attrs: KeyValueAttr[], @@ -1694,7 +1711,7 @@ function callVariableMapper( if (className !== undefined) { return { kind: 'class', className }; } - const expression = rawExpressionAttrValue( + const expression = elAttrValue( attrs, CALL_MAPPER_KEY_BY_KIND.delegateExpression, ); @@ -1778,8 +1795,9 @@ function lowerCallMappings(mappings: VariableMapping[]): { /** * Lower one `in`/`out` mapping. `all` (`*`) copies everything; a bare `target` * is the same-name shorthand; a single-segment `VarRef` source copies that - * variable by name; anything else renders to a `${...}` body. `local` is - * stamped only when set, so the IR never carries `local: false`. + * variable by name; anything else becomes its {@link elText}: a string + * literal bare, everything else a `${...}` body. `local` is stamped only + * when set, so the IR never carries `local: false`. */ function lowerCallMapping(mapping: VariableMapping): CallVariableMapping { const local = mapping.local ? ({ local: true } as const) : {}; @@ -1800,7 +1818,7 @@ function lowerCallMapping(mapping: VariableMapping): CallVariableMapping { } return { kind: 'expression', - sourceExpression: renderExpression(mapping.source), + sourceExpression: elText(mapping.source), target, ...local, }; @@ -1864,9 +1882,11 @@ function readLoop(stmt: RepeatOwner): Repeatable { } /** - * A whole number is the one count that goes into the attribute bare: Operaton - * parses a plain `loopCardinality` body as an integer and evaluates anything - * else as EL, so a decimal has to be wrapped to yield a number at all. + * A whole number is the one count that goes into the attribute bare: + * `MultiInstanceActivityBehavior.resolveLoopCardinality` `Integer.parseInt`s + * a body with no `${`/`#{` on every run, which only a plain integer + * survives; anything else is wrapped so the engine evaluates it as EL and + * truncates the result with `intValue()` instead. */ function loopCardinality(expr: Expr): string { if (isLiteralInt(expr)) { @@ -1979,7 +1999,7 @@ function listenersFor( function listenerBinding(listener: AstListener): ListenerBinding { if (listener.script !== undefined) { const { tag, code } = splitFencedScript(listener.script); - return { kind: 'script', format: SCRIPT_FORMAT_ALIASES[tag] ?? tag, code }; + return { kind: 'script', format: scriptFormatOf(tag) ?? tag, code }; } return withDeclaredFields( codeBinding(settingsOf(listener.items)) ?? NO_BINDING, @@ -2096,7 +2116,7 @@ function lowerIoParameters( .map((param) => ({ name: param.name, value: lowerIoValue(param.value) })); } -/** Lists and maps recurse; anything else becomes the plain text {@link attrValue} resolves. */ +/** Lists and maps recurse; anything else becomes the EL text {@link elText} resolves. */ function lowerIoValue(value: AstIoValue): IoValue { if (isListLiteral(value)) { return { kind: 'list', items: value.items.map(lowerIoValue) }; @@ -2112,9 +2132,9 @@ function lowerIoValue(value: AstIoValue): IoValue { } if (isScriptLiteral(value)) { const { tag, code } = splitFencedScript(value.body); - return { kind: 'script', format: SCRIPT_FORMAT_ALIASES[tag] ?? tag, code }; + return { kind: 'script', format: scriptFormatOf(tag) ?? tag, code }; } - return { kind: 'text', text: exprText(value) }; + return { kind: 'text', text: elText(value) }; } function boolAttrValue( @@ -2181,15 +2201,17 @@ function joinContinuation( * Drop the synthesized join gateway when nothing flows into it, which happens * when every branch terminates via `end`/`throw`/`goto` or a nested compound * that never falls through. A join with zero incoming flows is invalid BPMN. + * The join is always still in place here: its lowering pushed it before the + * branches, and nothing between removes an element. */ function pruneUnreachableJoin(builder: Builder, joinId: string): string | null { if (builder.sequenceFlows.some((flow) => flow.targetRef === joinId)) { return joinId; } - const index = builder.flowElements.findIndex((fe) => fe.id === joinId); - if (index !== -1) { - builder.flowElements.splice(index, 1); - } + builder.flowElements.splice( + builder.flowElements.findIndex((fe) => fe.id === joinId), + 1, + ); return null; } @@ -2227,8 +2249,9 @@ function namedAttrs(stmt: { items: ParenItem[] }): Named { /** * The first matching setting's value, as the plain string the IR carries. NOT - * for `expression`/`delegate`: {@link rawExpressionAttrValue} keeps their - * `${...}` wrapper instead of stripping it. + * for a key the engine evaluates as EL (`expression`, `delegate`, + * `mapperDelegate`, `businessKey`, an io value): {@link elAttrValue} keeps a + * bareword or dotted path wrapped in `${...}` instead of stripping it. */ function attrValue(attrs: KeyValueAttr[], key: string): string | undefined { const attr = attrs.find((a) => a.key === key); @@ -2236,9 +2259,11 @@ function attrValue(attrs: KeyValueAttr[], key: string): string | undefined { } /** - * Read an expression as the plain BPMN text a body or attribute carries rather - * than as a `${...}` body: a string literal yields its bare value, a bareword - * its dotted path verbatim, anything else its canonical `${...}` body. + * Read an expression as the plain BPMN text a key the engine takes as + * written carries (`label`, `documentation`, `class`, ...) rather than + * evaluates as EL: a string literal yields its bare value, a bareword its + * dotted path verbatim, anything else its canonical `${...}` body. For a key + * the engine evaluates as EL, see {@link elText}. */ function exprText(value: Expr): string { if (isLiteralString(value)) { @@ -2258,17 +2283,27 @@ function exprText(value: Expr): string { } /** - * The first matching attribute's value as the `${...}` body a raw JUEL - * attribute carries verbatim. Unlike {@link attrValue} this never strips the - * wrapper: a bareword or dotted `VarRef` is wrapped instead, which is what - * Operaton evaluates as EL. + * Read an expression as the EL text `createExpression` evaluates to what the + * author meant: a string literal is fixed text with no `${...}` slot to + * lose. A bareword, a dotted path, an operator chain, or a raw template + * takes its canonical `${...}`/`#{...}` rendering instead, since the engine + * reads bare text as a fixed string rather than as a variable lookup. */ -function rawExpressionAttrValue( - attrs: KeyValueAttr[], - key: string, -): string | undefined { +function elText(value: Expr): string { + if (isLiteralString(value)) { + return value.value; + } + return renderExpression(value); +} + +/** + * The first matching attribute's value as {@link elText} reads it. Unlike + * {@link attrValue} a bareword or dotted `VarRef` stays wrapped in `${...}`, + * which is what Operaton evaluates as EL. + */ +function elAttrValue(attrs: KeyValueAttr[], key: string): string | undefined { const attr = attrs.find((a) => a.key === key); - return attr === undefined ? undefined : renderExpression(attr.value); + return attr === undefined ? undefined : elText(attr.value); } /** For dotted-identifier values the grammar parses as a `VarRef` but BPMN wants as text. */ diff --git a/packages/transform/src/bpmn-moddle.d.ts b/packages/transform/src/bpmn-moddle.d.ts index b62d90e..a2184a1 100644 --- a/packages/transform/src/bpmn-moddle.d.ts +++ b/packages/transform/src/bpmn-moddle.d.ts @@ -64,9 +64,23 @@ declare module 'saxen' { getContext: () => ParseContext, ) => void; + /** Raw text between tags, entities still encoded; `decodeEntities` resolves them. */ + export type TextHandler = ( + value: string, + decodeEntities: (text: string) => string, + getContext: () => ParseContext, + ) => void; + + export type CDataHandler = ( + value: string, + getContext: () => ParseContext, + ) => void; + export class Parser { on(event: 'openTag', handler: OpenTagHandler): void; on(event: 'closeTag', handler: CloseTagHandler): void; + on(event: 'text', handler: TextHandler): void; + on(event: 'cdata', handler: CDataHandler): void; parse(xml: string): void; } } diff --git a/packages/transform/src/cfg-analysis.ts b/packages/transform/src/cfg-analysis.ts index ac637cc..e74023e 100644 --- a/packages/transform/src/cfg-analysis.ts +++ b/packages/transform/src/cfg-analysis.ts @@ -51,6 +51,14 @@ export function analyzeCfg(container: FlowContainer): CfgAnalysis { const dominates = makeDominanceQuery(idom, VIRTUAL_ENTRY); const postDominates = makeDominanceQuery(ipdom, VIRTUAL_EXIT); + // A back-edge is u -> v where v dominates u. Filtering the raw flow list + // keeps every parallel edge and excludes the sentinel edges. Computed once: + // `tryWhile` and `tryDoWhileEntry` each ask for it per emitted node, and a + // straight chain of n tasks would otherwise refilter all n flows n times. + const backEdgeList = container.sequenceFlows.filter((f) => + dominates(f.targetRef, f.sourceRef), + ); + return { immediateDominator(node) { return idom.get(node); @@ -61,11 +69,7 @@ export function analyzeCfg(container: FlowContainer): CfgAnalysis { dominates, postDominates, backEdges() { - // A back-edge is u -> v where v dominates u. Filtering the raw flow - // list keeps every parallel edge and excludes the sentinel edges. - return container.sequenceFlows.filter((f) => - dominates(f.targetRef, f.sourceRef), - ); + return backEdgeList; }, outgoing(node) { return [...(graph.succ.get(node) ?? [])]; @@ -245,23 +249,66 @@ function reversePostorder(root: string, succ: Map): string[] { return postorder.reverse(); } -/** `a` dominates `b` when `a` sits on `b`'s idom chain. Reflexive and total. */ +/** + * `a` dominates `b` when `a` sits on `b`'s idom chain. Reflexive and total. + * + * `idom` is a tree rooted at `root`: every reachable node has exactly one + * parent, so numbering it by one DFS gives each node a pre-order entry time + * and, as its exit time, the last entry time handed out anywhere in its + * subtree. `a` is then an ancestor of `b` (or `b` itself) exactly when `b`'s + * entry time falls inside `a`'s [entry, exit] interval, a classic Euler-tour + * containment test that answers in two map lookups. + * A node absent from `idom` (unknown or unreachable) gets no interval and the + * query answers false for it on either side, which is what "dominated by + * nothing and dominates nothing" requires. + */ function makeDominanceQuery( idom: Map, root: string, ): (a: string, b: string) => boolean { + const { entry, exit } = numberDominatorTree(idom, root); + return (a, b) => { - if (!idom.has(b)) return false; - if (a !== root && !idom.has(a)) return false; - - let cur: string | undefined = b; - const guard = idom.size + 1; - let steps = 0; - while (cur !== undefined && steps++ <= guard) { - if (cur === a) return true; - if (cur === root) break; - cur = idom.get(cur); - } - return false; + const ea = entry.get(a); + const eb = entry.get(b); + if (ea === undefined || eb === undefined) return false; + return ea <= eb && eb <= exit.get(a)!; }; } + +/** Iterative pre-order DFS over the parent map `idom`, rooted at `root`. */ +function numberDominatorTree( + idom: Map, + root: string, +): { entry: Map; exit: Map } { + const children = new Map(); + for (const [node, parent] of idom) { + if (node === root || parent === undefined) continue; + if (!children.has(parent)) children.set(parent, []); + children.get(parent)!.push(node); + } + + const entry = new Map(); + const exit = new Map(); + let counter = 0; + + const stack: Array<{ node: string; childIdx: number }> = [ + { node: root, childIdx: 0 }, + ]; + entry.set(root, counter++); + + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + const kids = children.get(frame.node) ?? []; + if (frame.childIdx < kids.length) { + const child = kids[frame.childIdx++]; + entry.set(child, counter++); + stack.push({ node: child, childIdx: 0 }); + } else { + exit.set(frame.node, counter - 1); + stack.pop(); + } + } + + return { entry, exit }; +} diff --git a/packages/transform/src/errors.ts b/packages/transform/src/errors.ts index c1d4678..9fa558f 100644 --- a/packages/transform/src/errors.ts +++ b/packages/transform/src/errors.ts @@ -1,5 +1,5 @@ /** - * Refusals raised by `xmlToIr`. + * Refusals raised by `xmlToIr`, plus {@link LayoutError}, raised by `irToXml`. * * Content the IR cannot express is refused before any IR is produced. Content * the IR does not carry but that costs no semantics is dropped with a warning @@ -245,19 +245,36 @@ export class UnsupportedLoopCharacteristicsError extends UnsupportedConstructErr } } -/** Pools or message flows: the IR models a single standalone process. */ +/** + * Two or more executable processes: `BpmnParse.parseProcessDefinitions` + * deploys each of them, and the IR holds one. A pool or a message flow is + * diagram data to the engine and is warned about instead. + */ export class UnsupportedCollaborationError extends UnsupportedConstructError { declare readonly detail: string; constructor(detail: string) { super( - `The file contains ${detail}, which this tool cannot import. ` + - 'Only a single standalone process (no pools or message flows) is supported.', + `The file contains ${detail}; this tool imports one process, and ` + + '`BpmnParse.parseProcessDefinitions` deploys each of them.', { detail }, ); } } +/** + * A document Operaton refuses to deploy whole (a ``, an undefined + * entity, a duplicate id, a `bpmn:import`), or one this tool cannot read as + * one process. `detail` says what was found and what reads it that way. + */ +export class UnsupportedDocumentError extends UnsupportedConstructError { + declare readonly detail: string; + + constructor(detail: string) { + super(`The document cannot be imported: ${detail}.`, { detail }); + } +} + /** * Operaton extension content the IR's discriminated unions cannot represent. * `detail` names the shape; the message states the rule it broke. @@ -303,12 +320,13 @@ export class UnsupportedFormReferenceError extends UnsupportedConstructError { } /** - * A condition Operaton evaluates outside UEL: a sequence flow's + * A condition Operaton evaluates outside UEL, or refuses: a sequence flow's * `bpmn:conditionExpression`, or a conditional event definition's * `bpmn:condition`. Both reach Operaton's `parseConditionExpression`, so - * `detail` names the same shape either way, a `language` attribute: Operaton - * builds a `ScriptCondition` from it and runs the body in that language, - * never as the UEL expression this tool writes. + * `detail` names the same two shapes either way: a `language` attribute, from + * which Operaton builds a `ScriptCondition` and runs the body in that + * language, never as the UEL expression this tool writes; or an `xsi:type` + * other than `tFormalExpression`, which fails the deployment. */ export class UnsupportedConditionExpressionError extends UnsupportedConstructError { declare readonly elementId: string; @@ -317,9 +335,30 @@ export class UnsupportedConditionExpressionError extends UnsupportedConstructErr constructor(elementId: string, detail: string) { super( `The condition on '${elementId}' cannot be imported: ${detail}. This ` + - 'tool writes a condition as an expression Operaton evaluates as ' + - 'UEL, and a condition Operaton hands to a script engine has no ' + - 'spelling here.', + 'tool writes a condition as a tFormalExpression Operaton evaluates ' + + 'as UEL; a condition of another type, or one Operaton hands to a ' + + 'script engine, has no spelling here.', + { elementId, detail }, + ); + } +} + +/** + * An exclusive gateway whose outgoing flows `BpmnParse.validateExclusiveGateway` + * fails the deployment on. `detail` is the engine's own sentence for the + * shape, so the refusal reads as the deployment error would. + */ +export class UnsupportedGatewayShapeError extends UnsupportedConstructError { + declare readonly elementId: string; + declare readonly detail: string; + + constructor(elementId: string, detail: string) { + super( + `The exclusive gateway '${elementId}' cannot be imported: ${detail} ` + + "Operaton's BpmnParse.validateExclusiveGateway fails the deployment " + + 'on this shape, so there is nothing that runs to write back; give ' + + 'every route but the fallback a condition, and name the fallback ' + + "in the gateway's 'default'.", { elementId, detail }, ); } @@ -392,3 +431,22 @@ function supportedKindsMessage( ); } } + +/** + * `bpmn-auto-layout`'s grid solver throws on some validator-clean flow shapes + * (a `goto` restructuring among them). `xml` is the document as serialized + * before the layout call: no `bpmndi:` diagram, but otherwise the document + * Operaton deploys the same as a laid-out one + * (`BpmnParse.parseDiagramInterchangeElements` reads a diagram only when one + * is present), so a caller can fall back to it. + */ +export class LayoutError extends Error { + readonly xml: string; + + constructor(xml: string, cause: unknown) { + const causeMessage = cause instanceof Error ? cause.message : String(cause); + super(`bpmn-auto-layout failed to lay out the process: ${causeMessage}`); + this.name = 'LayoutError'; + this.xml = xml; + } +} diff --git a/packages/transform/src/index.ts b/packages/transform/src/index.ts index 980220e..d564460 100644 --- a/packages/transform/src/index.ts +++ b/packages/transform/src/index.ts @@ -30,6 +30,7 @@ export type { IoMapped, LoopCharacteristics, Repeatable, + Activity, IoParameter, IoValue, FieldInjection, @@ -83,13 +84,16 @@ export { UnsupportedEventFeatureError, UnsupportedLoopCharacteristicsError, UnsupportedCollaborationError, + UnsupportedDocumentError, UnsupportedCallActivityError, UnsupportedExtensionFormError, UnsupportedFormReferenceError, UnsupportedConditionExpressionError, + UnsupportedGatewayShapeError, UnsupportedFormFieldConstraintError, UnsupportedErrorMappingError, UnsupportedAssignmentError, + LayoutError, } from './errors.js'; export { astToIr } from './ast-to-ir.js'; diff --git a/packages/transform/src/ir-to-dsl.ts b/packages/transform/src/ir-to-dsl.ts index ecd8b67..881414a 100644 --- a/packages/transform/src/ir-to-dsl.ts +++ b/packages/transform/src/ir-to-dsl.ts @@ -19,8 +19,10 @@ import { DATE_PATTERN_KEY, END_TRIGGERS, EXPRESSION_OPEN, + EXTERNAL_TASK_EL_NAME, isReservedName, joinSettingKey, + LOOP_VARIABLES, runSettingKey, TIMER_PARTICLE_BY_KIND, TYPE_BINDING_KEY, @@ -54,21 +56,30 @@ import type { } from './ir/types.js'; import { carriesFields, - eachElement, eventIdentities, gatewayDefaultFlowId, isGateway, repeats, + splitTimerJobSettings, } from './ir/types.js'; import { CATCH_EVENT_PREFIX, claimDeclarationName, - END_EVENT_PREFIX, - START_EVENT_PREFIX, + ID_SHAPED, + isMintedEndId, + isMintedStartId, + isWritableName, + mintPrintableName, + resolveCollision, THROW_EVENT_PREFIX, } from './synthesize-ids.js'; import { analyzeCfg, type CfgAnalysis } from './cfg-analysis.js'; -import { parseJuel, renderRawFallback } from './juel.js'; +import { + escapeQuoted, + type JuelNode, + parseJuel, + renderRawFallback, +} from './juel.js'; /** Reused by `xml-to-ir.ts` to dedent a preview built by wrapping a fragment in a throwaway process. */ export const INDENT = ' '; @@ -81,12 +92,30 @@ export const INDENT = ' '; */ type Lines = string[]; -/** Built by {@link codeDeclarations}: the name a raised code is written under, per kind. */ -interface CodeNames { +/** + * The names one print writes: the name a raised code is written under, per + * kind ({@link codeDeclarations}), and the name an id the script cannot spell + * prints as ({@link printedNames}). + */ +interface PrintNames { error: Map; escalation: Map; + /** Only the ids that print under another name; every other id prints as itself. */ + printed: ReadonlyMap; +} + +/** The name `id` prints under, at every site that writes an element or process id. */ +function nameOf(names: PrintNames, id: string): string { + return names.printed.get(id) ?? id; } +/** + * What the start and end predicates read of a container: its id, which seeds + * the minted terminals, and its elements, for the boundaries whose escape + * chains end in a minted end and for the first plain start. + */ +export type PrintContainer = Pick; + export type PrintWarningCategory = | 'label' | 'documentation' @@ -95,6 +124,7 @@ export type PrintWarningCategory = | 'degradedSplit' | 'droppedCondition' | 'refusedStatement' + | 'renamedId' | 'droppedSetting'; /** @@ -115,19 +145,23 @@ export function irToDsl(process: BpmnProcess): { warnings: PrintWarning[]; } { const warnings: PrintWarning[] = []; + const codes = codeDeclarations(process); + const names: PrintNames = { + ...codes.names, + printed: printedNames(process, warnings), + }; warnGatewayText(process, warnings); - warnRefusedStatements(process, warnings); + warnRefusedStatements(process, names, warnings); - const codes = codeDeclarations(process); - const emitter = new Emitter(process, warnings, codes.names); + const emitter = new Emitter(process, warnings, names); const body = emitter.emit(); const declarations = [ ...codes.lines.map((line) => INDENT + line), - ...collectionDecls(process), + ...variableDecls(process), ]; - const header = buildProcessHeader(process); + const header = buildProcessHeader(process, names); const lines = [header, ...declarations, ...body.map((l) => INDENT + l), '}']; return { source: lines.join('\n') + '\n', warnings }; } @@ -190,18 +224,19 @@ function warnGatewayText( */ function warnRefusedStatements( container: FlowContainer, + names: PrintNames, warnings: PrintWarning[], suppressed = false, ): void { for (const el of container.flowElements) { if ( - !isElidedOnPrint(el, container.flowElements, suppressed) && - isReservedName(el.id) + !isElidedOnPrint(el, container, suppressed) && + isReservedName(nameOf(names, el.id), container.id) ) { warnings.push(reservedNameWarning(el.id)); } if (el.kind === 'subProcess') { - warnRefusedStatements(el, warnings, el.triggeredByEvent === true); + warnRefusedStatements(el, names, warnings, el.triggeredByEvent === true); } } } @@ -217,6 +252,57 @@ function reservedNameWarning(elementId: string): PrintWarning { }; } +/** + * The name each id prints under where the script cannot spell the id itself: + * the process id and every element that writes its id as a statement name or + * a `goto` target. A gateway, a boundary and an event sub-process never write + * theirs, so they keep it and draw no report. A minted name is resolved + * against every id in the document and every name minted before it, since a + * name written twice leaves every jump to it ambiguous. + * + * The rebuilt document carries the new id, and the engine keys history + * (`HistoricActivityInstance.getActivityId`), migration plans + * (`MigrationPlanBuilder.mapActivities`) and a modification + * (`InstantiationBuilder.startBeforeActivity`) on the activity id, so the + * report says the run is not the same. + */ +function printedNames( + process: BpmnProcess, + warnings: PrintWarning[], +): ReadonlyMap { + const ids = [process.id]; + const taken = new Set(ids); + const collect = (container: FlowContainer): void => { + for (const el of container.flowElements) { + taken.add(el.id); + if (!isGateway(el) && !isBoundary(el) && !isHandler(el)) ids.push(el.id); + if (el.kind === 'subProcess') collect(el); + } + }; + collect(process); + + const printed = new Map(); + for (const id of ids) { + if (isWritableName(id)) continue; + const name = resolveCollision(mintPrintableName(id), taken); + taken.add(name); + printed.set(id, name); + warnings.push({ + elementId: id, + category: 'renamedId', + message: + 'The id this element carries in the model is not a name the script ' + + "can spell (letters, digits and '_', a '-' between them, and no " + + `keyword), so it is written as '${name}'. The document built from ` + + 'the script carries that id in its place, and the engine matches ' + + 'history, migration plans and a start-before-activity on the ' + + 'activity id, so what runs is not the same. Rename it in the ' + + 'model to keep the id.', + }); + } + return printed; +} + /** * One per container. A sub-process's body lives in the child container's * arrays, so the parent's CFG never sees inside it and treats the sub-process @@ -232,6 +318,15 @@ class Emitter { private readonly consumedFlows = new Set(); /** The gateways whose job settings a statement head has printed. */ private readonly settingsTaken = new Set(); + /** + * The `await`/`parallel` branch each statement was printed in, as the path + * of branch ids from the outermost block down, and `''` outside any. A jump + * remembers the path it was printed under, and the two are compared once + * the walk is done, since a jump can name a statement printed after it. + */ + private readonly branchOf = new Map(); + private branchPath = ''; + private readonly jumps: { target: string; branchPath: string }[] = []; private readonly deferredEnds: { lines: string[]; index: number; @@ -243,8 +338,8 @@ class Emitter { private readonly container: FlowContainer, /** Shared with every nested container, so one process yields one report. */ private readonly warnings: PrintWarning[], - /** The header's declarations, which every use site in the body refers to by name. */ - private readonly codeNames: CodeNames, + /** The header's declarations and the printed names, which every use site in the body writes. */ + private readonly names: PrintNames, /** * An event sub-process's start prints its trigger in the `on` header, so * the start statement inside the body prints without one. @@ -280,17 +375,14 @@ class Emitter { const lines: string[] = []; // 1. Structured emission from each start event. An elided start is - // re-derived by the compiler only at the body's head, so its chain is - // walked first; anywhere else it lands after another chain's `end` as a - // dangling `goto` and the compiler re-derives no start for it. + // re-derived by the compiler only at the head of a body that opens with + // no `start`, so its chain is walked first and alone; anywhere else it + // lands after another chain's `end` as a dangling `goto` and the compiler + // re-derives no start for it. for (const el of this.container.flowElements) { if ( el.kind === 'startEvent' && - isElidedOnPrint( - el, - this.container.flowElements, - this.startTriggerSuppressed, - ) && + isElidedOnPrint(el, this.container, this.startTriggerSuppressed) && !this.emittedNodes.has(el.id) ) { this.emitFrom(el.id, undefined, lines, 0); @@ -298,18 +390,22 @@ class Emitter { } for (const el of this.container.flowElements) { if (el.kind === 'startEvent' && !this.emittedNodes.has(el.id)) { - this.emitFrom(el.id, undefined, lines, 0); + this.emitStartGroup(el, lines); } } // 2. Boundary handlers, held back because the surface requires a handler - // block to follow the body it guards. - const boundaryLines: string[] = []; - for (const el of this.container.flowElements) { + // block to follow the body it guards. Each block is kept under its + // element's index so pass 5 can place it among the event sub-processes + // the way the model orders them. + const boundaryBlocks = new Map(); + this.container.flowElements.forEach((el, index) => { if (isBoundary(el) && !this.emittedNodes.has(el.id)) { - this.emitBoundaryHandler(el, boundaryLines, 0); + const block: string[] = []; + this.emitBoundaryHandler(el, block, 0); + boundaryBlocks.set(index, block); } - } + }); // 3. Orphaned fragments. for (const el of this.container.flowElements) { @@ -342,13 +438,16 @@ class Emitter { } for (const id of printedEnds) this.warnings.push(reservedNameWarning(id)); - // 5. Trailing handler group: boundary blocks, then event sub-processes. - for (const l of boundaryLines) lines.push(l); - for (const el of this.container.flowElements) { + // 5. Trailing handler group, in model order: the compiler lays handlers + // down in statement order and numbers an event sub-process by its + // statement index, so any other order renumbers it on the next compile. + this.container.flowElements.forEach((el, index) => { + const block = boundaryBlocks.get(index); + if (block !== undefined) lines.push(...block); if (isHandler(el) && !this.emittedNodes.has(el.id)) { this.emitHandler(el, lines); } - } + }); // Swept once the walk is done rather than reported where a construct gives // up: an elided pass-through and a jump forwarded through a gateway leave @@ -364,9 +463,61 @@ class Emitter { } } + // The validator refuses a `goto` into a branch of an `await` or `parallel` + // block unless the jump sits inside that branch, however deep, which is + // what the path prefix asks. + for (const { target, branchPath } of this.jumps) { + const inside = this.branchOf.get(target); + if ( + inside !== undefined && + inside !== '' && + !`${branchPath}/`.startsWith(`${inside}/`) + ) { + this.warnings.push(crossBranchJumpWarning(target)); + } + } + return lines; } + /** + * Every unprinted start leaving for the same step as `start` prints with + * it, back to back, and that step's chain is walked once under them: starts + * written back to back all enter the statement after them. Walked one at a + * time, the second start would find the step printed and jump onto it, + * which a jump cannot do for an elided end, and a `start` after the first + * chain would leave that chain's elided end off its block's tail. A start + * leaving on several routes is a split of its own and prints as one. + */ + private emitStartGroup( + start: Extract, + lines: string[], + ): void { + const route = this.soleRoute(start.id); + if (route === undefined) { + this.emitFrom(start.id, undefined, lines, 0); + return; + } + for (const el of this.container.flowElements) { + if (el.kind !== 'startEvent' || this.emittedNodes.has(el.id)) continue; + const own = this.soleRoute(el.id); + if (own === undefined || own.targetRef !== route.targetRef) continue; + this.emittedNodes.add(el.id); + this.branchOf.set(el.id, this.branchPath); + lines.push( + ...renderStartEvent(el, this.startTriggerSuppressed, this.names), + ); + this.consume(own); + } + this.emitFrom(route.targetRef, undefined, lines, 0); + } + + /** The one route the model gives a node, or `undefined` where it splits or ends. */ + private soleRoute(id: string): SequenceFlow | undefined { + const outs = this.outgoingBySource.get(id) ?? []; + return outs.length === 1 ? outs[0] : undefined; + } + /** * Nothing for a step, or for a gateway a head has already taken: a loop * hands its leftover routes to the choice chain under its own id. @@ -421,11 +572,11 @@ class Emitter { lines: string[], ): void { this.emittedNodes.add(handler.id); - lines.push(...buildOnHeader(handler, this.codeNames)); + lines.push(...buildOnHeader(handler, this.names)); for (const l of new Emitter( handler, this.warnings, - this.codeNames, + this.names, true, ).emit()) lines.push(INDENT + l); @@ -445,7 +596,7 @@ class Emitter { depth: number, ): void { this.emittedNodes.add(boundary.id); - lines.push(...buildBoundaryHeader(boundary, this.codeNames)); + lines.push(...buildBoundaryHeader(boundary, this.names)); const body: string[] = []; const next = this.followLinear(boundary.id, undefined, body, depth); if (next !== STOP) this.emitFrom(next, undefined, body, depth); @@ -491,12 +642,21 @@ class Emitter { ): string | typeof STOP { const el = this.byId.get(id); if (el === undefined) return STOP; + this.branchOf.set(id, this.branchPath); // A do-while body entry is reached before its loop gateway, so it has to be // recognized here or the body prints ahead of the loop and degrades. const doWhile = this.tryDoWhileEntry(id, stop, lines, depth); if (doWhile !== undefined) return doWhile; + // Read off the model's routes: a construct hands its leftover routes down + // as an empty list without the gateway being empty. + if (isGateway(el) && (this.outgoingBySource.get(id) ?? []).length === 0) { + this.emittedNodes.add(id); + this.warnings.push(emptySplitWarning(el.kind, id)); + return STOP; + } + // A gateway has no statement form to jump from, so it cannot rely on the // final sweep: every out-edge is captured here, by a construct or a goto. if (el.kind === 'exclusiveGateway') { @@ -506,7 +666,7 @@ class Emitter { return this.emitChoice(el.id, stop, lines, depth); } if (el.kind === 'parallelGateway' || el.kind === 'inclusiveGateway') { - return this.emitForkGateway(el, stop, lines, depth); + return this.emitFork(el.id, el.kind, stop, lines, depth); } if (el.kind === 'eventBasedGateway') { return this.emitRaceGateway(el, stop, lines, depth); @@ -515,27 +675,29 @@ class Emitter { // A fenced body is opaque multi-line text, so it prints as a line group. if (el.kind === 'scriptTask') { this.emittedNodes.add(id); - lines.push(...renderScriptTask(el)); + lines.push(...renderScriptTask(el, this.names)); return this.followLinear(id, stop, lines, depth); } if (el.kind === 'subProcess') { - // An event sub-process prints in the trailing handler pass. Reaching one - // through a flow edge means malformed IR, so print it here and stop. + // An event sub-process prints in the trailing handler pass and is + // entered by its trigger alone; the import refuses a flow into one, so + // reaching it here is malformed IR, as a duplicate id is. if (el.triggeredByEvent === true) { - this.emitHandler(el, lines); - return STOP; + throw new Error( + `irToDsl: event sub-process '${id}' in container '${this.container.id}' is reached through a flow edge; a handler is entered by its trigger alone.`, + ); } this.emittedNodes.add(id); const head = el.element === 'transaction' ? 'attempt' : 'subprocess'; lines.push( ...bodyHeader( - `${head} ${id}${repeatClause(el)}`, + `${head} ${nameOf(this.names, id)}${repeatClause(el)}`, [...namedSettings(el), ...engineSettings(el)], structuredMembers(el), ), ); - for (const l of new Emitter(el, this.warnings, this.codeNames).emit()) + for (const l of new Emitter(el, this.warnings, this.names).emit()) lines.push(INDENT + l); lines.push('}'); return this.followLinear(id, stop, lines, depth); @@ -554,7 +716,7 @@ class Emitter { stmt !== undefined && el.kind === 'endEvent' && el.eventDefinition === undefined && - isElidedOnPrint(el, this.container.flowElements) + isElidedOnPrint(el, this.container) ) { // The compiler never mints a synthesized end inside a branch or loop // body, whose exit it wires to the join or loop head, so one there @@ -573,9 +735,13 @@ class Emitter { /** * The routes leaving a statement. One is the fall-through the next statement - * takes; more than one prints as a choice, because a statement is a single - * position with a single way on, so a jump written for the second route would - * end the block and cut the first route's chain off after it. + * takes. More than one is a fork with the statement as its split: + * `BpmnActivityBehavior.performOutgoingBehavior` leaves by every route whose + * condition holds or that carries none and by the `default` alone when none + * was taken, which is the inclusive fork's rule, and with nothing weighed + * and no fallback it is the parallel fork's. One weighed route beside the + * fallback is the shape a choice routes the same way, and keeps the + * `if`/`else` the choice prints. * * What splits is read off the model rather than off the routes left to print: * a route an enclosing loop has already printed as its closing brace is one @@ -588,8 +754,19 @@ class Emitter { depth: number, ): string | typeof STOP { const outs = this.outgoingBySource.get(id) ?? []; - if (outs.length > 1) this.warnings.push(implicitSplitWarning(id)); - return this.emitChoice(id, stop, lines, depth); + if (outs.length <= 1) return this.emitChoice(id, stop, lines, depth); + const fallbackId = this.splitFallbackFlowId(id); + const weighed = outs.filter( + (f) => f.id !== fallbackId && f.conditionExpression !== undefined, + ); + if (outs.length === 2 && fallbackId !== undefined && weighed.length === 1) { + return this.emitChoice(id, stop, lines, depth); + } + const kind = + weighed.length > 0 || fallbackId !== undefined + ? 'inclusiveGateway' + : 'parallelGateway'; + return this.emitFork(id, kind, stop, lines, depth); } /** @@ -629,14 +806,12 @@ class Emitter { depth: number, ): string | typeof STOP { // A choice does read a condition written on the route taken when no other - // holds, and the engine refuses the model at deployment for carrying one. - // Asked of the routes the model gives the split, ahead of the two shapes - // below that print without a chain. - const fallbackId = this.splitFallbackFlowId(splitId); - const named = this.splitFallbackFlow(splitId); - if (named?.conditionExpression !== undefined) { - this.warnings.push(choiceFallbackConditionWarning(splitId)); - } + // holds, and the engine refuses the model at deployment for carrying one; + // a step skips its fallback while weighing and takes it when nothing held, + // so there the condition is weighed nowhere. Asked of the routes the model + // gives the split, ahead of the two shapes below that print without a + // chain. + this.warnFallbackCondition(splitId); if (outs.length === 0) return STOP; if (outs.length === 1) { @@ -644,23 +819,17 @@ class Emitter { return this.takeFallThrough(outs[0]!, stop, lines); } - // The fallback is the chain's `else` whatever else it carries: heading its - // branch with the condition would put the branch on a run of its own and - // leave the split with nowhere to go when nothing holds. - const fallback = outs.find((f) => f.id === fallbackId); - - // Desugared IR has at most one unconditioned flow. Imported IR may carry more. - const conditioned = outs.filter( - (f) => f !== fallback && f.conditionExpression !== undefined, - ); - const unconditioned = outs.filter( - (f) => f === fallback || f.conditionExpression === undefined, + const { fallback, conditioned, unconditioned } = this.weighRoutes( + splitId, + outs, ); this.warnInventedFallback(splitId); const join = - this.cleanJoin(splitId, outs) ?? - this.guardClauseContinuation(unconditioned); + this.cleanJoin(splitId, outs, stop) ?? + this.convergence(splitId, outs, stop) ?? + this.guardClauseContinuation(unconditioned) ?? + this.enclosingContinuation(splitId, outs, stop); for (const f of outs) this.consumedFlows.add(f.id); this.emitIfChain( @@ -737,28 +906,84 @@ class Emitter { } /** - * A split the catalog cannot fold, every route leaving as a jump. Each jump - * takes a branch of its own because a jump ends its block, so a second one - * written beside the first could never run. Nothing weighs the branches: the - * conditions go with the split, which {@link degradedSplitWarning} reports, - * and so do its settings, which the sweep reports. + * The routes of a split sorted the way the chain and the fork block read + * them. The fallback is the `else` whatever else it carries: heading its + * branch with the condition would put the branch on a run of its own and + * leave the split with nowhere to go when nothing holds. Desugared IR has at + * most one unconditioned flow; imported IR may carry more. + */ + private weighRoutes( + splitId: string, + outs: SequenceFlow[], + ): { + fallback: SequenceFlow | undefined; + conditioned: SequenceFlow[]; + unconditioned: SequenceFlow[]; + } { + const fallbackId = this.splitFallbackFlowId(splitId); + const fallback = outs.find((f) => f.id === fallbackId); + return { + fallback, + conditioned: outs.filter( + (f) => f !== fallback && f.conditionExpression !== undefined, + ), + unconditioned: outs.filter( + (f) => f === fallback || f.conditionExpression === undefined, + ), + }; + } + + /** + * A fork or a race the catalog cannot fold, every route leaving as a jump + * under the condition it carried. Each jump takes a branch of its own because + * a jump ends its block, so a second one written beside the first could never + * run. The chain keeps the conditions and loses the split's kind, which the + * marker names and {@link degradedSplitWarning} reports; the settings go + * with it, which the sweep reports. */ private emitJumps( splitId: string, + kind: Gateway['kind'], outs: SequenceFlow[], lines: string[], depth: number, ): void { - this.emitIfChain([], outs, undefined, undefined, splitId, [], lines, depth); + lines.push(degradedSplitMarker(splitId, kind)); + const { fallback, conditioned, unconditioned } = this.weighRoutes( + splitId, + outs, + ); + this.emitIfChain( + conditioned, + unconditioned, + fallback, + undefined, + splitId, + [], + lines, + depth, + ); } /** * The checks below establish that the branch region belongs to this gateway * and re-enters at the join. `undefined` means the gateway is unstructured. + * + * A join past `stop` is refused: inside a loop body the body dominates + * everything after the loop, so a split whose routes leave the loop and + * reconverge behind it would pass the dominance checks, walk the staying + * route past the printed loop head and drop the back edge. */ - private cleanJoin(splitId: string, outs: SequenceFlow[]): string | undefined { + private cleanJoin( + splitId: string, + outs: SequenceFlow[], + stop: string | undefined, + ): string | undefined { const join = this.cfg.immediatePostDominator(splitId); if (join === undefined || !this.byId.has(join)) return undefined; + if (stop !== undefined && !this.cfg.postDominates(stop, join)) { + return undefined; + } if (!this.cfg.postDominates(join, splitId)) return undefined; if (!this.cfg.dominates(splitId, join)) return undefined; for (const f of outs) { @@ -768,6 +993,92 @@ class Emitter { return join; } + /** + * Where the routes come back together when the post-dominator queries + * cannot say: the nearest node every live route reaches, read off the + * model's routes and bounded by `stop`. A branch that can end puts the + * split's post-dominator at the exit, and a step that loops on itself + * leaves the container with no post-dominators at all, so the clean join + * and the survivors' chains both miss a merge the routes plainly share, and + * a branch walked past it prints the continuation inside the block. + * + * A route straight into a sink is a branch that ends and never counts. Of + * the rest, a route is live when it reaches `stop` or a node another route + * reaches; where none does, every one is. Among the nodes the live routes + * share, in the first one's breadth order: a merge, a gateway with one + * route out, ahead of a step on the way to it, since the compiler lowers + * every block with a merge and a step ahead of it belongs to the branch; + * then `stop`; then the nearest, except for a lone live route heading a + * branch with its condition, whose whole chain is that branch and which + * continues where the chain ends. `joinKind` narrows the answer to a merge + * of that kind the split dominates, the only node a fork or a race + * synchronizes at: a merge entered from outside the block as well would + * synchronize that entry too once the block's own join stands for it. + */ + private convergence( + splitId: string, + outs: readonly SequenceFlow[], + stop: string | undefined, + joinKind?: Gateway['kind'], + ): string | undefined { + const routes = outs + .filter((f) => (this.outgoingBySource.get(f.targetRef) ?? []).length > 0) + .map((f) => ({ f, reach: this.reachable(f.targetRef, splitId, stop) })); + const shares = ({ reach }: (typeof routes)[number]): boolean => + [...reach].some( + (n) => + n === stop || routes.some((r) => r.reach !== reach && r.reach.has(n)), + ); + const live = routes.some(shares) ? routes.filter(shares) : routes; + if (live.length === 0) return undefined; + const shared = [...live[0]!.reach].filter((n) => + live.every((r) => r.reach.has(n)), + ); + const isMerge = (n: string): boolean => { + const el = this.byId.get(n); + return ( + el !== undefined && + isGateway(el) && + (this.outgoingBySource.get(n) ?? []).length === 1 && + (joinKind === undefined || + (el.kind === joinKind && this.cfg.dominates(splitId, n))) + ); + }; + const merge = shared.find(isMerge); + if (joinKind !== undefined) return merge; + if (merge !== undefined) return merge; + if (stop !== undefined && shared.includes(stop)) return stop; + const [only] = live; + const weighed = + live.length === 1 && + only!.f.conditionExpression !== undefined && + only!.f.id !== this.splitFallbackFlowId(splitId); + return weighed ? shared.at(-1) : shared[0]; + } + + /** + * The nodes a walk from `from` can reach, in breadth order. `stop` is kept + * and not crossed, and the split is left out: a path back through it runs + * the routing again, which the block already stands for. + */ + private reachable( + from: string, + splitId: string, + stop: string | undefined, + ): Set { + const seen = new Set(); + const queue = [from]; + for (let i = 0; i < queue.length; i++) { + const n = queue[i]!; + if (n === splitId || seen.has(n)) continue; + seen.add(n); + if (n === stop) continue; + for (const f of this.outgoingBySource.get(n) ?? []) + queue.push(f.targetRef); + } + return seen; + } + /** * A guard clause has no clean join: one branch terminates while the else-less * default carries the main flow. Treating that default as the continuation @@ -783,6 +1094,34 @@ class Emitter { return unconditioned.length === 1 ? unconditioned[0]!.targetRef : undefined; } + /** + * A split inside a loop body or a branch whose every route is conditioned: + * a leaving route puts the split's immediate post-dominator outside the + * region (the exit, or a node behind the loop when the leaving route and the + * loop exit share an end), so there is no clean join, and with no + * unconditioned route no guard clause, yet the routes that stay inside run + * into `stop`, which is the continuation. Only the leaving routes print as + * jumps; as jumps throughout, the staying route's steps would be hoisted + * out of the loop and their edge into its folded head dropped. + * + * A route straight into `stop` counts as staying (`postDominates` is + * reflexive) and prints as an empty branch. + */ + private enclosingContinuation( + splitId: string, + outs: SequenceFlow[], + stop: string | undefined, + ): string | undefined { + if (stop === undefined) return undefined; + if (outs.some((f) => f.conditionExpression === undefined)) return undefined; + const stays = outs.some( + (f) => + this.cfg.dominates(splitId, f.targetRef) && + this.cfg.postDominates(stop, f.targetRef), + ); + return stays ? stop : undefined; + } + /** * Walk or `goto` is decided per branch, since branches mix: one can flow back * to the join while another jumps away. @@ -813,14 +1152,16 @@ class Emitter { /** * Whether the entry sits inside `[split, join)` and can be walked inline. - * Three shapes qualify: an ordinary body that re-merges, so `join` + * Four shapes qualify: an ordinary body that re-merges, so `join` * post-dominates `entry`; a guard clause whose entry is a synthesized * terminal the split owns and that terminates before the join, which has no * continuation to relocate and prints the same statement in either scope; - * and a guard clause whose entry is a bare authored end that the split's - * route reaches as its only incoming flow. An authored entry with a chain - * of its own stays a `goto`, keeping that chain at its authored scope so its - * coordinate-derived ids survive the round trip. + * a guard clause whose entry is a bare authored end that the split's route + * reaches as its only incoming flow; and a guard clause whose entry is a + * gateway the split owns, which a jump could not name anyway, and whose + * routes the walk sorts with `join` as their stop node. An authored entry + * with a chain of its own stays a `goto`, keeping that chain at its authored + * scope so its coordinate-derived ids survive the round trip. */ private branchStaysInRegion( entry: string, @@ -836,22 +1177,31 @@ class Emitter { ) { return false; } - if (isSynthesizedTerminalId(entry, el.kind)) return true; + if (isGateway(el)) return true; + if (isSynthesizedTerminalId(entry, el.kind, this.container)) return true; return el.kind === 'endEvent' && this.cfg.incoming(entry).length === 1; } - /** With no `join` the body runs to its own end, which is how a race with no merge prints. */ + /** + * With no `join` the body runs to its own end, which is how a race with no + * merge prints. `branch` is the id of the fork or race route this body + * prints under; a loop body passes none and stays in its enclosing branch. + */ private emitBranch( entry: string, join: string | undefined, lines: string[], depth: number, + branch?: string, ): void { + const outer = this.branchPath; + if (branch !== undefined) this.branchPath = `${outer}/${branch}`; const body: string[] = []; if (entry !== join) { this.emitFrom(entry, join, body, depth + 1); } for (const l of body) lines.push(INDENT + l); + this.branchPath = outer; } /** The post-loop continuation, or `undefined` when the pattern misses. */ @@ -864,9 +1214,13 @@ class Emitter { if (loop.kind !== 'exclusiveGateway') return undefined; // Unconditioned mirrors `tryDoWhileEntry`'s conditioned requirement, so the - // two patterns never both fire. + // two patterns never both fire. A route from the head back into itself is + // no body: matched here it would print the exit route as the body. const backEdge = this.unconsumed(this.cfg.backEdges()).find( - (f) => f.targetRef === loop.id && f.conditionExpression === undefined, + (f) => + f.targetRef === loop.id && + f.sourceRef !== loop.id && + f.conditionExpression === undefined, ); if (backEdge === undefined) return undefined; @@ -903,7 +1257,8 @@ class Emitter { depth: number, ): string | typeof STOP | undefined { const backEdge = this.unconsumed(this.cfg.backEdges()).find((f) => { - if (f.targetRef !== node) return false; + // A route back into the head itself has no body to run before the test. + if (f.targetRef !== node || f.sourceRef === node) return false; if (f.conditionExpression === undefined) return false; const head = this.byId.get(f.sourceRef); return ( @@ -951,15 +1306,26 @@ class Emitter { * A fork whose branches carry conditions prints the same block with a head on * each branch. One rule reads it back: a condition on any branch means the * branches are weighed one by one, none anywhere means they all run. + * + * Keyed on the split's id and kind rather than on a gateway: a step whose + * own routes split reaches the same block with the kind its routes give it, + * and its head settings are none. */ - private emitForkGateway( - fork: ForkGateway, + private emitFork( + splitId: string, + kind: ForkKind, stop: string | undefined, lines: string[], depth: number, ): string | typeof STOP { - this.emittedNodes.add(fork.id); - const outs = this.unconsumedOut(fork.id); + this.emittedNodes.add(splitId); + const fork: Fork = { + id: splitId, + kind, + fallbackId: this.splitFallbackFlowId(splitId), + }; + this.warnFallbackCondition(splitId); + const outs = this.unconsumedOut(splitId); if (outs.length === 0) return STOP; if (outs.length === 1) { @@ -967,19 +1333,19 @@ class Emitter { } const join = - this.cleanForkJoin(fork.id, outs, fork.kind) ?? - this.recoveredForkJoin(fork.id, outs, fork.kind); + this.cleanForkJoin(splitId, outs, kind) ?? + this.convergence(splitId, outs, stop, kind) ?? + this.recoveredForkJoin(splitId, outs, kind); for (const f of outs) this.consumedFlows.add(f.id); if (join === undefined) { - this.warnings.push(degradedSplitWarning(fork.id)); - this.emitJumps(fork.id, outs, lines, depth); + this.warnings.push(degradedSplitWarning(splitId)); + this.emitJumps(splitId, kind, outs, lines, depth); return STOP; } - this.warnInventedFallback(fork.id); - this.warnFallbackCondition(fork, outs); + this.warnInventedFallback(splitId); this.warnUnweighedBranchCondition(fork, outs); // The fallback running straight into the merge is the one the reader gets @@ -993,14 +1359,14 @@ class Emitter { // The join is continued from, never pre-elided: a one-out parallel join is // a transparent pass-through in `emitNode`. const settings = [ - ...this.takeHeadSettings(fork.id), + ...this.takeHeadSettings(splitId), ...this.takeJoinSettings(join), ]; lines.push(`parallel${headSettings(settings)} {`); branches.forEach((f) => { // `emitBranch` prefixes one INDENT; wrap and re-indent for `parallel {`. const branchLines: string[] = []; - this.emitBranch(f.targetRef, join, branchLines, depth); + this.emitBranch(f.targetRef, join, branchLines, depth, f.id); lines.push(INDENT + this.branchHead(fork, f) + '{'); for (const l of branchLines) lines.push(INDENT + l); lines.push(INDENT + '}'); @@ -1017,9 +1383,9 @@ class Emitter { * condition would put the branch back on a run of its own. A fork that weighs * nothing heads no branch at all. */ - private branchHead(fork: ForkGateway, flow: SequenceFlow): string { + private branchHead(fork: Fork, flow: SequenceFlow): string { if (fork.kind !== 'inclusiveGateway') return ''; - if (flow.id === fork.defaultFlowId) return 'else '; + if (flow.id === fork.fallbackId) return 'else '; return flow.conditionExpression === undefined ? '' : `if (${renderCondition(flow)}) `; @@ -1031,13 +1397,13 @@ class Emitter { * the same; {@link warnFallbackCondition} reports the condition either way. */ private isImplicitFallback( - fork: ForkGateway, + fork: Fork, flow: SequenceFlow, join: string, ): boolean { return ( fork.kind === 'inclusiveGateway' && - flow.id === fork.defaultFlowId && + flow.id === fork.fallbackId && flow.targetRef === join ); } @@ -1052,37 +1418,27 @@ class Emitter { * Read off the model rather than from the caller's list: the engine weighs * every route the element has, so one an enclosing construct has already * printed keeps the element from running out of routes all the same. - * - * A split names the route it takes there and the IR carries that name, so the - * report can say the model named none. A step names it in BPMN and the IR - * does not carry it, so {@link inventedStepFallbackWarning} reports the same - * fall-through without claiming what the model named. */ private warnInventedFallback(splitId: string): void { // A split that takes every route whatever the conditions say never runs out // of routes, so there is no failure here for a fall-through to paper over. - const el = this.byId.get(splitId); - if (el?.kind === 'parallelGateway') return; + if (this.byId.get(splitId)?.kind === 'parallelGateway') return; if (this.splitFallbackFlowId(splitId) !== undefined) return; const outs = this.outgoingBySource.get(splitId) ?? []; if (outs.some((f) => f.conditionExpression === undefined)) return; - this.warnings.push( - el !== undefined && isGateway(el) - ? inventedFallbackWarning(splitId) - : inventedStepFallbackWarning(splitId), - ); + this.warnings.push(inventedFallbackWarning(splitId)); } /** - * The route a split takes when no condition holds, for one that names it. - * The IR carries it on a split alone, so a step answers `undefined` whatever - * its BPMN said, and so do the split kinds that read no condition. + * The route a split takes when no condition holds, for one that names it: + * the two split kinds that read a condition, and every step, whose `default` + * `BpmnActivityBehavior.handleNoTransitions` takes. */ private splitFallbackFlowId(splitId: string): string | undefined { const el = this.byId.get(splitId); - return el !== undefined && isGateway(el) - ? gatewayDefaultFlowId(el) - : undefined; + if (el === undefined) return undefined; + if (isGateway(el)) return gatewayDefaultFlowId(el); + return 'defaultFlowId' in el ? el.defaultFlowId : undefined; } /** @@ -1110,9 +1466,9 @@ class Emitter { * out the fallback going nowhere but the merge: that one leaves no `else` * behind and so nothing to report. */ - private warnDeadFallback(fork: ForkGateway, branches: SequenceFlow[]): void { + private warnDeadFallback(fork: Fork, branches: SequenceFlow[]): void { if (fork.kind !== 'inclusiveGateway') return; - const fallback = branches.find((f) => f.id === fork.defaultFlowId); + const fallback = branches.find((f) => f.id === fork.fallbackId); if (fallback === undefined) return; const alwaysRuns = branches.some( (f) => f !== fallback && f.conditionExpression === undefined, @@ -1122,19 +1478,22 @@ class Emitter { } /** - * A condition on the fallback itself. A fork weighs its branches and takes - * the fallback only when it took none of them, so the condition on the - * fallback is weighed nowhere and {@link branchHead} prints the branch as the - * fallback alone. The condition is dropped there, and reported here. - * - * Reads the fork's own edges rather than the branches that print, so the - * fallback {@link isImplicitFallback} leaves out is covered too. + * A condition on the fallback itself, reported once per split off the + * model's routes, whatever the fallback ends up printed as: the `else` of a + * chain or a block, the plain route on, or nothing. A choice weighs its + * fallback among the others and the engine refuses to deploy one carrying a + * condition there; a fork and a step skip it while weighing and take it when + * nothing held, so the condition is weighed nowhere and the run is the same. */ - private warnFallbackCondition(fork: ForkGateway, outs: SequenceFlow[]): void { - if (fork.kind !== 'inclusiveGateway') return; - const fallback = outs.find((f) => f.id === fork.defaultFlowId); - if (fallback?.conditionExpression === undefined) return; - this.warnings.push(forkFallbackConditionWarning(fork.id)); + private warnFallbackCondition(splitId: string): void { + if (this.splitFallbackFlow(splitId)?.conditionExpression === undefined) { + return; + } + this.warnings.push( + this.byId.get(splitId)?.kind === 'exclusiveGateway' + ? choiceFallbackConditionWarning(splitId) + : forkFallbackConditionWarning(splitId), + ); } /** @@ -1144,10 +1503,7 @@ class Emitter { * branches from one that does not: printing it would read back as the other * fork. The condition is dropped there, and reported here. */ - private warnUnweighedBranchCondition( - fork: ForkGateway, - outs: SequenceFlow[], - ): void { + private warnUnweighedBranchCondition(fork: Fork, outs: SequenceFlow[]): void { if (fork.kind !== 'parallelGateway') return; if (!outs.some((f) => f.conditionExpression !== undefined)) return; this.warnings.push(unweighedBranchWarning(fork.id)); @@ -1159,7 +1515,7 @@ class Emitter { outs: SequenceFlow[], joinKind: Gateway['kind'], ): string | undefined { - const join = this.cleanJoin(forkId, outs); + const join = this.cleanJoin(forkId, outs, undefined); if (join === undefined) return undefined; return this.byId.get(join)?.kind === joinKind ? join : undefined; } @@ -1232,12 +1588,12 @@ class Emitter { if (waits.some((w) => w === undefined)) { this.warnings.push(degradedSplitWarning(race.id)); - this.emitJumps(race.id, outs, lines, depth); + this.emitJumps(race.id, race.kind, outs, lines, depth); return STOP; } // Below the degradation above, whose own report already covers the - // conditions lost with the split it could not print. + // conditions the jumps carry for the split it could not print. if (outs.some((f) => f.conditionExpression !== undefined)) { this.warnings.push(raceConditionWarning(race.id)); } @@ -1245,6 +1601,7 @@ class Emitter { // An XOR merge: exactly one branch of a race ever runs. const join = this.cleanForkJoin(race.id, outs, 'exclusiveGateway') ?? + this.convergence(race.id, outs, stop, 'exclusiveGateway') ?? this.recoveredForkJoin(race.id, outs, 'exclusiveGateway'); const settings = [ @@ -1252,7 +1609,7 @@ class Emitter { ...this.takeJoinSettings(join), ]; lines.push(`await${headSettings(settings)} {`); - for (const wait of waits) { + waits.forEach((wait, i) => { const { el, body } = wait!; this.emittedNodes.add(el.id); const branchLines: string[] = []; @@ -1260,9 +1617,9 @@ class Emitter { // The wait leads straight into its body with nothing written between // them, so the edge goes the way a fall-through does. this.consume(body); - this.emitBranch(body.targetRef, join, branchLines, depth); + this.emitBranch(body.targetRef, join, branchLines, depth, outs[i]!.id); } - const trigger = renderTrigger(el.eventDefinition, this.codeNames); + const trigger = renderTrigger(el.eventDefinition, this.names); const head = bodyHeader( trigger.head, [...trigger.items, ...jobSettingItems(el)], @@ -1271,7 +1628,7 @@ class Emitter { for (const l of head) lines.push(INDENT + l); for (const l of branchLines) lines.push(INDENT + l); lines.push(INDENT + '}'); - } + }); lines.push('}'); return join === undefined ? STOP : this.continueAt(join, stop, lines); @@ -1322,9 +1679,8 @@ class Emitter { * weigh it nowhere, so the run is the same without it; everywhere else the * engine reads it and the run is not. * - * The route a split names as its fallback is neither: a fork keeps it out of - * the routes it weighs, and a choice carrying a condition there is refused at - * deployment, so the drop reads as the fallback it is. + * The route a split names as its fallback is neither: the split's own report + * ({@link warnFallbackCondition}) covers it however it prints. * * A route beside a fallback the split weighs nothing on is neither again: the * model has that fallback left to leave by, so the drop reads as a divergence @@ -1335,11 +1691,7 @@ class Emitter { ): PrintWarning | undefined { const sourceId = flow.sourceRef; const kind = this.byId.get(sourceId)?.kind; - if (flow.id === this.splitFallbackFlowId(sourceId)) { - return kind === 'inclusiveGateway' - ? forkFallbackConditionWarning(sourceId) - : undefined; - } + if (flow.id === this.splitFallbackFlowId(sourceId)) return undefined; switch (kind) { case 'parallelGateway': return unweighedBranchWarning(sourceId); @@ -1385,7 +1737,8 @@ class Emitter { private pushGoto(target: string, lines: string[]): void { const real = this.forwardToRealTarget(target, new Set()); if (real !== undefined) { - lines.push(`goto ${real}`); + lines.push(`goto ${nameOf(this.names, real)}`); + this.jumps.push({ target: real, branchPath: this.branchPath }); return; } lines.push(droppedEdgeMarker(target)); @@ -1411,9 +1764,7 @@ class Emitter { const el = this.byId.get(target); if (el === undefined) return target; if (!isGateway(el)) { - return isElidedOnPrint(el, this.container.flowElements) - ? undefined - : target; + return isElidedOnPrint(el, this.container) ? undefined : target; } if (seen.has(target)) return undefined; seen.add(target); @@ -1426,41 +1777,47 @@ class Emitter { return this.forwardToRealTarget(forward.targetRef, seen); } + /** + * Omitted for a synthesized id: the forward compiler re-derives the same + * `Throw_...`/`Catch_...` from the statement's coordinate, so dropping it is + * lossless. + */ + private terminalNameSuffix( + el: Extract< + FlowElement, + { kind: 'endEvent' | 'intermediateThrowEvent' | 'intermediateCatchEvent' } + >, + ): string { + return isSynthesizedTerminalId(el.id, el.kind, this.container) + ? '' + : ` ${nameOf(this.names, el.id)}`; + } + /** `undefined` when the element has no statement form. */ private renderStatement(el: FlowElement): Lines | undefined { switch (el.kind) { case 'startEvent': - if ( - isElidedOnPrint( - el, - this.container.flowElements, - this.startTriggerSuppressed, - ) - ) { + if (isElidedOnPrint(el, this.container, this.startTriggerSuppressed)) { return undefined; } - return renderStartEvent( - el, - this.startTriggerSuppressed, - this.codeNames, - ); + return renderStartEvent(el, this.startTriggerSuppressed, this.names); case 'endEvent': { const members = startOrEndMembers(el); const definition = el.eventDefinition; if (definition === undefined || isEndCarried(definition)) { const head = definition === undefined ? '' : ` ${definition.kind}`; return bracketed( - `end ${el.id}${head}`, + `end ${nameOf(this.names, el.id)}${head}`, [...namedSettings(el), ...jobSettingItems(el)], members, ); } return renderThrow( - el, definition, + this.terminalNameSuffix(el), [...throwBindingSettings(el), ...jobSettingItems(el)], members, - this.codeNames, + this.names, ); } case 'intermediateThrowEvent': { @@ -1475,9 +1832,9 @@ class Emitter { case 'message': case 'compensation': case 'link': { - const trigger = renderTrigger(def, this.codeNames); + const trigger = renderTrigger(def, this.names); return bracketed( - `emit ${trigger.head}${terminalNameSuffix(el)}`, + `emit ${trigger.head}${this.terminalNameSuffix(el)}`, [...trigger.items, ...settings], structuredMembers(el), ); @@ -1489,27 +1846,27 @@ class Emitter { } } case 'intermediateCatchEvent': { - const trigger = renderTrigger(el.eventDefinition, this.codeNames); + const trigger = renderTrigger(el.eventDefinition, this.names); return bracketed( - `await ${trigger.head}${terminalNameSuffix(el)}`, + `await ${trigger.head}${this.terminalNameSuffix(el)}`, [...trigger.items, ...jobSettingItems(el)], structuredMembers(el), ); } case 'userTask': - return renderUserTask(el); + return renderUserTask(el, this.names); case 'serviceTask': - return renderServiceTask(el, this.codeNames); + return renderServiceTask(el, this.names); case 'task': return bracketed( - `step ${el.id}${repeatClause(el)}`, + `step ${nameOf(this.names, el.id)}${repeatClause(el)}`, [...namedSettings(el), ...engineSettings(el)], structuredMembers(el), ); case 'receiveTask': - return renderReceiveTask(el); + return renderReceiveTask(el, this.names); case 'callActivity': - return renderCallActivity(el); + return renderCallActivity(el, this.names); // These print as a line group in `emitNode`/`emitBoundaryHandler`, and are // listed so the type checker still catches a new kind. case 'scriptTask': @@ -1537,10 +1894,22 @@ class Emitter { const STOP = Symbol('stop'); /** The two kinds a `parallel` block prints: one weighs its branches, one takes them all. */ -type ForkGateway = Extract< - FlowElement, - { kind: 'parallelGateway' | 'inclusiveGateway' } ->; +type ForkKind = 'parallelGateway' | 'inclusiveGateway'; + +/** A split the fork block prints: a gateway of either kind, or a step whose routes give it one. */ +interface Fork { + id: string; + kind: ForkKind; + fallbackId: string | undefined; +} + +/** The word the marker names a lost split by. */ +const SPLIT_KIND_WORD: Record = { + exclusiveGateway: 'exclusive', + parallelGateway: 'parallel', + inclusiveGateway: 'inclusive', + eventBasedGateway: 'event-based', +}; /** One branch of a race: the wait it opens on, and the edge into its body. */ interface RaceWait { @@ -1560,6 +1929,10 @@ function droppedEdgeMarker(target: string): string { return `${UNSTRUCTURED_MARKER} (dropped edge into ${target})`; } +function degradedSplitMarker(splitId: string, kind: Gateway['kind']): string { + return `${UNSTRUCTURED_MARKER} (split ${splitId} degraded to jumps; was ${SPLIT_KIND_WORD[kind]})`; +} + function droppedEdgeWarning(target: string): PrintWarning { return { elementId: target, @@ -1573,10 +1946,10 @@ function droppedEdgeWarning(target: string): PrintWarning { /** * A split whose branches the catalog cannot fold keeps its edges as jumps, - * which carry the routes and lose everything the split said about them. A jump - * leaves no marker behind, so this is the only report of it. An edge with no - * name to jump to takes the marker {@link droppedEdgeWarning} reports instead, - * which is why this one counts no branches. + * each under the condition it carried, and loses the split itself: the marker + * line above the jumps names it. An edge with no name to jump to takes the + * marker {@link droppedEdgeWarning} reports instead, which is why this one + * counts no branches. */ function degradedSplitWarning(splitId: string): PrintWarning { return { @@ -1585,44 +1958,83 @@ function degradedSplitWarning(splitId: string): PrintWarning { message: 'The branches leaving this split have no form in the script, so a ' + 'branch leaves as a jump, or as a marker where it opens on something ' + - 'the script cannot name. The split is lost with them, along with any ' + - 'condition weighing a branch and any wait opening one, and a jump ends ' + - 'the path it sits on, so at most one branch is left running. What they ' + - 'leave behind is where hand-repair starts.', + 'the script cannot name. A condition weighing a branch is written on ' + + 'its jump, but the split is lost: whether it opened one branch, every ' + + 'branch whose condition held, or the first to resolve is written ' + + 'nowhere, and a jump ends the path it sits on, so at most one branch ' + + 'is left running. The marker line above the jumps is where hand-repair ' + + 'starts.', }; } -function inventedFallbackWarning(forkId: string): PrintWarning { +/** + * Where {@link degradedSplitWarning} names a split whose branches the script + * cannot fold, this names one with nothing to fold: the split prints as + * nothing and the run ends where the script's block does. What the model does + * there depends on the kind, and the sentence says which. + */ +function emptySplitWarning(kind: Gateway['kind'], id: string): PrintWarning { + const model = { + // `ParallelGatewayActivityBehavior.execute` leaves by no route, and + // `PvmExecutionImpl.leaveActivityViaTransitions` ends the execution. At + // top level that is the whole run; inside a fork's branch it is that + // branch alone, and the join it never reaches keeps the fork waiting. + parallelGateway: + "The engine ends the run here too, so outside a fork's branch the " + + 'process runs the same without it; inside one, only that branch ends ' + + 'and the fork never completes.', + // `InclusiveGatewayActivityBehavior.execute` throws `stuckExecutionException`. + inclusiveGateway: + 'The engine stops the run with an error here, where the script ends ' + + 'it, so what runs is not the same.', + // `EventBasedGatewayActivityBehavior.execute` is a wait state, and with + // nothing to wait for the instance never leaves it. + eventBasedGateway: + 'The engine waits here forever, where the script ends the run, so what ' + + 'runs is not the same.', + // `BpmnParse.validateExclusiveGateway`: "has no outgoing sequence flows". + exclusiveGateway: + 'The engine refuses to deploy the model as drawn, while the script ' + + 'deploys and ends the run here.', + }[kind]; return { - elementId: forkId, - category: 'defaultFlow', + elementId: id, + category: 'degradedSplit', message: - 'Every branch of this split runs under a condition and the model names ' + - 'no fallback, so the run it describes fails here when none of them ' + - 'holds. The script has no form for that failure and carries on past ' + - 'the branches instead, so what runs changes. Check that carrying on is ' + - 'what was meant.', + `This ${kind === 'eventBasedGateway' ? 'wait' : 'split'} has no route ` + + `out, so it was left out of the script. ${model}`, }; } /** - * The same fall-through at a step, whose own routes split. BPMN lets a step - * name the route to take when none of its conditions holds and the IR carries - * that name on a split alone, so this says what the script does and leaves out - * what the model named. The import reports the name it dropped where there was - * one. + * A jump printed outside the `await`/`parallel` branch its target was printed + * in. The validator refuses it, since a branch's steps run only when the whole + * block is reached, and the model is where the route comes from, so the jump + * is written and reported rather than dropped. */ -function inventedStepFallbackWarning(nodeId: string): PrintWarning { +function crossBranchJumpWarning(target: string): PrintWarning { + return { + elementId: target, + category: 'refusedStatement', + message: + 'A route the model takes into this step crosses the border of a branch ' + + "of an 'await' or 'parallel' block, and the jump written for it draws " + + 'an error when the source is read back: the steps of a branch run only ' + + 'when the whole block is reached. Redraw the route in the model, or ' + + 'move the step out of the branch.', + }; +} + +function inventedFallbackWarning(splitId: string): PrintWarning { return { - elementId: nodeId, + elementId: splitId, category: 'defaultFlow', message: - 'Every route leaving this step runs under a condition, and the script ' + - 'carries on past them when none of them holds, which hands the step a ' + - 'way on that its conditions do not give it. A step in the model can ' + - 'name the route to take there, and this tool carries a fallback on a ' + - 'split alone, so what the model named is not visible here. Check that ' + - 'carrying on is what was meant.', + 'Every route leaving here runs under a condition and the model names ' + + 'no fallback, so the run it describes fails here when none of them ' + + 'holds. The script has no form for that failure and carries on past ' + + 'the routes instead, so what runs changes. Check that carrying on is ' + + 'what was meant.', }; } @@ -1639,24 +2051,6 @@ function raceConditionWarning(raceId: string): PrintWarning { }; } -/** - * A step whose own routes split. The script has one way on from a step, so the - * routes print as an `if` chain, which is a choice; the model runs every route - * it can. - */ -function implicitSplitWarning(nodeId: string): PrintWarning { - return { - elementId: nodeId, - category: 'degradedSplit', - message: - 'This step leaves on more than one route, and the model takes every ' + - 'route it can at once. A step in the script has one way on from it, so ' + - 'the routes print as a choice, which takes one of them. Check that ' + - 'taking one is what was meant, and draw the routes as a split of their ' + - 'own if it was not.', - }; -} - /** * A condition the engine weighs, on a route the script writes as the plain * step-to-step fall-through. Unlike the conditions a wait or an unweighing @@ -1779,7 +2173,11 @@ function droppedSettingWarning(gatewayId: string): PrintWarning { }; } -/** A pathological IR degrades to a `goto` rather than overflowing the stack. */ +/** + * A pathological IR degrades to a `goto` rather than overflowing the stack. + * Unpinned: reaching it takes an IR nested a thousand blocks deep, which no + * fixture builds. + */ const MAX_NESTING_DEPTH = 1000; /** @@ -1795,13 +2193,13 @@ export const PROCESS_HEADER_SETTINGS = [ ['candidateStarterGroups', quote], ] as const; -function buildProcessHeader(process: BpmnProcess): string { +function buildProcessHeader(process: BpmnProcess, names: PrintNames): string { const settings: string[] = namedSettings(process); for (const [key, render] of PROCESS_HEADER_SETTINGS) { const value = process[key]; if (value !== undefined) settings.push(setting(key, render(value))); } - return `process ${process.id}${parens(settings)} {`; + return `process ${nameOf(names, process.id)}${parens(settings)} {`; } /** Handlers carry no flow edges and print at the end of their container's body. */ @@ -1842,29 +2240,27 @@ function isEndCarried( * reads this half alone, so a jump into such an end is dropped and marked * whether or not the end ends up printed. * - * `siblings` is the container's flow elements in order; only the start arm - * reads them. The compiler re-derives a dropped start at the body's head and - * nowhere else, so only the first plain unnamed start can go: a second one - * prints under its reserved id, and the error the validator draws on that id - * is what tells the reader an entry point needs a name, where dropping it too - * would turn two entries into one without a word. + * `container` is the one the element sits in, whose id the minted start and + * end carry. The compiler re-derives a dropped start at the body's head and + * nowhere else, so only the first plain start under the minted id can go: a + * second one prints under its own id. */ export function isElidedOnPrint( el: FlowElement, - siblings: readonly FlowElement[], + container: PrintContainer, startTriggerSuppressed = false, ): boolean { switch (el.kind) { case 'startEvent': return ( - siblings.find( + container.flowElements.find( (s) => s.kind === 'startEvent' && - isPlainUnnamed(s, startTriggerSuppressed), + isPlainUnnamed(s, container, startTriggerSuppressed), )?.id === el.id ); case 'endEvent': - if (!isSynthesizedTerminalId(el.id, el.kind)) return false; + if (!isSynthesizedTerminalId(el.id, el.kind, container)) return false; // The compiler cannot re-derive a definition the `end` statement // carries, so that always prints, and `end`'s mandatory `name=ID` means // the synthesized id prints too. @@ -1873,10 +2269,10 @@ export function isElidedOnPrint( return !isEndCarried(el.eventDefinition); case 'intermediateThrowEvent': // Spells the id through `terminalNameSuffix`, which drops a synthesized one. - return isSynthesizedTerminalId(el.id, el.kind); + return isSynthesizedTerminalId(el.id, el.kind, container); case 'intermediateCatchEvent': // Spells the id through `terminalNameSuffix`, which drops a synthesized one. - return isSynthesizedTerminalId(el.id, el.kind); + return isSynthesizedTerminalId(el.id, el.kind, container); case 'boundaryEvent': // Prints as `on : `, keyed on the host. return true; @@ -1908,14 +2304,22 @@ export function isElidedOnPrint( /** A start with nothing of its own to print, so nothing is lost by dropping it. */ function isPlainUnnamed( el: Extract, + container: PrintContainer, startTriggerSuppressed: boolean, ): boolean { // A trigger has nowhere else to print, so a start carrying one always // prints. Inside an event sub-process the trigger prints in the `on` - // header instead, and the emitter suppresses it here. + // header instead, and the emitter suppresses it here; a timer head also + // carries the start's timer-job settings, so those alone do not make the + // start print either. if (el.eventDefinition !== undefined && !startTriggerSuppressed) return false; + const own = + startTriggerSuppressed && el.eventDefinition?.kind === 'timer' + ? splitTimerJobSettings(el).continuation + : el; return ( - isSynthesizedTerminalId(el.id, el.kind) && !carriesPrintableContent(el) + isSynthesizedTerminalId(el.id, el.kind, container) && + !carriesPrintableContent(own) ); } @@ -2014,7 +2418,9 @@ function ioParameters(el: IoMapped): Lines[] { /** * Text takes the quoting every string-valued attribute uses, so a `${...}` body * re-parses as a raw expression. A map key always quotes, since the bare - * spelling is an identifier token and a key reading as a keyword would not lex. + * spelling is an identifier token and a key reading as a keyword would not lex; + * it is the `STRING` the grammar's `MapKey` takes, never an expression, so it + * quotes as prose. */ function renderIoValue(value: IoValue): string { switch (value.kind) { @@ -2024,7 +2430,7 @@ function renderIoValue(value: IoValue): string { return `[${value.items.map((item) => renderIoValue(item)).join(', ')}]`; case 'map': { const entries = value.entries.map( - (entry) => `${quote(entry.key)}: ${renderIoValue(entry.value)}`, + (entry) => `${quoteLiteral(entry.key)}: ${renderIoValue(entry.value)}`, ); return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`; } @@ -2106,22 +2512,30 @@ function renderFence(format: string, code: string): string { } /** - * The validator rejects these prefixes in authored source, so an id carrying - * the one its own kind is minted with is synthesized. An id carrying another - * kind's template is an authored name and has to keep printing. An end answers - * to the throw prefix as well, because `throw` lowers to an end event; a catch - * answers to its own prefix only, since nothing else lowers to one. + * The validator refuses these in authored source, so an id carrying the form + * its own kind is minted with is synthesized: the exact start and end minted + * for `container` (and for a boundary escape in it), and the `Throw_` and + * `Catch_` prefixes, which are positional and no modeler writes. An id + * carrying another kind's template is an authored name and has to keep + * printing. An end answers to the throw prefix as well, because `throw` + * lowers to an end event; a catch answers to its own prefix only, since + * nothing else lowers to one. */ function isSynthesizedTerminalId( id: string, kind: FlowElement['kind'], + container: PrintContainer, ): boolean { switch (kind) { case 'startEvent': - return id.startsWith(START_EVENT_PREFIX); + return isMintedStartId(id, container.id); case 'endEvent': return ( - id.startsWith(END_EVENT_PREFIX) || id.startsWith(THROW_EVENT_PREFIX) + isMintedEndId( + id, + container.id, + container.flowElements.filter(isBoundary).map((b) => b.id), + ) || id.startsWith(THROW_EVENT_PREFIX) ); case 'intermediateThrowEvent': return id.startsWith(THROW_EVENT_PREFIX); @@ -2132,27 +2546,13 @@ function isSynthesizedTerminalId( } } -/** - * Omitted for a synthesized id: the forward compiler re-derives the same - * `Throw_...`/`Catch_...` from the statement's coordinate, so dropping it is - * lossless. - */ -function terminalNameSuffix( - el: Extract< - FlowElement, - { kind: 'endEvent' | 'intermediateThrowEvent' | 'intermediateCatchEvent' } - >, -): string { - return isSynthesizedTerminalId(el.id, el.kind) ? '' : ` ${el.id}`; -} - -/** An authored id prints so it survives as a goto target. */ +/** An authored id prints so it survives as a goto target; `nameSuffix` is {@link Emitter.terminalNameSuffix}'s. */ function renderThrow( - el: Extract, def: Exclude, + nameSuffix: string, settings: string[], members: Lines[], - names: CodeNames, + names: PrintNames, ): Lines { switch (def.kind) { case 'error': @@ -2162,7 +2562,7 @@ function renderThrow( case 'compensation': { const trigger = renderTrigger(def, names); return bracketed( - `throw ${trigger.head}${terminalNameSuffix(el)}`, + `throw ${trigger.head}${nameSuffix}`, [...trigger.items, ...settings], members, ); @@ -2176,10 +2576,15 @@ function renderThrow( } } -/** Engine attributes come off the sub-process: the start is elided on print. */ +/** + * Engine attributes come off the sub-process, plus the timer job's off the + * start when that start prints no statement: the compiler puts the three on + * the start a timer head synthesizes ({@link timerJob}), and a start that + * prints keeps them on its own line. + */ function buildOnHeader( handler: Extract, - names: CodeNames, + names: PrintNames, ): Lines { const start = handler.flowElements.find( (e): e is Extract => @@ -2191,25 +2596,40 @@ function buildOnHeader( ); } const trigger = renderTrigger(start.eventDefinition, names); + const lifted = isElidedOnPrint(start, handler, true) + ? jobSettingItems(timerJob(start)) + : []; return bodyHeader( `on ${trigger.head}`, [ ...trigger.items, ...engineSettings(handler), + ...lifted, ...alongsideFlag(start.isInterrupting === false), ], structuredMembers(handler), ); } -/** `attachedToRef` prints verbatim; refusing a bad host belongs to validation. */ +/** + * The settings of the job a timer start declares, which an `on timer` head + * authors for the start it synthesizes ({@link splitTimerJobSettings}); the + * async flags are the sub-process's own. + */ +function timerJob( + start: Extract, +): JobSettings { + return splitTimerJobSettings(start).timer; +} + +/** `attachedToRef` prints under the host's name; refusing a bad host belongs to validation. */ function buildBoundaryHeader( boundary: Extract, - names: CodeNames, + names: PrintNames, ): Lines { const trigger = renderTrigger(boundary.eventDefinition, names); return bodyHeader( - `on ${boundary.attachedToRef}: ${trigger.head}`, + `on ${nameOf(names, boundary.attachedToRef)}: ${trigger.head}`, [ ...trigger.items, ...jobSettingItems(boundary), @@ -2234,7 +2654,7 @@ const TIMER_PARTICLE: Record< */ function renderTrigger( def: EventDefinition, - names: CodeNames, + names: PrintNames, ): { head: string; items: string[]; @@ -2344,7 +2764,7 @@ function alongsideFlag(nonInterrupting: boolean): string[] { function renderStartEvent( el: Extract, startTriggerSuppressed: boolean, - names: CodeNames, + names: PrintNames, ): Lines { const trigger = el.eventDefinition === undefined || startTriggerSuppressed @@ -2352,7 +2772,7 @@ function renderStartEvent( : renderTrigger(el.eventDefinition, names); const head = trigger.head === '' ? '' : ` ${trigger.head}`; return bracketed( - `start ${el.id}${head}`, + `start ${nameOf(names, el.id)}${head}`, [ ...trigger.items, ...namedSettings(el), @@ -2366,7 +2786,10 @@ function renderStartEvent( } /** The label leads the assignment settings, and the form block leads the members. */ -function renderUserTask(el: Extract): Lines { +function renderUserTask( + el: Extract, + names: PrintNames, +): Lines { const settings = namedSettings(el); for (const [key, render] of USER_TASK_SETTINGS) { const value = el[key]; @@ -2381,10 +2804,11 @@ function renderUserTask(el: Extract): Lines { settings.push(...engineSettings(el)); const form = el.formFields === undefined ? [] : [renderFormBlock(el.formFields)]; - return bracketed(`user ${el.id}${repeatClause(el)}`, settings, [ - ...form, - ...structuredMembers(el), - ]); + return bracketed( + `user ${nameOf(names, el.id)}${repeatClause(el)}`, + settings, + [...form, ...structuredMembers(el)], + ); } /** In print order. The IR field name is also the DSL keyword. */ @@ -2432,14 +2856,29 @@ function renderFormField(field: FormField): Lines { return withMembers(headWithSettings, fieldBlockMembers(field)); } -/** `string`, `date` and `enum` quote, `number` and `boolean` print bare, EL always quotes. */ +/** + * `string`, `date` and `enum` quote; `number` and `boolean` print bare the + * text that re-lexes as one literal and lowers back to the same text, and + * quote the rest. `FormFieldHandler.createFormField` evaluates a default as + * an expression, so the text a modeler wrote is the constant the engine + * sees: bare, `maybe` would read a variable, `1 + 1` would compute, and `-3` + * is a unary expression the compiler lowers to `${-3}`. + */ function renderFormDefault(value: string, type: FormFieldType): string { - if (value.startsWith('${')) { - return quote(value); - } - return type === 'number' || type === 'boolean' ? value : quote(value); + const bare = + (type === 'number' || type === 'boolean') && + FORM_DEFAULT_LITERAL.test(value) && + (type !== 'number' || String(Number(value)) === value); + return bare ? value : quote(value); } +// Canonical spellings only: `1.50`, `1.0` and `007` re-lex as numbers but +// lower to `1.5`, `1` and `7`, so they stay quoted to keep their text. +// `String(Number(value)) === value` catches what the shape alone can't: past +// 2^53 or 15 significant digits, `Number` itself rounds the value, so a +// canonically spelled literal can still print a different number than it read. +const FORM_DEFAULT_LITERAL = /^(0|[1-9]\d*)(\.\d*[1-9])?$|^(true|false)$/; + /** `pattern` first, being a type parameter rather than a constraint, then each constraint in IR order. */ function fieldSettings(field: FormField): string[] { const settings: string[] = @@ -2511,7 +2950,7 @@ function propertyMembers(properties: ExtensionProperty[] | undefined): Lines[] { */ function errorMappingMembers( mappings: ErrorMapping[] | undefined, - names: CodeNames, + names: PrintNames, ): Lines[] { return (mappings ?? []).map((m) => [ `error ${names.error.get(m.errorCode) ?? m.errorCode} when ${renderRawCondition(m.condition)}`, @@ -2521,6 +2960,7 @@ function errorMappingMembers( /** The message leads the engine settings, so the wait reads before them. */ function renderReceiveTask( el: Extract, + names: PrintNames, ): Lines { const settings = [ ...namedSettings(el), @@ -2530,7 +2970,7 @@ function renderReceiveTask( ...engineSettings(el), ]; return bracketed( - `receive ${el.id}${repeatClause(el)}`, + `receive ${nameOf(names, el.id)}${repeatClause(el)}`, settings, structuredMembers(el), ); @@ -2544,7 +2984,7 @@ const SERVICE_TASK_LIKE_KEYWORD = { function renderServiceTask( el: Extract, - codeNames: CodeNames, + names: PrintNames, ): Lines { const keyword = SERVICE_TASK_LIKE_KEYWORD[el.element ?? 'service']; const binding = el.binding; @@ -2554,17 +2994,21 @@ function renderServiceTask( ...resultVariableSetting(el), ...engineSettings(el), ]; - return bracketed(`${keyword} ${el.id}${repeatClause(el)}`, settings, [ - ...fieldMembers(binding), - ...propertyMembers( - binding.kind === 'external' ? binding.properties : undefined, - ), - ...errorMappingMembers( - binding.kind === 'external' ? binding.errorMappings : undefined, - codeNames, - ), - ...structuredMembers(el), - ]); + return bracketed( + `${keyword} ${nameOf(names, el.id)}${repeatClause(el)}`, + settings, + [ + ...fieldMembers(binding), + ...propertyMembers( + binding.kind === 'external' ? binding.properties : undefined, + ), + ...errorMappingMembers( + binding.kind === 'external' ? binding.errorMappings : undefined, + names, + ), + ...structuredMembers(el), + ], + ); } /** The settings spelling out an execution binding, whatever carries it. */ @@ -2629,6 +3073,7 @@ function versionBindingSetting(binding: VersionBinding): string { /** Fixed order: the settings, then the members, the mappings last. */ function renderCallActivity( el: Extract, + names: PrintNames, ): Lines { const settings: string[] = [ ...namedSettings(el), @@ -2671,20 +3116,22 @@ function renderCallActivity( members.push([renderCallMapping('out', mapping)]); } - return bracketed(`call ${el.id}${repeatClause(el)}`, settings, members); + return bracketed( + `call ${nameOf(names, el.id)}${repeatClause(el)}`, + settings, + members, + ); } /** * All-digit prints bare; anything else quotes, so it re-parses as an - * expression. An expression is trimmed and a `#{` opening written as `${`, - * the one raw form the surface lexes; Operaton reads either opening after a - * trim (`StringUtil.isExpression`). + * expression. An expression is trimmed, since a raw template opens directly + * after its quote and Operaton reads the opening after a trim of its own + * (`StringUtil.isExpression`). */ function renderNumericValue(value: string): string { if (/^[0-9]+$/.test(value)) return value; - return quote( - EXPRESSION_OPEN.test(value) ? value.trim().replace(/^#/, '$') : value, - ); + return quote(EXPRESSION_OPEN.test(value) ? value.trim() : value); } function resultVariableSetting(el: { resultVariable?: string }): string[] { @@ -2726,6 +3173,7 @@ function renderCallMapping( /** The fence closes the statement, so it goes on the last line the members leave. */ function renderScriptTask( el: Extract, + names: PrintNames, ): Lines { const settings = [ ...namedSettings(el), @@ -2733,7 +3181,7 @@ function renderScriptTask( ...engineSettings(el), ]; const lines = bracketed( - `script ${el.id}${repeatClause(el)}`, + `script ${nameOf(names, el.id)}${repeatClause(el)}`, settings, structuredMembers(el), ); @@ -2797,15 +3245,13 @@ function headSettings(settings: string[]): string { /** A literal count prints bare; anything else is an expression. */ export const BARE_CARDINALITY = /^\d+$/; -/** A plain name is the collection variable itself; anything else is an expression. */ -const BARE_COLLECTION = /^[A-Za-z_][A-Za-z0-9_]*$/; - /** * The grammar's `ID` terminal. The clause writes the name each run sees bare * and has no other form for it, so this is also the test the import direction - * refuses by. + * refuses by. A collection spelled as a name prints bare by the same test; + * anything else is an expression. */ -export const BARE_ELEMENT_VARIABLE = /^[_a-zA-Z]\w*(-\w+)*$/; +export const BARE_ELEMENT_VARIABLE = ID_SHAPED; /** * The name every code is raised by, one map per kind because the two are @@ -2819,9 +3265,12 @@ export const BARE_ELEMENT_VARIABLE = /^[_a-zA-Z]\w*(-\w+)*$/; */ function codeDeclarations(process: BpmnProcess): { lines: string[]; - names: CodeNames; + names: Pick; } { - const names: CodeNames = { error: new Map(), escalation: new Map() }; + const names = { + error: new Map(), + escalation: new Map(), + }; const lines: string[] = []; const taken = new Set(); @@ -2858,39 +3307,121 @@ function codeDeclarations(process: BpmnProcess): { } /** - * A `var` line per collection a printed clause names bare, in first-appearance - * order. Operaton reads a bare `operaton:collection` as the name of a process - * variable, so a program that iterates one without declaring it reads a - * variable it never declares; a quoted name and a `${...}` body print as - * strings and name nothing. The type is `any` because a repetition says a - * variable is iterated and nothing more. A form field or a catch binding of the - * same name types it already, and every declaration of one name has to agree on - * the type, so a second line would be an error rather than a duplicate. + * A `var : any` line per variable the body reads bare and nothing + * types, in first-appearance order over one fixed walk. BPMN has no slot for + * a declaration, so the print declares what it writes in a variable position: + * the roots of a condition, a count, an `until`, an error mapping's `when`, a + * conditional trigger, an `in` source and a bare collection, which are the + * positions the validator reads a variable at, minus what its symbol table + * already holds (`DefaultVariableSymbolProvider.collect`): a form field, a + * catch binding, an io parameter name, an element variable, and the engine's + * loop counters once anything repeats. `externalTask` inside a mapping and an + * `out` source are exempt there too, the one evaluated on the external task + * and the other in the called process. The type is `any` because a read says + * nothing more, and every declaration of one name has to agree on the type, + * so a second line for a typed name would be an error rather than a duplicate. */ -function collectionDecls(process: BpmnProcess): string[] { - const collections = new Set(); +function variableDecls(process: BpmnProcess): string[] { + const reads = new Set(); const typed = new Set(); - for (const el of eachElement(process)) { - if ('formFields' in el) { - for (const field of el.formFields ?? []) typed.add(field.id); - } - const def = 'eventDefinition' in el ? el.eventDefinition : undefined; - if (def?.kind === 'error' || def?.kind === 'escalation') { - if (def.codeVariable !== undefined) typed.add(def.codeVariable); - if ('messageVariable' in def && def.messageVariable !== undefined) { - typed.add(def.messageVariable); - } + let anyRepeats = false; + + const read = (body: string | undefined, exempt?: string): void => { + if (body === undefined) return; + const result = parseJuel(body); + if (result.kind !== 'structured') return; + for (const name of varRoots(result.expr)) { + if (name !== exempt) reads.add(name); } - const collection = 'loop' in el ? el.loop?.collection : undefined; - if (collection !== undefined && BARE_COLLECTION.test(collection)) { - collections.add(collection); + }; + + const walk = (container: FlowContainer): void => { + for (const el of container.flowElements) { + if ('formFields' in el) { + for (const field of el.formFields ?? []) typed.add(field.id); + } + const def = 'eventDefinition' in el ? el.eventDefinition : undefined; + if (def?.kind === 'error' || def?.kind === 'escalation') { + if (def.codeVariable !== undefined) typed.add(def.codeVariable); + if ('messageVariable' in def && def.messageVariable !== undefined) { + typed.add(def.messageVariable); + } + } + if ('inputParameters' in el || 'outputParameters' in el) { + for (const param of el.inputParameters ?? []) typed.add(param.name); + for (const param of el.outputParameters ?? []) typed.add(param.name); + } + const loop = 'loop' in el ? el.loop : undefined; + if (repeats(loop)) { + anyRepeats = true; + if (loop.elementVariable !== undefined) typed.add(loop.elementVariable); + read(loop.cardinality); + if ( + loop.collection !== undefined && + BARE_ELEMENT_VARIABLE.test(loop.collection) + ) { + reads.add(loop.collection); + } + read(loop.completionCondition); + } + if (def?.kind === 'conditional') read(def.condition); + if (el.kind === 'serviceTask' && el.binding.kind === 'external') { + for (const mapping of el.binding.errorMappings ?? []) { + read(mapping.condition, EXTERNAL_TASK_EL_NAME); + } + } + if (el.kind === 'callActivity') { + for (const mapping of el.inMappings ?? []) { + if ( + mapping.kind === 'variable' && + mapping.source !== mapping.target && + BARE_ELEMENT_VARIABLE.test(mapping.source) + ) { + reads.add(mapping.source); + } + } + } + if (el.kind === 'subProcess') walk(el); } - } - return [...collections] + for (const flow of container.sequenceFlows) read(flow.conditionExpression); + }; + walk(process); + + if (anyRepeats) for (const name of LOOP_VARIABLES) typed.add(name); + return [...reads] .filter((name) => !typed.has(name)) .map((name) => `${INDENT}var ${name}: any`); } +/** The variable roots of a structured body, an index expression's included. */ +function* varRoots(node: JuelNode): Generator { + switch (node.kind) { + case 'varRef': + yield node.name; + for (const accessor of node.accessors) { + if ('index' in accessor) yield* varRoots(accessor.index); + } + break; + case 'unary': + yield* varRoots(node.operand); + break; + case 'binary': + yield* varRoots(node.left); + yield* varRoots(node.right); + break; + case 'ternary': + yield* varRoots(node.condition); + yield* varRoots(node.whenTrue); + yield* varRoots(node.whenFalse); + break; + case 'paren': + yield* varRoots(node.inner); + break; + default: + break; + } +} + /** * The repeat clause of an activity, with a leading space, or the empty string * when it runs once. A collection spelled as a plain name prints bare and @@ -2912,7 +3443,7 @@ function repeatClause(el: Repeatable): string { if (loop.collection !== undefined) { const element = loop.elementVariable === undefined ? '' : `${loop.elementVariable} `; - const collection = BARE_COLLECTION.test(loop.collection) + const collection = BARE_ELEMENT_VARIABLE.test(loop.collection) ? loop.collection : quote(loop.collection); parts.push(`each ${element}in ${collection}`); @@ -2934,38 +3465,28 @@ function renderRawCondition(body: string): string { } /** - * Escapes inner quotes and backslashes to match the STRING terminal. This is - * the quoting for a value the engine evaluates, which a body opening with - * `${` has to keep re-lexing as: an expression, a delegate, a timer body, an - * io value, a collection. Prose and a declared name take {@link quoteLiteral}. + * The quoting for a value the engine evaluates, which a body opening with + * `${` or `#{` has to keep re-lexing as: an expression, a delegate, a timer + * body, an io value, a collection. The grammar reads a raw template with the + * same escapes as a literal, so one escaper serves both and this is its exact + * inverse. Prose and a declared name take {@link quoteLiteral}. */ function quote(value: string): string { - return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + return `"${escapeQuoted(value)}"`; } /** - * The quoting for a value that has to come back byte for byte: a label, - * documentation, a version tag, a form field label, a declared code, an error - * message. - * - * A body opening with `${` is escaped because it lexes as a raw expression, - * and the reader unwrapping one strips its quotes without unescaping, so every - * other escape inside it would come back as two characters. `\$` is not a - * recognized escape and the lexer hands an unrecognized one back unchanged, - * which keeps the two exact inverses. Only that opening earns the backslash: - * anywhere else a `$` needs none, and an escape no terminal asks for is noise - * in source somebody reads. + * The quoting for a value that has to come back byte for byte as text: a + * label, documentation, a version tag, a form field label, a map key, a + * declared code, an error message. * - * A carriage return is escaped for the reason a newline is: the printed line - * carries no raw control character, so nothing between writing the source and - * reading it back can normalize the prose out from under it. `\r` is a - * recognized escape and the lexer maps it straight back. + * A body opening with `${` or `#{` gets a backslash before the opener, so it + * lexes as a `STRING` rather than as a raw template. `\$` and `\#` are not + * recognized escapes and the reader hands the character back unchanged, which + * keeps the two exact inverses. Only that opening earns the backslash: an + * escape no terminal asks for is noise in source somebody reads. */ function quoteLiteral(value: string): string { - const escaped = value - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\r/g, '\\r') - .replace(/\n/g, '\\n'); - return `"${escaped.startsWith('${') ? '\\' + escaped : escaped}"`; + const escaped = escapeQuoted(value); + return `"${/^[$#]\{/.test(escaped) ? '\\' + escaped : escaped}"`; } diff --git a/packages/transform/src/ir-to-xml.ts b/packages/transform/src/ir-to-xml.ts index 021faba..8b2a0e3 100644 --- a/packages/transform/src/ir-to-xml.ts +++ b/packages/transform/src/ir-to-xml.ts @@ -12,8 +12,9 @@ import { existsSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { basename, dirname, extname, join } from 'node:path'; +import { dirname, join } from 'node:path'; +import { EXPRESSION_OPEN } from '@bpmn-script/language'; import { BpmnModdle, type BpmnModdleInstance, @@ -24,6 +25,7 @@ import { import { layoutProcess } from 'bpmn-auto-layout'; import { humanize } from './humanize.js'; +import { LayoutError } from './errors.js'; import { resolveCollision } from './synthesize-ids.js'; import type { BpmnProcess, @@ -54,12 +56,16 @@ import type { import { carriesFields, eventIdentities, - gatewayDefaultFlowId, isGateway, repeats, } from './ir/types.js'; -/** Opaque to Operaton; it never has to resolve. */ +/** + * `BpmnParse.parseDefinitionsAttributes` stores this as `this.targetNamespace`, + * `parseProcess` sets it as the process definition's category, and + * `resolveName` prefixes every unqualified message and signal id and ref with + * it. None of that dereferences it as a URI; the engine never fetches it. + */ const TARGET_NAMESPACE = 'http://bpmnscript.io/processes'; /** @@ -98,11 +104,14 @@ function resolveOperatonModdlePath(): string { * Both directions of the XML boundary build their moddle here, so the read and * write paths stay symmetric. * - * `camunda:` stays unregistered on purpose. `camunda-bpmn-moddle` defines the - * same property names (`class`, `assignee`, `formKey`, `historyTimeToLive`) on - * the same BPMN types as the Operaton fork, and moddle then refuses both with - * `property <...> already defined; ... not allowed without redefines`. Reading - * `camunda:*` needs no extension anyway: moddle falls through to `$attrs`. + * `camunda:` stays unregistered: the importer rewrites the camunda namespace + * URI to the operaton one before this moddle instance ever reads the text (see + * `xmlToIr`), so no camunda-bound element or attribute reaches `fromXML` under + * a foreign prefix at all. Registering `camunda-bpmn-moddle` here besides would + * collide: it defines the same property names (`class`, `assignee`, `formKey`, + * `historyTimeToLive`) on the same BPMN types as the Operaton fork, and moddle + * would refuse both with `property <...> already defined; ... not allowed + * without redefines`. */ export function createModdle(): BpmnModdleInstance { return new BpmnModdle({ @@ -111,7 +120,6 @@ export function createModdle(): BpmnModdleInstance { } export interface IrToXmlOptions { - sourceFileName?: string; exporterVersion?: string; } @@ -149,12 +157,9 @@ export async function irToXml( }; const processElement = moddle.create('bpmn:Process', processAttrs); - const stem = options?.sourceFileName - ? basename(options.sourceFileName, extname(options.sourceFileName)) - : process.id; const diagrams = buildSubProcessExpansionHint(moddle, processElement); const definitions = moddle.create('bpmn:Definitions', { - id: `Definitions_${stem}`, + id: resolveCollision(`Definitions_${process.id}`, roots.taken), targetNamespace: TARGET_NAMESPACE, exporter: 'BPMNscript', exporterVersion: options?.exporterVersion ?? '0.0.0', @@ -172,20 +177,38 @@ export async function irToXml( // with its own formatting. const { xml } = await moddle.toXML(definitions, { format: true }); - const xmlWithDi = await layoutProcess(xml); + // `bpmn-auto-layout` re-parses this string with `moddle.fromXML` internally + // and silently drops anything that draws a warning there (an illegal or + // duplicate id chief among them), serializing whatever survives with no + // indication that the document lost its process. Catching that here, before + // the layouter runs, turns a two-line output with exit 0 into a thrown error + // naming the actual defect. + const { warnings } = await createModdle().fromXML(xml); + if (warnings.length > 0) { + throw new Error( + `IR to XML produced a document bpmn-moddle cannot re-read: ${warnings[0]!.message}`, + ); + } - return xmlWithDi; + try { + return await layoutProcess(xml); + } catch (cause) { + throw new LayoutError(xml, cause); + } } /** * BPMN puts these at `bpmn:Definitions` level, deduped by code or name, so one - * identity maps to one element. Map order is emission order. + * identity maps to one element. Map order is emission order. `taken` is every + * id already in the document, so the `bpmn:Definitions` id itself can join the + * same collision check these roots use. */ interface RootElementIndex { errorByCode: Map; escalationByCode: Map; messageByName: Map; signalByName: Map; + taken: Set; } /** @@ -261,7 +284,7 @@ function synthesizeRootElements( taken, ); - return { errorByCode, escalationByCode, messageByName, signalByName }; + return { errorByCode, escalationByCode, messageByName, signalByName, taken }; } /** The name is the engine-side identity, so every use of it shares one root. */ @@ -336,16 +359,27 @@ function serviceTaskBindingAttrs( } /** - * No code means no `errorRef`/`escalationRef`: a ref-less definition catches any. - * - * @param binding The implementation of a thrown message, which Operaton reads - * off the definition rather than off the event. + * What Operaton reads off the definition rather than off the event that owns + * it. Both fields are the owner's own, passed through so the node stays the + * one place a setting is stored. */ +interface DefinitionOwner { + /** The implementation of a thrown message. */ + binding?: ServiceTaskBinding; + /** + * The timer job's lock, which `BpmnParse.parseTimer` takes from + * `operaton:exclusive` on the definition. The same attribute on the event + * tag reaches only the async continuation job. + */ + exclusive?: false; +} + +/** No code means no `errorRef`/`escalationRef`: a ref-less definition catches any. */ function buildEventDefinition( moddle: BpmnModdleInstance, def: EventDefinition, roots: RootElementIndex, - binding?: ServiceTaskBinding, + owner: DefinitionOwner = {}, ): ModdleElement { switch (def.kind) { case 'error': { @@ -374,7 +408,9 @@ function buildEventDefinition( case 'message': return moddle.create('bpmn:MessageEventDefinition', { messageRef: roots.messageByName.get(def.messageName), - ...(binding === undefined ? {} : serviceTaskBindingAttrs(binding)), + ...(owner.binding === undefined + ? {} + : serviceTaskBindingAttrs(owner.binding)), }); case 'signal': return moddle.create('bpmn:SignalEventDefinition', { @@ -388,6 +424,7 @@ function buildEventDefinition( }); return moddle.create('bpmn:TimerEventDefinition', { [TIMER_KIND_TO_CHILD[def.timerKind]]: expression, + ...(owner.exclusive === false ? { 'operaton:exclusive': false } : {}), }); } case 'conditional': @@ -417,11 +454,11 @@ function eventDefinitionAttrs( moddle: BpmnModdleInstance, def: EventDefinition | undefined, roots: RootElementIndex, - binding?: ServiceTaskBinding, + owner: DefinitionOwner, ): Record { return def === undefined ? {} - : { eventDefinitions: [buildEventDefinition(moddle, def, roots, binding)] }; + : { eventDefinitions: [buildEventDefinition(moddle, def, roots, owner)] }; } /** Read back by the import direction, which inverts it. */ @@ -508,7 +545,7 @@ function buildContainerChildren( } attachIncomingOutgoing(container, flowNodeById, sequenceFlowById); - attachGatewayDefaults(container, flowNodeById, sequenceFlowById); + attachDefaultFlows(container, flowNodeById, sequenceFlowById); attachBoundaryHosts(container, flowNodeById); return [ @@ -535,7 +572,7 @@ function createFlowNode( case 'startEvent': { const attrs: Record = { ...baseAttrs, - ...eventDefinitionAttrs(moddle, node.eventDefinition, roots), + ...eventDefinitionAttrs(moddle, node.eventDefinition, roots, node), ...(node.initiator !== undefined ? { 'operaton:initiator': node.initiator } : {}), @@ -550,36 +587,26 @@ function createFlowNode( case 'endEvent': return moddle.create('bpmn:EndEvent', { ...baseAttrs, - ...eventDefinitionAttrs( - moddle, - node.eventDefinition, - roots, - node.binding, - ), + ...eventDefinitionAttrs(moddle, node.eventDefinition, roots, node), }); case 'intermediateThrowEvent': return moddle.create('bpmn:IntermediateThrowEvent', { ...baseAttrs, - ...eventDefinitionAttrs( - moddle, - node.eventDefinition, - roots, - node.binding, - ), + ...eventDefinitionAttrs(moddle, node.eventDefinition, roots, node), }); case 'intermediateCatchEvent': return moddle.create('bpmn:IntermediateCatchEvent', { ...baseAttrs, - ...eventDefinitionAttrs(moddle, node.eventDefinition, roots), + ...eventDefinitionAttrs(moddle, node.eventDefinition, roots, node), }); case 'boundaryEvent': { // `attachedToRef` is wired later, by `attachBoundaryHosts`. const attrs: Record = { ...baseAttrs, - ...eventDefinitionAttrs(moddle, node.eventDefinition, roots), + ...eventDefinitionAttrs(moddle, node.eventDefinition, roots, node), }; // BPMN defaults to interrupting, and the serializer drops a default. if (node.cancelActivity === false) { @@ -939,7 +966,8 @@ function buildExtensionElements( // (`operaton:class`/`operaton:delegateExpression`), so it precedes even the // io-parameter block. Only a serviceTask-like tag has that implementation // slot; a thrown or emitted message's binding lives on its message - // definition instead, which carries no field slot of its own (ADR 0032). + // definition instead, which the language keeps no member block for + // (ADR 0032), so no field is ever written there. if (node.kind === 'serviceTask') { for (const field of codeBindingFields(node.binding)) { values.push(buildField(moddle, field)); @@ -950,9 +978,11 @@ function buildExtensionElements( values.push(inputOutput); } // A thrown message's binding lives on its `bpmn:MessageEventDefinition` - // (`serviceTaskBindingAttrs`, called from `buildEventDefinition`), which has - // no `extensionElements` of its own to carry a property list or a mapping - // into; only a serviceTask-like tag reaches here with one. + // (`serviceTaskBindingAttrs`, called from `buildEventDefinition`). The + // engine reads a mapping off that definition and a property list off the + // event (`BpmnParse.parseServiceTaskLike` is handed both), but the language + // has no position for either on a throw, so none is written; only a + // serviceTask-like tag reaches here with one. if (node.kind === 'serviceTask' && node.binding.kind === 'external') { if (node.binding.properties !== undefined) { values.push(buildProperties(moddle, node.binding.properties, 'name')); @@ -973,9 +1003,19 @@ function buildExtensionElements( ); } if (node.kind === 'userTask') { + // `BpmnParse.parseTimeoutTaskListener` refuses a timeout listener with no + // id, the key `TaskDefinition.addTimeoutTaskListener` stores it under, so + // each gets one minted here; the importer drops it and this re-mints it. + let timeouts = 0; for (const listener of node.taskListeners ?? []) { + let id: string | undefined; + if (listener.event === 'timeout') { + timeouts += 1; + id = resolveCollision(`${node.id}_timeout_${timeouts}`, roots.taken); + roots.taken.add(id); + } values.push( - buildListener(moddle, 'operaton:TaskListener', listener, roots), + buildListener(moddle, 'operaton:TaskListener', listener, roots, id), ); } } @@ -1079,9 +1119,10 @@ function codeBindingFields( /** * One `operaton:field`. `stringValue` is injected into the bean verbatim; - * `expression` is evaluated per instantiation. The moddle declares - * `expression` as a non-attribute String, so it serializes as a child rather - * than an attribute, which is what lets the two slots share one DSL spelling. + * `expression` is evaluated per instantiation, and a value opening with `${` + * or `#{` lands there. The moddle declares `expression` as a non-attribute + * String, so it serializes as a child rather than an attribute, which is what + * lets the two slots share one DSL spelling. */ function buildField( moddle: BpmnModdleInstance, @@ -1089,7 +1130,7 @@ function buildField( ): ModdleElement { return moddle.create('operaton:Field', { name: field.name, - ...(field.value.startsWith('${') + ...(EXPRESSION_OPEN.test(field.value) ? { expression: field.value } : { stringValue: field.value }), }); @@ -1101,9 +1142,11 @@ function buildListener( type: 'operaton:ExecutionListener' | 'operaton:TaskListener', listener: ExecutionListener | TaskListener, roots: RootElementIndex, + id?: string, ): ModdleElement { const fields = codeBindingFields(listener.binding); const attrs: Record = { + ...(id !== undefined ? { id } : {}), event: listener.event, ...listenerBindingAttrs(moddle, listener.binding), ...(fields.length > 0 @@ -1300,8 +1343,12 @@ function callMappingAttrs( return attrs; } -/** `${amount > 1000}` -> `amount > 1000`. Anything undelimited passes through. */ -function conditionLabel(conditionExpression: string): string { +/** + * `${amount > 1000}` -> `amount > 1000`. Anything undelimited passes through. + * The import reads a flow's `name` against the same rule, so a name this + * would write back is not reported as lost. + */ +export function conditionLabel(conditionExpression: string): string { return conditionExpression.replace(/^\$\{([\s\S]*)\}$/, '$1'); } @@ -1363,24 +1410,28 @@ function attachIncomingOutgoing( } } -/** `bpmn:default` holds a moddle-element reference, not the raw id. */ -function attachGatewayDefaults( +/** + * `bpmn:default` holds a moddle-element reference, not the raw id. The two + * split kinds and every activity kind carry one; `defaultFlowId` is the only + * key of that name, so its presence is the whole question. + */ +function attachDefaultFlows( container: FlowContainer, flowNodeById: Map, sequenceFlowById: Map, ): void { for (const node of container.flowElements) { - if (!isGateway(node)) continue; - const defaultFlowId = gatewayDefaultFlowId(node); + const defaultFlowId = + 'defaultFlowId' in node ? node.defaultFlowId : undefined; if (defaultFlowId === undefined) continue; - const gateway = requireById(flowNodeById, node.id); + const owner = requireById(flowNodeById, node.id); const defaultFlow = sequenceFlowById.get(defaultFlowId); if (defaultFlow === undefined) { throw new Error( `${node.kind} "${node.id}" declares default flow "${defaultFlowId}" that does not exist.`, ); } - gateway.default = defaultFlow; + owner.default = defaultFlow; } } diff --git a/packages/transform/src/ir/types.ts b/packages/transform/src/ir/types.ts index 674862e..e54e806 100644 --- a/packages/transform/src/ir/types.ts +++ b/packages/transform/src/ir/types.ts @@ -16,7 +16,9 @@ import type { FORM_FIELD_TYPES, TASK_LISTENER_EVENTS, THROW_TRIGGERS, + TimerJobKey, } from '@bpmn-script/language'; +import { TIMER_JOB_KEYS } from '@bpmn-script/language'; /** * Sequence flows never cross a container boundary, so a parent can treat a @@ -36,9 +38,13 @@ export interface BpmnProcess extends FlowContainer, Named { versionTag?: string; /** Absent means the exporter's default; see `HISTORY_TIME_TO_LIVE`. */ historyTimeToLive?: string; - /** Comma-separated user ids the engine checks before it will start the process. */ + /** + * Comma-separated user ids listed as candidate starters (Tasklist's process + * list); `BpmnDeployer.addAuthorizations` stores each entry as written, and + * nothing checks them when an instance starts. + */ candidateStarterUsers?: string; - /** Comma-separated group ids the engine checks before it will start the process. */ + /** Comma-separated group ids, listed and stored the same way. */ candidateStarterGroups?: string; /** * Every error code the process raises, catches, or declares, in canonical @@ -244,9 +250,12 @@ export type EventDefinition = } | { /** - * Payload-less: BPMN compensation carries no code and no `activityRef`. - * `waitForCompletion` stays unmodeled because the moddle schema defaults - * it to `true` and the engine supports no other value. + * Payload-less in this IR: no code and no `activityRef`, so a throw + * always compensates its whole scope rather than one activity. + * `BpmnParse.parseThrowCompensateEventDefinition` reads both + * `activityRef` and `waitForCompletion` off the BPMN definition; + * `waitForCompletion` stays unmodeled here because the moddle schema + * defaults it to `true` and the engine warns on any other value. */ kind: 'compensation'; } @@ -360,6 +369,35 @@ export function jobSettings(found: { }; } +export type { TimerJobKey }; + +/** + * Split the settings of an element that declares a timer between the two jobs + * it can create. `BpmnParse.parseTimer` builds the timer job with the lock off + * the timer definition and the priority off the declaring activity, and + * `DefaultFailedJobParseListener.parseStartEvent`, `parseBoundaryEvent` and + * `parseIntermediateCatchEvent` read its retry cycle off the declaring element. + * The async flags create the continuation job and stay with it. `timer` is + * normalized as {@link jobSettings} is, so a key absent on `settings` stays + * absent. + */ +export function splitTimerJobSettings( + settings: T, +): { timer: JobSettings; continuation: Omit } { + const continuation = { ...settings } as Omit; + for (const key of TIMER_JOB_KEYS) delete (continuation as JobSettings)[key]; + return { + timer: jobSettings({ + asyncBefore: undefined, + asyncAfter: undefined, + exclusive: settings.exclusive, + jobPriority: settings.jobPriority, + retryCycle: settings.retryCycle, + }), + continuation, + }; +} + /** * {@link JobSettings} plus execution listeners. Mixed into every event and * activity kind; the four gateway kinds extend `JobSettings` directly and @@ -393,7 +431,10 @@ export function ioMapped( * `cardinality` and `collection` is present: with both, the count drives the * runs while each run still sees its element. The job settings are the ones * the engine reads off this element onto each run, `RUN_ENGINE_KEYS` in the - * language package, which is why there is no `jobPriority`. + * language package. There is no `jobPriority` field here because the plain + * one on the repeated activity already prices each run's job, not the whole + * loop's; the whole-loop job takes the process definition's own priority, + * since nothing writes one on this element. */ export interface LoopCharacteristics extends Omit { /** A literal count, or an expression that yields one. */ @@ -416,6 +457,17 @@ export interface Repeatable { loop?: LoopCharacteristics; } +/** What BPMN declares on `bpmn:Activity` alone, so every step kind carries it and no event or gateway does. */ +export interface Activity extends Repeatable { + /** + * The BPMN `default` attribute on a step: `BpmnActivityBehavior.findTransitionsToTake` + * skips it while weighing the other routes and `handleNoTransitions` takes + * it when none of them held, so a step with several routes carries it the + * way a gateway does. + */ + defaultFlowId?: string; +} + /** * The invariant above, as a check both write-out directions read, so neither * can decide on its own what a loop with nothing to repeat over means. @@ -571,8 +623,7 @@ export interface IntermediateCatchEvent extends EngineAttributes { eventDefinition: CatchEventDefinition; } -export interface UserTask - extends EngineAttributes, IoMapped, Repeatable, Named { +export interface UserTask extends EngineAttributes, IoMapped, Activity, Named { kind: 'userTask'; id: string; assignee?: string; @@ -662,7 +713,7 @@ export function carriesFields( } export interface ServiceTask - extends EngineAttributes, IoMapped, Repeatable, Named { + extends EngineAttributes, IoMapped, Activity, Named { kind: 'serviceTask'; id: string; binding: ServiceTaskBinding; @@ -680,7 +731,7 @@ export interface ServiceTask } export interface ScriptTask - extends EngineAttributes, IoMapped, Repeatable, Named { + extends EngineAttributes, IoMapped, Activity, Named { kind: 'scriptTask'; id: string; /** Canonical Operaton `scriptFormat`, e.g. `"javascript"`, `"groovy"`. */ @@ -692,7 +743,7 @@ export interface ScriptTask } /** A step the engine records and leaves at once; the work happens outside it. */ -export interface Task extends EngineAttributes, IoMapped, Repeatable, Named { +export interface Task extends EngineAttributes, IoMapped, Activity, Named { kind: 'task'; id: string; } @@ -702,7 +753,7 @@ export interface Task extends EngineAttributes, IoMapped, Repeatable, Named { * API rather than a correlation. */ export interface ReceiveTask - extends EngineAttributes, IoMapped, Repeatable, Named { + extends EngineAttributes, IoMapped, Activity, Named { kind: 'receiveTask'; id: string; /** `messageRef`, and the dedupe key: one name, one root element. */ @@ -793,7 +844,7 @@ export function gatewayDefaultFlowId(gateway: Gateway): string | undefined { /** An activity that is itself a container; the parent wires flow to it by `id`. */ export interface SubProcess - extends FlowContainer, EngineAttributes, IoMapped, Repeatable, Named { + extends FlowContainer, EngineAttributes, IoMapped, Activity, Named { kind: 'subProcess'; /** The event sub-process an `on` lowers to, fired by its start event's trigger. */ triggeredByEvent?: true; @@ -846,7 +897,7 @@ export type CallVariableMapper = * stable: `businessKey`, then `inMappings`, then `outMappings`. */ export interface CallActivity - extends EngineAttributes, IoMapped, Repeatable, Named { + extends EngineAttributes, IoMapped, Activity, Named { kind: 'callActivity'; id: string; /** The id of the invoked process. */ diff --git a/packages/transform/src/juel.ts b/packages/transform/src/juel.ts index 8c39435..e2dc4a2 100644 --- a/packages/transform/src/juel.ts +++ b/packages/transform/src/juel.ts @@ -1,8 +1,8 @@ /** * Parser, classifier, and DSL serializer for the JUEL subset, on the import * path. It decides whether a raw `${...}` or `#{...}` body fits the subset and - * can print as clean unquoted DSL, or has to fall back to the quoted `"${...}"` - * raw form. + * can print as clean unquoted DSL, or has to fall back to the quoted raw form, + * `"${...}"` or `"#{...}"` with the opener it came with. * * The subset boundary is the Langium expression sub-grammar in * `packages/language/src/bpmn-script.langium`, whose precedence the @@ -68,51 +68,69 @@ export type BinaryOp = | '/' | '%'; -/** A raw `text` is the verbatim inner body, without the wrapper it came in. */ +/** + * A raw `text` is the verbatim inner body, without the wrapper it came in; + * `open` is that wrapper's first character, `$` when the body had none. + */ export type ExprResult = - { kind: 'structured'; expr: JuelNode } | { kind: 'raw'; text: string }; + | { kind: 'structured'; expr: JuelNode } + | { kind: 'raw'; text: string; open: '$' | '#' }; /** Never throws: anything outside the subset comes back as a raw result. */ export function parseJuel(body: string): ExprResult { + const open = /^#\{/.test(body.trim()) ? '#' : '$'; const inner = stripWrapper(body); if (inner === undefined) { - return { kind: 'raw', text: stripWrapperLenient(body) }; + return { kind: 'raw', text: stripWrapperLenient(body), open }; } try { const tokens = tokenize(inner); if (tokens === undefined) { - return { kind: 'raw', text: inner }; + return { kind: 'raw', text: inner, open }; } const parser = new Parser(tokens); const expr = parser.parseExpr(); // Trailing tokens, such as the `()` of a method call, put the body outside // the subset, so the parse has to consume the whole stream. if (!parser.atEnd()) { - return { kind: 'raw', text: inner }; + return { kind: 'raw', text: inner, open }; } return { kind: 'structured', expr }; } catch { - return { kind: 'raw', text: inner }; + return { kind: 'raw', text: inner, open }; } } /** * The DSL surface string `irToDsl` writes into a condition or attribute: - * `amount > 1000` when structured, the quoted `"${...}"` fallback when raw, so - * an out-of-subset body survives the round trip with its text intact, rewrapped - * as `${...}`. + * `amount > 1000` when structured, the quoted raw template when raw, so an + * out-of-subset body survives the round trip with its text and opener intact. */ export function renderRawFallback(result: ExprResult): string { if (result.kind === 'raw') { - return `"\${${result.text}}"`; + return `"${result.open}{${escapeQuoted(result.text)}}"`; } return renderNode(result.expr); } +/** + * The body of a double-quoted DSL string, the exact inverse of the grammar's + * `convertString` on a `STRING` or `RAW_TEMPLATE` token: the reader resolves + * these five escapes and no other character needs one. + */ +export function escapeQuoted(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} + /** * Either delimiter opens an EL body, and Operaton evaluates one written with - * either the same way, so a `#{...}` body reads in here and prints back out - * through the `${...}` surface the DSL has a form for. + * either the same way, so a structured `#{...}` body prints bare and the + * rebuilt document writes it inside `${...}`. */ const OPEN_WRAPPER = /^[$#]\{/; @@ -382,7 +400,8 @@ class Parser { this.pos++; return { kind: 'null' }; case 'id': - return this.parseVarRef(); + this.pos++; + return this.parseVarRef(tok.value); case 'punct': if (tok.value === '(') { this.pos++; @@ -396,9 +415,8 @@ class Parser { } } - /** `id (.prop | [expr])*`. */ - private parseVarRef(): JuelNode { - const idTok = this.advance(); + /** `id (.prop | [expr])*`, entered with the id consumed. */ + private parseVarRef(name: string): JuelNode { const accessors: Accessor[] = []; for (;;) { const tok = this.peek(); @@ -421,22 +439,13 @@ class Parser { } break; } - return { kind: 'varRef', name: idTok.value, accessors }; + return { kind: 'varRef', name, accessors }; } private peek(): Token | undefined { return this.tokens[this.pos]; } - private advance(): Token { - const tok = this.tokens[this.pos]; - if (tok === undefined) { - throw new ParseError('unexpected end of input'); - } - this.pos++; - return tok; - } - private peekOp(): string | undefined { const tok = this.tokens[this.pos]; return tok?.type === 'op' ? tok.value : undefined; @@ -479,8 +488,10 @@ function renderNode(node: JuelNode): string { case 'decimal': return String(node.value); case 'string': - // Canonical form is double-quoted, so an embedded quote is re-escaped. - return `"${node.value.replace(/"/g, '\\"')}"`; + // The grammar's string reader turns `\b` into a backspace and drops any + // other lone backslash, so only the doubled form reads back; it is also + // the one escape `Scanner.nextString` in operaton-juel takes besides `\"`. + return `"${escapeQuoted(node.value)}"`; case 'bool': return node.value; case 'null': diff --git a/packages/transform/src/operaton-moddle.json b/packages/transform/src/operaton-moddle.json index 22d0ec4..9a092c3 100644 --- a/packages/transform/src/operaton-moddle.json +++ b/packages/transform/src/operaton-moddle.json @@ -531,6 +531,16 @@ } ] }, + { + "name": "PotentialStarter", + "superClass": ["Element"], + "properties": [ + { + "name": "resourceAssignmentExpression", + "type": "bpmn:ResourceAssignmentExpression" + } + ] + }, { "name": "Connector", "superClass": ["Element"], @@ -585,6 +595,19 @@ } ] }, + { + "name": "TimerEventDefinition", + "isAbstract": true, + "extends": ["bpmn:TimerEventDefinition"], + "properties": [ + { + "name": "exclusive", + "isAttr": true, + "type": "Boolean", + "default": true + } + ] + }, { "name": "Error", "isAbstract": true, diff --git a/packages/transform/src/synthesize-ids.ts b/packages/transform/src/synthesize-ids.ts index 5bddcc7..e9fb54a 100644 --- a/packages/transform/src/synthesize-ids.ts +++ b/packages/transform/src/synthesize-ids.ts @@ -12,9 +12,9 @@ import { BpmnScriptGrammar, reservedWordsOf } from '@bpmn-script/language'; -/** The prefixes the printer matches on to tell a minted id from an authored one. */ -export const START_EVENT_PREFIX = 'StartEvent_'; -export const END_EVENT_PREFIX = 'EndEvent_'; +const START_EVENT_PREFIX = 'StartEvent_'; +const END_EVENT_PREFIX = 'EndEvent_'; +/** The prefixes the printer matches on to tell a minted throw or catch from an authored one. */ export const THROW_EVENT_PREFIX = 'Throw_'; export const CATCH_EVENT_PREFIX = 'Catch_'; @@ -63,6 +63,32 @@ export function makeEndEventId(processId: string, taken: Set): string { return claimId(`${END_EVENT_PREFIX}${processId}`, taken); } +/** + * Whether `id` is exactly the start the compiler mints for the container. + * Exact rather than a prefix test: the Modeler names its default start + * `StartEvent_1`, which is an authored id like any other and has to print. + * The validator reserves the same two forms, so a script cannot spell them. + */ +export function isMintedStartId(id: string, containerId: string): boolean { + return id === `${START_EVENT_PREFIX}${containerId}`; +} + +/** + * The end the compiler mints for the container, or for a boundary escape in + * it, whose chain ends in `EndEvent_` inside the host container. + */ +export function isMintedEndId( + id: string, + containerId: string, + boundaryIds: Iterable, +): boolean { + if (id === `${END_EVENT_PREFIX}${containerId}`) return true; + for (const boundaryId of boundaryIds) { + if (id === `${END_EVENT_PREFIX}${boundaryId}`) return true; + } + return false; +} + export function makeThrowEventId(coordinate: string): string { return `${THROW_EVENT_PREFIX}${coordinate}`; } @@ -94,10 +120,11 @@ function claimId(base: string, taken: Set): string { return id; } -/** Mirrors the `ID` terminal, which is the only shape a declaration name has. */ -const ID_SHAPED = /^[_a-zA-Z]\w*(-\w+)*$/; +/** The grammar's `ID` terminal, the one shape a name in the script has. */ +export const ID_SHAPED = /^[_a-zA-Z]\w*(-\w+)*$/; -function isWritableName(word: string): boolean { +/** Whether the script can spell `word` as a name: `ID`-shaped and no keyword. */ +export function isWritableName(word: string): boolean { return ( ID_SHAPED.test(word) && !reservedWordsOf(BpmnScriptGrammar()).has(word) ); @@ -118,13 +145,17 @@ export function claimDeclarationName( const base = preferred !== undefined && isWritableName(preferred) ? preferred - : mintDeclarationName(code); + : mintPrintableName(code); return claimId(base, taken); } -/** A code a name cannot spell keeps its word characters and loses the rest. */ -function mintDeclarationName(code: string): string { - const sanitized = isWritableName(code) ? code : code.replace(/\W/g, '_'); +/** + * The name the script writes for text it cannot spell, an error code or an + * element id: the word characters kept and the rest replaced. Not claimed + * here, since the callers resolve collisions against different sets. + */ +export function mintPrintableName(text: string): string { + const sanitized = isWritableName(text) ? text : text.replace(/\W/g, '_'); // A keyword lexes as itself rather than as an `ID`, and a word opening on a // digit does not lex as one at all, so neither can name a declaration. An // underscore fixes both, and no keyword carries one. diff --git a/packages/transform/src/xml-to-ir.ts b/packages/transform/src/xml-to-ir.ts index d94db8a..4cdb5ae 100644 --- a/packages/transform/src/xml-to-ir.ts +++ b/packages/transform/src/xml-to-ir.ts @@ -4,8 +4,10 @@ * an `UnsupportedConstructError` subclass before any IR exists; content it does * not carry comes back in `warnings`. See ADR 0014, Honest BPMN Import. * - * `camunda:` is an accepted alias for `operaton:` on extension attributes, - * `operaton:` winning when both are set. Extension elements get no alias. + * The camunda namespace is normalized to the operaton one before parsing + * (`BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` falls back to the camunda URI + * wherever the operaton spelling is absent), so every `camunda:` attribute + * and element is read exactly as its `operaton:` spelling. */ import { @@ -22,6 +24,7 @@ import { FORM_CONSTRAINT_TYPES, formatPlainWordList, formatWordList, + scriptFormatOf, SHELL_FLAG_FIELDS, SHELL_FLAG_LITERALS, isFormConstraintName, @@ -73,6 +76,7 @@ import type { SubProcess, Task, TaskListener, + TimerJobKey, UserTask, VersionBinding, } from './ir/types.js'; @@ -81,6 +85,7 @@ import { eventIdentities, ioMapped, jobSettings, + splitTimerJobSettings, } from './ir/types.js'; import { @@ -89,6 +94,7 @@ import { UnsupportedCollaborationError, UnsupportedConditionExpressionError, UnsupportedConstructError, + UnsupportedDocumentError, UnsupportedElementError, UnsupportedErrorMappingError, UnsupportedEventDefinitionError, @@ -97,6 +103,7 @@ import { UnsupportedFormFieldConstraintError, UnsupportedFormFieldTypeError, UnsupportedFormReferenceError, + UnsupportedGatewayShapeError, UnsupportedLoopCharacteristicsError, UnsupportedServiceTaskFormError, } from './errors.js'; @@ -107,8 +114,11 @@ import { INDENT, irToDsl, isElidedOnPrint, + type PrintContainer, } from './ir-to-dsl.js'; +import { parseJuel } from './juel.js'; import { + conditionLabel, createModdle, FORM_FIELD_TYPE_TO_OPERATON, HISTORY_TIME_TO_LIVE, @@ -118,6 +128,7 @@ import { } from './ir-to-xml.js'; import { claimDeclarationName, + isWritableName, makeEventSubProcessId, makeStartEventId, } from './synthesize-ids.js'; @@ -198,8 +209,12 @@ const JOB_SETTING_OWNERS: readonly string[] = [ ]; /** - * A thrown message runs the same implementation, off its definition. The catch - * side carries the same element type and honors none of it, which is what + * A thrown message runs the same implementation, off its definition: + * `BpmnParse.parseIntermediateThrowEvent` and `parseEndEvents` hand + * `parseServiceTaskLike` the definition as the element to read, so the + * priority, the result variable, the fields and the error mappings are read + * off it too ({@link readThrownMessageBinding} reports each). The catch side + * carries the same element type and honors none of it, which is what * {@link warnCatchSideImplementationAttrs} reports. */ const IMPLEMENTATION_OWNERS = [ @@ -216,6 +231,30 @@ const IMPLEMENTATION_ATTRS = [ 'topic', ] as const; +/** What `parseServiceTaskLike` reads off the element beside the implementation. */ +const IMPLEMENTATION_EXTRA_ATTRS = [ + 'taskPriority', + 'resultVariable', + 'resultVariableName', +] as const; + +/** The extension children the same reader takes off the element. */ +const IMPLEMENTATION_EXTRA_CHILDREN: readonly string[] = [ + 'operaton:Field', + 'operaton:ErrorEventDefinition', +]; + +/** + * The tags `parseServiceTaskLike` is handed as the property-list element of a + * thrown message (`parseExternalServiceTask` reads `operaton:properties` off + * it), so the block is marked read on both and reported by hand on every + * shape ({@link readThrowEventAttributes}). + */ +const THROW_EVENT_TAGS: readonly string[] = [ + 'bpmn:IntermediateThrowEvent', + 'bpmn:EndEvent', +]; + function consumptionTable( entries: readonly (readonly [string, readonly string[]])[], ): ReadonlyMap> { @@ -232,12 +271,16 @@ function consumptionTable( * reported by hand with its own reason: the three `*Variable` names by * {@link warnThrowSideBindingAttrs}, `jobPriority` on a repetition by * {@link sweepRepetition}, every engine setting on a link throw by - * {@link warnLinkThrowEngineSettings}. + * {@link warnLinkThrowEngineSettings}, what `parseServiceTaskLike` reads off + * a message definition by {@link readThrownMessageBinding} on a throw and + * {@link warnCatchSideImplementationAttrs} on a catch, and a property list on + * an end or an intermediate throw by {@link readThrowEventAttributes}. */ const CONSUMED_EXTENSION_ATTRS = consumptionTable([ ['asyncBefore', JOB_SETTING_OWNERS], + ['async', JOB_SETTING_OWNERS], ['asyncAfter', JOB_SETTING_OWNERS], - ['exclusive', JOB_SETTING_OWNERS], + ['exclusive', [...JOB_SETTING_OWNERS, 'bpmn:TimerEventDefinition']], ['jobPriority', JOB_SETTING_OWNERS], ['assignee', ['bpmn:UserTask']], ['formKey', ['bpmn:UserTask']], @@ -254,11 +297,13 @@ const CONSUMED_EXTENSION_ATTRS = consumptionTable([ ['delegateExpression', IMPLEMENTATION_OWNERS], ['type', IMPLEMENTATION_OWNERS], ['topic', IMPLEMENTATION_OWNERS], - ['taskPriority', SERVICE_TASK_LIKE_OWNERS], - ['resultVariable', [...SERVICE_TASK_LIKE_OWNERS, 'bpmn:ScriptTask']], + ['taskPriority', IMPLEMENTATION_OWNERS], + ['resultVariable', [...IMPLEMENTATION_OWNERS, 'bpmn:ScriptTask']], + ['resultVariableName', [...IMPLEMENTATION_OWNERS, 'bpmn:ScriptTask']], ['decisionRef', ['bpmn:BusinessRuleTask']], ['decisionRefBinding', ['bpmn:BusinessRuleTask']], ['decisionRefVersion', ['bpmn:BusinessRuleTask']], + ['decisionRefTenantId', ['bpmn:BusinessRuleTask']], ['mapDecisionResult', ['bpmn:BusinessRuleTask']], ['calledElementBinding', ['bpmn:CallActivity']], ['calledElementVersion', ['bpmn:CallActivity']], @@ -286,12 +331,15 @@ const CONSUMED_EXTENSION_ELEMENTS = consumptionTable([ ['operaton:FailedJobRetryTimeCycle', JOB_SETTING_OWNERS], ['operaton:In', ['bpmn:CallActivity']], ['operaton:Out', ['bpmn:CallActivity']], - ['operaton:InputOutput', ACTIVITY_TAGS], + // The none throw imports as a step and carries the mapping; the throws + // with a definition report it by hand ({@link mapIntermediateThrowEvent}). + ['operaton:InputOutput', [...ACTIVITY_TAGS, 'bpmn:IntermediateThrowEvent']], ['operaton:ExecutionListener', ENGINE_ATTRIBUTE_OWNERS], ['operaton:TaskListener', ['bpmn:UserTask']], - ['operaton:Field', SERVICE_TASK_LIKE_OWNERS], - ['operaton:Properties', SERVICE_TASK_LIKE_OWNERS], - ['operaton:ErrorEventDefinition', SERVICE_TASK_LIKE_OWNERS], + ['operaton:Field', IMPLEMENTATION_OWNERS], + ['operaton:Properties', [...SERVICE_TASK_LIKE_OWNERS, ...THROW_EVENT_TAGS]], + ['operaton:ErrorEventDefinition', IMPLEMENTATION_OWNERS], + ['operaton:PotentialStarter', ['bpmn:Process']], ]); /** @@ -312,6 +360,7 @@ const CONSUMED_EXTENSION_ELEMENTS = consumptionTable([ */ const CONSUMED_CHILD_ATTRS = consumptionTable([ ['operaton:FormData', []], + ['operaton:PotentialStarter', []], [ 'operaton:FormField', ['id', 'label', 'type', 'defaultValue', 'datePattern'], @@ -364,9 +413,12 @@ const CONSUMED_CHILD_ATTRS = consumptionTable([ 'operaton:ExecutionListener', ['event', 'class', 'expression', 'delegateExpression'], ], + // `id` keys a timeout listener's timer job (`BpmnParse.parseTimeoutTaskListener`) + // and the export mints one per timeout listener, so the one read is dropped + // without a word; on any other event `parseTaskListener` never reads it. [ 'operaton:TaskListener', - ['event', 'class', 'expression', 'delegateExpression'], + ['id', 'event', 'class', 'expression', 'delegateExpression'], ], ['operaton:Field', ['name', 'stringValue']], ]); @@ -408,13 +460,80 @@ function invert( const OPERATON_TO_FORM_FIELD_TYPE: Readonly> = invert(FORM_FIELD_TYPE_TO_OPERATON); -const KEPT_SETTINGS_NOTE = - '(this tool keeps the assignee, form, form reference, script, ' + - 'service-task binding, injected fields, result variable, version tag, ' + - "input/output mappings and listeners, an external task's priority, " + - 'properties and error mappings, and the async, retry, job-priority and ' + - 'task-assignment settings; a gateway carries the async, retry and ' + - 'job-priority settings and nothing else).'; +/** + * What Operaton reads off an owner kind whose IR node has no slot for it, + * keyed `/`, each with the + * clause naming the reader. {@link warnUnimportedSetting} appends the clause, + * so a drop the engine would have acted on says so; a name absent here is + * one no engine method reads on that owner. + * + * A row keyed with a `throw:` or `initial:` prefix only matches a drop swept + * under that qualifier: `async` and `operaton:In` on a signal definition are + * read by `parseSignalEventDefinition` on the thrown side alone (a catch's + * definition runs with `isThrowing=false`, which skips both), and + * `parseStartFormHandlers` runs for the process's own start alone, never for + * a nested one. The bare key would otherwise claim the engine reads a catch + * or a nested start's copy, which it does not. + */ +const ENGINE_READS_ELSEWHERE: ReadonlyMap = new Map< + string, + string +>([ + [ + 'throw:bpmn:SignalEventDefinition/async', + 'reads it on a thrown signal as its async delivery ' + + '(BpmnParse.parseSignalEventDefinition)', + ], + [ + 'throw:bpmn:SignalEventDefinition/operaton:In', + 'reads it on a thrown signal as its payload ' + + '(BpmnParse.parseSignalEventDefinition through parseInputParameter)', + ], + ...[ + 'formKey', + 'formRef', + 'formRefBinding', + 'formRefVersion', + 'formHandlerClass', + ].map((name): [string, string] => [ + `initial:bpmn:StartEvent/${name}`, + "reads it on the process's own start (BpmnParse.parseStartFormHandlers)", + ]), + ...['jobPriority', 'taskPriority'].map((name): [string, string] => [ + `bpmn:Process/${name}`, + 'reads it (BpmnParse.parseProcess through parsePriority)', + ]), + [ + 'bpmn:Process/isStartableInTasklist', + 'reads it (BpmnParse.parseProcess through isStartable)', + ], + [ + 'bpmn:Process/operaton:ExecutionListener', + 'runs it on the process instance ' + + '(BpmnParse.parseExecutionListenersOnScope)', + ], + [ + 'bpmn:SequenceFlow/operaton:ExecutionListener', + 'runs it when the flow is taken, whatever its event says ' + + '(BpmnParse.parseExecutionListenersOnTransition)', + ], + ...GATEWAY_TAGS.map((tag): [string, string] => [ + `${tag}/operaton:ExecutionListener`, + 'runs it (BpmnParse.parseExecutionListenersOnScope)', + ]), + [ + 'bpmn:IntermediateThrowEvent/operaton:InputOutput', + 'reads it on every throw but a link (BpmnParse.parseActivityInputOutput)', + ], + [ + 'bpmn:IntermediateCatchEvent/operaton:InputOutput', + 'reads it (BpmnParse.parseActivityInputOutput)', + ], + [ + 'bpmn:EndEvent/operaton:InputOutput', + 'reads its input parameters (BpmnParse.parseActivityInputOutput)', + ], +]); const IMPORTED_FLOW_NOTE = '(this tool imports the executable flow and the engine settings on its ' + @@ -472,18 +591,42 @@ function unresolvedRef(el: ModdleElement, name: string): string | undefined { } /** - * Parse a BPMN 2.0 XML document into the IR. Throws when the XML is malformed, - * has no `bpmn:Process`, or has more than one. + * An `xmlns` declaration binding a prefix to the camunda namespace every + * Camunda Modeler export declares. Only a declaration is rewritten: the same + * URI inside a `bpmn:documentation` body or a script is text the engine never + * reads as a namespace. + */ +const CAMUNDA_XMLNS = + /(xmlns(?::[\w.-]+)?\s*=\s*)(["'])http:\/\/camunda\.org\/schema\/1\.0\/bpmn\2/g; +/** The namespace whose lookup falls back to the camunda URI, `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS`. */ +const OPERATON_NS = 'http://operaton.org/schema/1.0/bpmn'; + +/** + * Parse a BPMN 2.0 XML document into the IR. Throws when the XML is malformed + * or when the document as a whole is one Operaton refuses or this tool cannot + * read as one process ({@link refuseDocumentShapes}, {@link selectProcess}). */ export async function xmlToIr( xml: string, ): Promise<{ ir: BpmnProcess; warnings: ImportWarning[] }> { const moddle = createModdle(); + refuseDocumentShapes(xml); + + // `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` is one `Namespace` whose lookup + // falls back to the camunda URI when the operaton spelling is absent, so + // the engine runs a `camunda:` element exactly as its `operaton:` spelling. + // moddle-xml binds a prefix to a package by URI, not by spelling, so + // rewriting the declared URI alone (never the prefix, which can recur + // inside attribute values) makes every `camunda:` element and attribute + // arrive typed and readable as `operaton:`, with no separate alias code. + const normalizedXml = xml.replace(CAMUNDA_XMLNS, `$1$2${OPERATON_NS}$2`); + const camundaRead = normalizedXml !== xml; // moddle records "unparsable content" only for elements in the registered // `operaton:` namespace whose type the extension does not declare. Declared // operaton and foreign-namespace elements materialize as values instead. - const { rootElement, warnings: moddleWarnings } = await moddle.fromXML(xml); + const { rootElement, warnings: moddleWarnings } = + await moddle.fromXML(normalizedXml); const root = rootElement as ModdleElement; if (root.$type !== 'bpmn:Definitions') { @@ -492,31 +635,29 @@ export async function xmlToIr( ); } - const rootElements = (root.get('rootElements') as ModdleElement[]) ?? []; + refuseDroppedIds(moddleWarnings); + refuseImports(root); - if (rootElements.some((e) => e.$type === 'bpmn:Collaboration')) { - throw new UnsupportedCollaborationError( - 'multiple linked processes (pools and message flows)', - ); - } - - const processes = rootElements.filter((e) => e.$type === 'bpmn:Process'); - if (processes.length === 0) { - throw new Error( - 'BPMN document contains no root element: nothing to import.', - ); - } - if (processes.length > 1) { - throw new Error( - 'Multi-process definitions are not supported ' + - `(found ${processes.length} elements).`, - ); - } + const rootElements = (root.get('rootElements') as ModdleElement[]) ?? []; + const warnings: ImportWarning[] = []; + const processEl = selectProcess(rootElements, warnings); recordUnresolvedRefs(moddleWarnings); + const recoveredPositions = recordDroppedConditions(root, normalizedXml); - const warnings: ImportWarning[] = []; - const mappedProcess = mapProcess(processes[0], warnings); + const mappedProcess = mapProcess(processEl, warnings); + + if (camundaRead) { + warnings.push({ + elementId: mappedProcess.id, + category: 'unmappedConstruct', + message: + 'The file declares the camunda namespace; it was read as the ' + + 'operaton namespace, since `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` ' + + 'falls back to the camunda URI wherever the operaton spelling is ' + + 'absent, and the document written back carries `operaton:` alone.', + }); + } // One name space across both lists: an error and an escalation declaration // share the scope a use site resolves in, so a name taken by one is taken. @@ -538,9 +679,12 @@ export async function xmlToIr( ...(errorDecls.length > 0 ? { errorDecls } : {}), ...(escalationDecls.length > 0 ? { escalationDecls } : {}), }; + warnCollaborationDrops(rootElements, processEl, warnings); + const signalRoots = rootElements.filter((e) => e.$type === 'bpmn:Signal'); + refuseDuplicateSignalNames(signalRoots); warnUnreferencedRoots( rootElements.filter((e) => e.$type === 'bpmn:Message'), - rootElements.filter((e) => e.$type === 'bpmn:Signal'), + signalRoots, ir, warnings, ); @@ -553,14 +697,230 @@ export async function xmlToIr( collectUnparsableResidualDrops( moddleWarnings, - xml, + normalizedXml, ir.id, warnings, reportedRootIds, + recoveredPositions, ); return { ir, warnings }; } +/** The entities XML predefines; every other `&name;` needs a DOCTYPE to declare it. */ +const UNDEFINED_ENTITY = /&(?!(?:amp|lt|gt|quot|apos);)[A-Za-z][\w.-]*;/; + +/** + * Refuse, on the raw text, what the engine's SAX parse refuses before + * `BpmnParse` sees an element, and the one attribute shape moddle reads + * differently from the engine. Comments and CDATA sections are cut first: a + * script body may legitimately hold `&name;` or `|/g, ''); + if (/ declaration, which Operaton refuses to deploy ' + + '(`Parser.setXxeProcessing` sets disallow-doctype-decl unless XXE ' + + 'processing is enabled) and whose entities this tool does not expand', + ); + } + const entity = UNDEFINED_ENTITY.exec(text); + if (entity !== null) { + throw new UnsupportedDocumentError( + `it references the entity '${entity[0]}', which XML does not predefine ` + + "and no DOCTYPE declares, so Operaton's parse fails on it and this " + + 'tool would keep it as literal text', + ); + } + for (const [, name, , value] of text.matchAll( + /<[^<>]*?\s(isSequential|triggeredByEvent)\s*=\s*(["'])(.*?)\2/g, + )) { + if (value === 'true' || value === 'false') continue; + throw new UnsupportedDocumentError( + `${name}="${value}" is outside true and false; this tool reads the ` + + "schema's boolean, while Operaton's validating parse (`Parse." + + `execute\` against \`BPMN20.xsd\`, which types \`${name}\` ` + + '`xsd:boolean`) refuses the deployment before `BpmnParse` reads it', + ); + } +} + +/** + * moddle-xml drops an element whose id repeats or falls outside its ASCII + * pattern, leaving a warning and a dangling reference; the engine validates + * every file against `BPMN20.xsd` (`BpmnParse` sets the schema, `Parse.execute` + * runs a validating parse, `ParseHandler.error` files each violation) and + * refuses a duplicate `xs:ID`, while it deploys a non-ASCII NCName this tool + * cannot read. + */ +function refuseDroppedIds(moddleWarnings: unknown): void { + for (const warning of (moddleWarnings as ModdleWarning[] | undefined) ?? []) { + const match = /nested error: (duplicate|illegal) ID <([^>]+)>/.exec( + String(warning.message ?? ''), + ); + if (match === null) continue; + const [, kind, id] = match; + throw new UnsupportedDocumentError( + kind === 'duplicate' + ? `the id '${id}' is written on two elements; Operaton validates ` + + 'every file against BPMN20.xsd and refuses a duplicate xs:ID' + : `the id '${id}' is outside what this tool reads: ASCII letters, ` + + "digits, '_', '-' and '.', starting with a letter or '_', " + + 'where the schema admits any letter; rename it', + ); + } +} + +/** + * `BpmnParse.parseImports` fails the deployment on every `importType` but + * `http://schemas.xmlsoap.org/wsdl/`, and on that one without + * `CxfWSDLImporter` on the classpath. + */ +function refuseImports(root: ModdleElement): void { + const [theImport] = + (root.get('imports') as ModdleElement[] | undefined) ?? []; + if (theImport === undefined) return; + throw new UnsupportedDocumentError( + `it declares a bpmn:import of type '${readString(theImport, 'importType') ?? ''}'; ` + + '`BpmnParse.parseImports` fails the deployment on every import type ' + + 'but WSDL, and on WSDL without CxfWSDLImporter on the classpath', + ); +} + +/** + * The one process to import: the one marked `isExecutable="true"`, which is + * the one `BpmnParse.parseProcessDefinitions` deploys (an absent attribute + * deploys nothing in a new deployment, `!deployment.isNew()`). Every other + * process is a warned drop. Two executable processes deploy side by side and + * the IR holds one, so they refuse; several processes of which none is + * executable deploy nothing, so they refuse too, while a lone unmarked process + * imports with {@link mapProcess}'s warning. + */ +function selectProcess( + rootElements: ModdleElement[], + warnings: ImportWarning[], +): ModdleElement { + const processes = rootElements.filter((e) => e.$type === 'bpmn:Process'); + if (processes.length === 0) { + throw new UnsupportedDocumentError( + 'it holds no bpmn:process, so there is nothing to import', + ); + } + const executable = processes.filter((p) => p.get('isExecutable') === true); + if (executable.length > 1) { + throw new UnsupportedCollaborationError( + `${executable.length === 2 ? 'two' : executable.length} executable ` + + `processes (${executable.map((p) => `'${p.id}'`).join(', ')})`, + ); + } + if (executable.length === 0) { + if (processes.length > 1) { + throw new UnsupportedDocumentError( + `none of its ${processes.length} processes is marked ` + + 'isExecutable="true"; `BpmnParse.parseProcessDefinitions` deploys ' + + 'none of them in a new deployment', + ); + } + return processes[0]; + } + const [selected] = executable; + for (const skipped of processes) { + if (skipped === selected) continue; + warnings.push({ + elementId: skipped.id ?? selected.id ?? '', + category: 'unmappedConstruct', + message: + `The process '${skipped.id}' is not marked executable and was not ` + + 'imported; `BpmnParse.parseProcessDefinitions` does not deploy it either.', + }); + } + return selected; +} + +/** + * One warning per participant and per message flow. `BpmnParse.parseCollaboration` + * records a participant's `processRef` for `parseBPMNShape` alone, and no + * method in `BpmnParse` reads a message flow, so a collaboration is diagram + * data: the imported process runs identically, and the document written back + * has no pool. Anything else hung on the collaboration (an artifact) is swept + * like any unread BPMN child. + */ +function warnCollaborationDrops( + rootElements: ModdleElement[], + processEl: ModdleElement, + warnings: ImportWarning[], +): void { + for (const collab of rootElements) { + if (collab.$type !== 'bpmn:Collaboration') continue; + const collabId = collab.id ?? processEl.id ?? ''; + for (const p of (collab.get('participants') as ModdleElement[]) ?? []) { + const id = p.id ?? collabId; + const ref = getEl(p, 'processRef'); + const names = + ref === processEl + ? `the imported process '${processEl.id}'` + : ref !== undefined + ? `process '${ref.id}', which was not imported` + : 'no process this document holds'; + warnings.push({ + elementId: id, + category: 'unmappedConstruct', + message: + `The pool ${describePoolName(p)}(${id}) names ${names}; ` + + '`BpmnParse.parseCollaboration` records it for the diagram alone, ' + + 'and the document written back has no pool.', + }); + } + for (const flow of (collab.get('messageFlows') as ModdleElement[]) ?? []) { + const id = flow.id ?? collabId; + const end = (name: string): string => + getEl(flow, name)?.id ?? unresolvedRef(flow, name) ?? '?'; + warnings.push({ + elementId: id, + category: 'unmappedConstruct', + message: + `The message flow '${id}' from '${end('sourceRef')}' to ` + + `'${end('targetRef')}' was not imported; \`BpmnParse\` reads no ` + + 'message flow, so the process runs identically without it.', + }); + } + collectUnmappedBpmnDrops(collab, collabId, warnings); + } +} + +function describePoolName(participant: ModdleElement): string { + const name = readString(participant, 'name'); + return name === undefined ? '' : `'${name}' `; +} + +/** `BpmnParse.parseSignals` fails the deployment on a duplicate signal name. */ +function refuseDuplicateSignalNames(signalRoots: ModdleElement[]): void { + const byName = new Map(); + for (const root of signalRoots) { + const name = readString(root, 'name'); + if (name === undefined) continue; + const rootId = requireId(root); + const prior = byName.get(name); + if (prior !== undefined) { + throw new UnsupportedEventFeatureError( + rootId, + `signal roots '${prior}' and '${rootId}' both declare the name ` + + `"${name}"; \`BpmnParse.parseSignals\` fails the deployment on a ` + + 'duplicate signal name', + 'Leave one root per signal name.', + ); + } + byName.set(name, rootId); + } +} + /** * The declarations `bpmn:Error` and `bpmn:Escalation` roots carry, in document * order, deduped by code. Referenced or not: the surface holds a declaration @@ -769,6 +1129,135 @@ function positionsUnderRoots( return positions; } +/** A `` moddle dropped, read back off the source text by {@link recordDroppedConditions}. */ +interface DroppedCondition { + xsiType: string; + body: string; + /** Where moddle's warning places the element, so the residual sweep skips it. */ + position: string; + language?: string; + /** The `resource` attribute, whichever prefix carries it in source text. */ + resource?: string; +} + +/** Keyed by the flow the element sat in; see {@link UNRESOLVED_REFS} for why a WeakMap. */ +const DROPPED_CONDITIONS = new WeakMap(); + +/** + * moddle resolves an unprefixed `xsi:type` against the document's default + * namespace, where `BpmnParse.parseConditionExpression` resolves it against + * `BPMN20_NS` whatever the document declares, so `` in a prefixed document reaches no reader + * (moddle drops it as unparsable content) while the engine deploys and + * evaluates it. The two agree on a prefixed type and in a default-namespace + * document. Such an element is read back off the source text and keyed by + * its flow, so {@link mapSequenceFlow} reads it as the engine does; the + * positions of the ones taken are returned for the residual sweep to skip. + */ +function recordDroppedConditions( + root: ModdleElement, + xml: string, +): ReadonlySet { + const positions = new Set(); + const byFlowId = scanUnprefixedConditions(xml); + if (byFlowId.size === 0) return positions; + for (const flow of flowElementsDeep(root)) { + if (flow.$type !== 'bpmn:SequenceFlow') continue; + if (getEl(flow, 'conditionExpression') !== undefined) continue; + const dropped = flow.id === undefined ? undefined : byFlowId.get(flow.id); + if (dropped === undefined) continue; + DROPPED_CONDITIONS.set(flow, dropped); + positions.add(dropped.position); + } + return positions; +} + +/** Every flow element under `container`, containers' own children included. */ +function* flowElementsDeep(container: ModdleElement): Generator { + const children = [ + ...((container.get('rootElements') as ModdleElement[] | undefined) ?? []), + ...((container.get('flowElements') as ModdleElement[] | undefined) ?? []), + ]; + for (const child of children) { + yield child; + yield* flowElementsDeep(child); + } +} + +/** The `conditionExpression` elements typed without a prefix, by the id of the flow each sits in. */ +function scanUnprefixedConditions(xml: string): Map { + const found = new Map(); + const parser = new Parser(); + let flowId: string | undefined; + let open: (DroppedCondition & { flowId: string }) | undefined; + parser.on( + 'openTag', + (elementName, getAttrs, _decodeEntities, selfClosing, getContext) => { + const local = localNameOf(elementName); + if (local === 'sequenceFlow') { + flowId = selfClosing ? undefined : getAttrs()['id']; + return; + } + if (local !== 'conditionExpression' || flowId === undefined) return; + const attrs = getAttrs(); + const xsiType = attrs['xsi:type']; + if (xsiType === undefined || xsiType.includes(':')) return; + const { line, column } = getContext(); + open = { + flowId, + xsiType, + body: '', + position: positionKey(line, column), + language: nonEmptyAttr(attrs, 'language'), + resource: nonEmptyAttr(attrs, 'resource'), + }; + if (selfClosing) { + found.set(flowId, open); + open = undefined; + } + }, + ); + parser.on('text', (value, decodeEntities) => { + if (open !== undefined) open.body += decodeEntities(value); + }); + parser.on('cdata', (value) => { + if (open !== undefined) open.body += value; + }); + parser.on('closeTag', (elementName) => { + const local = localNameOf(elementName); + if (local === 'conditionExpression' && open !== undefined) { + found.set(open.flowId, open); + open = undefined; + } else if (local === 'sequenceFlow') { + flowId = undefined; + } + }); + parser.parse(xml); + return found; +} + +function localNameOf(qualifiedName: string): string { + return qualifiedName.slice(qualifiedName.indexOf(':') + 1); +} + +/** + * An attribute of a raw-text tag, found by local name whatever prefix binds + * it: the namespace swap in {@link xmlToIr} only rewrites `xmlns:*` values, + * so a document's own choice of `camunda:` or `operaton:` survives on the + * attribute itself. Empty and absent both read as `undefined`, matching + * {@link readString} and {@link readNamespacedAttr} on the moddle-parsed path. + */ +function nonEmptyAttr( + attrs: Record, + localName: string, +): string | undefined { + for (const [key, value] of Object.entries(attrs)) { + if (localNameOf(key) === localName) + return value.length > 0 ? value : undefined; + } + return undefined; +} + /** * One {@link ImportWarning} per residual "unparsable content" moddle warning, * attributed to the process because moddle cannot tie the dropped element to a @@ -776,7 +1265,8 @@ function positionsUnderRoots( * * A residual written inside a root {@link collectRootDrops} already reported * whole is skipped, so the root does not draw a second warning blamed on the - * process for its own child. + * process for its own child; so is a condition {@link recordDroppedConditions} + * read back, which its flow carries. */ function collectUnparsableResidualDrops( moddleWarnings: unknown, @@ -784,9 +1274,13 @@ function collectUnparsableResidualDrops( processId: string, warnings: ImportWarning[], reportedRootIds: ReadonlySet, + recoveredPositions: ReadonlySet, ): void { const list = (moddleWarnings as ModdleWarning[] | undefined) ?? []; - const underReportedRoot = positionsUnderRoots(xml, reportedRootIds); + const underReportedRoot = new Set([ + ...positionsUnderRoots(xml, reportedRootIds), + ...recoveredPositions, + ]); for (const warning of list) { const message = String(warning.message ?? ''); const match = /unparsable content <([^>]+)>/i.exec(message); @@ -807,7 +1301,7 @@ function collectUnparsableResidualDrops( category: 'extensionAttribute', message: `Extra engine-specific configuration (${construct}${location}) was not ` + - `imported; it could not be attributed to a specific step ${KEPT_SETTINGS_NOTE}`, + 'imported; it could not be attributed to a specific step.', }); } } @@ -827,8 +1321,11 @@ function mapProcess( const named = readNamed(processEl, id, warnings); // The one place import changes what the document says rather than leaving - // something out, so it gets its own wording. - if (processEl.get('isExecutable') === false) { + // something out, so it gets its own wording. An absent attribute is the + // same change: `BpmnParse.parseProcessDefinitions` defaults it to + // `!deployment.isNew()`, false for every deployment of a new resource. + const isExecutable = processEl.get('isExecutable'); + if (isExecutable === false) { warnings.push({ elementId: id, category: 'unmappedConstruct', @@ -838,18 +1335,27 @@ function mapProcess( 'and is written back as one, so an engine will deploy and run what ' + 'the source document held back.', }); + } else if (isExecutable !== true) { + warnings.push({ + elementId: id, + category: 'unmappedConstruct', + message: + `The process '${id}' is not marked isExecutable="true", which ` + + '`BpmnParse.parseProcessDefinitions` skips in a new deployment; it ' + + 'was imported as an executable process and is written back as one.', + }); } collectLaneDrops(processEl, id, warnings); collectExtensionDrops(processEl, id, warnings); collectUnmappedBpmnDrops(processEl, id, warnings); + const starters = readPotentialStarters(processEl, id, warnings); const { flowElements, sequenceFlows } = mapContainer( processEl, warnings, 'process', ); - const versionTag = readNamespacedAttr(processEl, 'versionTag'); // The exporter stamps HISTORY_TIME_TO_LIVE on every process that authored // none, so reading that exact value back would invent a setting the source @@ -860,13 +1366,13 @@ function mapProcess( authoredTimeToLive === HISTORY_TIME_TO_LIVE ? undefined : authoredTimeToLive; - const candidateStarterUsers = readNamespacedAttr( - processEl, - 'candidateStarterUsers', + const candidateStarterUsers = mergeCandidates( + starters.users, + readNamespacedAttr(processEl, 'candidateStarterUsers'), ); - const candidateStarterGroups = readNamespacedAttr( - processEl, - 'candidateStarterGroups', + const candidateStarterGroups = mergeCandidates( + starters.groups, + readNamespacedAttr(processEl, 'candidateStarterGroups'), ); return { @@ -882,6 +1388,52 @@ function mapProcess( }; } +/** + * `BpmnParse.parseStartAuthorization` reads every `operaton:potentialStarter` + * into the candidate-starter lists before the two attributes append theirs, + * splitting each formal expression as `parsePotentialOwnerResourceAssignment` + * splits a task's: the same respelling a `bpmn:potentialOwner` gets. + */ +function readPotentialStarters( + processEl: ModdleElement, + id: string, + warnings: ImportWarning[], +): { users: string[]; groups: string[] } { + const users: string[] = []; + const groups: string[] = []; + const report = (message: string): void => { + warnings.push({ elementId: id, category: 'unmappedConstruct', message }); + }; + for (const starter of extensionValues(processEl)) { + if (starter.$type !== 'operaton:PotentialStarter') continue; + const text = formalExpressionTextOf(starter); + if (text === undefined) { + report( + `The operaton:potentialStarter on '${id}' was not imported: it ` + + 'carries no formal expression, and Operaton reads nothing else off ' + + 'it (BpmnParse.parsePotentialStarterResourceAssignment).', + ); + continue; + } + const split = splitCandidates(text); + users.push(...split.users); + groups.push(...split.groups); + const became: [key: string, value: string[]][] = [ + ['candidateStarterUsers', split.users], + ['candidateStarterGroups', split.groups], + ].filter((entry): entry is [string, string[]] => entry[1].length > 0); + report( + `The operaton:potentialStarter on '${id}' imports as ` + + `${became.map(([key, value]) => `${key}: "${value.join(',')}"`).join(' and ')}: ` + + 'Operaton reads its formal expression that way ' + + '(BpmnParse.parsePotentialStarterResourceAssignment), and this tool ' + + `writes it back as ${became.map(([key]) => `operaton:${key}`).join(' and ')}, ` + + 'which the engine reads the same.', + ); + } + return { users, groups }; +} + /** * Which container hosts the element being mapped, threaded down because moddle * offers no `$parent`: an undo handler sits directly inside the block whose @@ -895,7 +1447,7 @@ function mapContainer( warnings: ImportWarning[], hostKind: ContainerHostKind, ): { flowElements: FlowElement[]; sequenceFlows: SequenceFlow[] } { - refuseMultipleStartEvents(el, hostKind); + checkStartEventCount(el, hostKind, warnings); return mapContainerChildren( el, warnings, @@ -906,25 +1458,69 @@ function mapContainer( /** * A process takes several start events, one per `start` statement of its - * body. A subprocess or a transaction takes one: `BpmnParse.parseScopeStartEvent` - * errors deployment on the second start of any scope that is not a process. - * An event handler is checked in {@link mapEventSubProcess}. + * body, but `BpmnParse.selectInitial` fails the deployment on a second plain + * or timer start. A subprocess or a transaction takes exactly one: + * `BpmnParse.parseScopeStartEvent` errors on the second. With none, + * `BpmnParse.parseStartEvents` errors on a `process` or `subProcess` tag + * alone, so a transaction with no start deploys and + * `SubProcessActivityBehavior.execute` throws on entering it, where the + * script adds a start and runs. An event handler is checked in + * {@link mapEventSubProcess}. */ -function refuseMultipleStartEvents( +function checkStartEventCount( el: ModdleElement, hostKind: ContainerHostKind, + warnings: ImportWarning[], ): void { - if (hostKind === 'process') return; + const id = requireId(el); const children = (el.get('flowElements') as ModdleElement[]) ?? []; const starts = children.filter((c) => c.$type === 'bpmn:StartEvent'); + if (starts.length === 0 && hostKind !== 'transaction') { + throw new UnsupportedEventFeatureError( + id, + 'it has no start event, which BpmnParse.parseStartEvents fails the ' + + `deployment on ("${hostKind} must define a startEvent element")`, + 'Add a start event and lead it to the first step.', + ); + } + if (hostKind === 'process') { + const initialCandidates = starts.filter((start) => + eventDefinitionsOf(start).every( + (def) => def.$type === 'bpmn:TimerEventDefinition', + ), + ); + if (initialCandidates.length > 1) { + throw new UnsupportedEventFeatureError( + requireId(initialCandidates[1]), + `the process '${id}' has ${initialCandidates.length} plain or timer ` + + 'starts, which BpmnParse.selectInitial fails the deployment on ' + + '("multiple none start events or timer start events not ' + + 'supported on process definition")', + 'Leave one plain or timer start; the others may carry a message, ' + + 'signal, or condition trigger.', + ); + } + return; + } if (starts.length > 1) { throw new UnsupportedEventFeatureError( - requireId(el), + id, `it has ${starts.length} start events; this tool writes one entry ` + 'point per subprocess or transaction, so a second start has nowhere to go', 'Leave one start and connect the steps that followed the others onto it.', ); } + if (starts.length === 0) { + warnings.push({ + elementId: id, + category: 'unmappedConstruct', + message: + `The bpmn:transaction '${id}' has no start event: Operaton deploys ` + + 'it and SubProcessActivityBehavior.execute fails on entering it ' + + '("No initial activity found"); the script adds a start, so the ' + + 'imported block runs.', + }); + } } const ACTIVITY_TYPES: ReadonlySet = new Set(ACTIVITY_TAGS); @@ -1125,12 +1721,12 @@ function describePairedBoundary( * content this tool cannot import either, so the caller falls back to the * general wording instead of a rewrite it cannot show. * - * The synthesized start/end ids never need to dodge the reserved-name check: - * a plain, unlabeled synthesized start is elided from the printed body - * entirely (`isElidedOnPrint`), the same way one is on any ordinary import. - * Only the wrapper's own id has to clear it, since it is the one statement - * that prints its id; `Compensated_` does, because every reserved - * pattern names a different prefix. + * The synthesized start never needs to dodge the reserved-name check: a + * plain, unlabeled start under the id minted for its handler is elided from + * the printed body entirely (`isElidedOnPrint`), the same way one is on any + * ordinary import. Only the wrapper's own id has to clear it, since it is the + * one statement that prints its id; `Compensated_` does, because every + * reserved pattern names a different prefix. */ function describeCompensationRewrite( hostEl: ModdleElement, @@ -1154,14 +1750,17 @@ function describeCompensationRewrite( if (compensated === undefined || handler === undefined) return undefined; const wrapperId = `Compensated_${hostId}`; + const handlerId = makeEventSubProcessId(wrapperId); const compensationStart: FlowElement = { kind: 'startEvent', - id: makeStartEventId(wrapperId, new Set()), + // Minted for the handler it sits in, as the compiler mints it, so the + // printer elides it the way it elides the compiler's own. + id: makeStartEventId(handlerId, new Set()), eventDefinition: { kind: 'compensation' }, }; const onCompensation: SubProcess = { kind: 'subProcess', - id: makeEventSubProcessId(wrapperId), + id: handlerId, triggeredByEvent: true, flowElements: [compensationStart, handler], sequenceFlows: [], @@ -1188,37 +1787,10 @@ function describeCompensationRewrite( } /** - * Report the fallback route named on an activity. `createActivityOnScope` puts - * `default` on every activity Operaton parses, and `performOutgoingBehavior` - * takes that route when it selected no other, so dropping it changes what runs. - * The IR carries it on the two split kinds alone. - * - * A BPMN-declared attribute is invisible to both generic sweeps: it never - * reaches `$attrs`, and the declared-attribute sweep looks at `operaton:` - * attributes only. Every such drop is therefore reported by hand. - */ -function warnDroppedDefaultFlow( - el: ModdleElement, - id: string, - warnings: ImportWarning[], -): void { - if (!ACTIVITY_TYPES.has(el.$type)) return; - const defaultFlowId = getEl(el, 'default')?.id; - if (defaultFlowId === undefined) return; - warnings.push({ - elementId: id, - category: 'unmappedConstruct', - message: - `The 'default' attribute on '${id}' was not imported: it names the ` + - `route ('${defaultFlowId}') Operaton takes when no other route out of ` + - 'the step is taken, and this tool carries a fallback on a split alone, ' + - 'so the imported flow names none here.', - }); -} - -/** - * `BpmnParse` reads neither quantity attribute. Reported by hand, for the - * reason {@link warnDroppedDefaultFlow} gives. + * `BpmnParse` reads neither quantity attribute. A BPMN-declared attribute is + * invisible to both generic sweeps: it never reaches `$attrs`, and the + * declared-attribute sweep looks at `operaton:` attributes only. Every such + * drop is therefore reported by hand. */ function warnIgnoredQuantityAttrs( el: ModdleElement, @@ -1257,32 +1829,21 @@ function mapContainerChildren( ): { flowElements: FlowElement[]; sequenceFlows: SequenceFlow[] } { const flowElements: FlowElement[] = []; const sequenceFlows: SequenceFlow[] = []; + // What the printer's elision reads: the container's id, which seeds the + // minted start and end, and its elements, asked once the list is complete. + const container = { id: requireId(el), flowElements }; const children = (el.get('flowElements') as ModdleElement[]) ?? []; for (const child of children) { refuseIfForCompensation(child, el); const mappedAt = flowElements.length; switch (child.$type) { - case 'bpmn:StartEvent': { - // Only here is it known whether a start's trigger moves into an `on` - // header, which decides whether the statement prints at all. The - // starts before it decide too, so it joins the list before asking. - const start = mapStart(child); - flowElements.push(start); - warnElidedNamedDrop( - start, - flowElements, - hostKind === 'eventSubProcess', - warnings, - ); + case 'bpmn:StartEvent': + flowElements.push(mapStart(child)); break; - } - case 'bpmn:EndEvent': { - const end = mapEndEvent(child, warnings, hostKind); - flowElements.push(end); - warnElidedNamedDrop(end, flowElements, false, warnings); + case 'bpmn:EndEvent': + flowElements.push(mapEndEvent(child, warnings, hostKind)); break; - } case 'bpmn:IntermediateThrowEvent': flowElements.push(mapIntermediateThrowEvent(child, warnings)); break; @@ -1352,19 +1913,189 @@ function mapContainerChildren( default: throw new UnsupportedElementError(child.$type, child.id); } - attachRepetition(flowElements, mappedAt, child, warnings); - if (child.id !== undefined) { - warnDroppedDefaultFlow(child, child.id, warnings); - warnIgnoredQuantityAttrs(child, child.id, warnings); - collectExtensionDrops(child, child.id, warnings); - collectUnmappedBpmnDrops(child, child.id, warnings); + attachRepetition(flowElements, mappedAt, child, warnings); + if (child.id !== undefined) { + warnIgnoredQuantityAttrs(child, child.id, warnings); + // 'initial': parseStartFormHandlers runs for the process's own start + // alone, so only a start at the process's top level reports a form + // setting as read elsewhere; a nested start's copy reads as unread anywhere. + collectExtensionDrops( + child, + child.id, + warnings, + hostKind === 'process' && child.$type === 'bpmn:StartEvent' + ? 'initial' + : undefined, + ); + collectUnmappedBpmnDrops(child, child.id, warnings); + } + } + + // Whether a start or an end prints at all is a question about the whole + // container: the first plain start under the minted id goes, and an end + // named for a boundary escape goes only if that boundary is in the list, + // which this tool's own output writes after the end. So the list is + // complete before any element is asked. + for (const mapped of flowElements) { + if (mapped.kind === 'startEvent') { + warnElidedNamedDrop( + mapped, + container, + hostKind === 'eventSubProcess', + warnings, + ); + } else if (mapped.kind === 'endEvent') { + warnElidedNamedDrop(mapped, container, false, warnings); + } + } + attachDefaultFlows(children, flowElements, sequenceFlows, warnings); + checkExclusiveGateways(children); + checkHandlerFlows(flowElements, sequenceFlows); + checkBoundaryEventHosts(flowElements, sequenceFlows, warnings); + checkLinkFlows(flowElements, sequenceFlows); + checkWaitBranches(flowElements, sequenceFlows); + return { flowElements, sequenceFlows }; +} + +/** + * Carry the `default` a step names once every flow of the container is + * mapped: `BpmnActivityBehavior.handleNoTransitions` takes it when no other + * route out of the step held, so it is routing, the same as on a gateway, + * which {@link mapDefaultingGateway} reads for itself. One naming a flow that + * does not leave the step is what `handleNoTransitions` throws + * `missingDefaultFlowException` on, and a `default` naming no element at all + * is read back from moddle's dropped reference for the same report. + */ +function attachDefaultFlows( + children: ModdleElement[], + flowElements: FlowElement[], + sequenceFlows: SequenceFlow[], + warnings: ImportWarning[], +): void { + for (const child of children) { + if (!ACTIVITY_TYPES.has(child.$type)) continue; + const defaultFlowId = + getEl(child, 'default')?.id ?? unresolvedRef(child, 'default'); + if (defaultFlowId === undefined) continue; + const index = flowElements.findIndex((fe) => fe.id === child.id); + const mapped = flowElements[index]; + if (mapped === undefined || !isActivity(mapped)) continue; + if ( + sequenceFlows.some( + (sf) => sf.id === defaultFlowId && sf.sourceRef === mapped.id, + ) + ) { + flowElements[index] = { ...mapped, defaultFlowId }; + continue; + } + warnings.push({ + elementId: mapped.id, + category: 'unmappedConstruct', + message: + `The 'default' attribute on '${mapped.id}' was not imported: it ` + + `names '${defaultFlowId}', which is not a route out of the step, so ` + + 'BpmnActivityBehavior.handleNoTransitions finds no flow to take and ' + + 'fails the step whenever no other route holds; the imported step ' + + 'names no fallback.', + }); + } +} + +/** + * The shapes `BpmnParse.validateExclusiveGateway` fails the deployment on, + * asked of the document rather than the IR: the engine counts a condition + * element with an empty body as a condition, which the IR carries as none, + * and reads `default` as the raw attribute, so one naming no element in the + * document still counts as a default. Each refusal quotes the engine's own + * sentence for the shape. One conditioned route beside one plain route with + * no default is left alone: the engine takes the plain one as the default and + * warns. + */ +function checkExclusiveGateways(children: ModdleElement[]): void { + const flows = children.filter((c) => c.$type === 'bpmn:SequenceFlow'); + for (const gateway of children) { + if (gateway.$type !== 'bpmn:ExclusiveGateway') continue; + const id = requireId(gateway); + const refuse = (detail: string): never => { + throw new UnsupportedGatewayShapeError(id, detail); + }; + const outgoing = flows.filter((f) => getEl(f, 'sourceRef')?.id === id); + const conditioned = (flow: ModdleElement): boolean => + getEl(flow, 'conditionExpression') !== undefined || + DROPPED_CONDITIONS.has(flow); + if (outgoing.length === 0) { + refuse(`Exclusive Gateway '${id}' has no outgoing sequence flows.`); + } + if (outgoing.length === 1) { + if (conditioned(outgoing[0])) { + refuse( + `Exclusive Gateway '${id}' has only one outgoing sequence flow ` + + `('${outgoing[0].id}'). This is not allowed to have a condition.`, + ); + } + continue; + } + // `default=""` is moddle's dropped reference to nothing; `BpmnParse. + // validateExclusiveGateway` reads an empty default as none. + const defaultFlowId = + getEl(gateway, 'default')?.id ?? + (unresolvedRef(gateway, 'default') || undefined); + const plain = outgoing.filter( + (flow) => !conditioned(flow) && flow.id !== defaultFlowId, + ); + const conditionedDefault = outgoing.find( + (flow) => conditioned(flow) && flow.id === defaultFlowId, + ); + if (conditionedDefault !== undefined) { + refuse( + `Exclusive Gateway '${id}' has outgoing sequence flow ` + + `'${conditionedDefault.id}' which is the default flow but has a ` + + 'condition too.', + ); + } + if (plain.length > 0 && (defaultFlowId !== undefined || plain.length > 1)) { + refuse( + `Exclusive Gateway '${id}' has outgoing sequence flow '${plain[0].id}' ` + + 'without condition which is not the default flow.', + ); } } +} - checkBoundaryEventHosts(flowElements, sequenceFlows, warnings); - checkLinkFlows(flowElements, sequenceFlows); - checkWaitBranches(flowElements, sequenceFlows); - return { flowElements, sequenceFlows }; +/** + * `BpmnParse.parseSequenceFlow` fails the deployment on a flow into or out of + * an event sub-process, off the flow's own endpoints; the element's + * `incoming`/`outgoing` children are bookkeeping a hand-written file omits, + * so the check reads the flows. + */ +function checkHandlerFlows( + flowElements: FlowElement[], + sequenceFlows: SequenceFlow[], +): void { + const handlers = new Set( + flowElements + .filter((fe) => fe.kind === 'subProcess' && fe.triggeredByEvent === true) + .map((fe) => fe.id), + ); + if (handlers.size === 0) return; + for (const sf of sequenceFlows) { + const entering = handlers.has(sf.targetRef); + if (!entering && !handlers.has(sf.sourceRef)) continue; + const handlerId = entering ? sf.targetRef : sf.sourceRef; + throw new UnsupportedEventFeatureError( + handlerId, + entering + ? `the flow '${sf.id}' enters the event handler '${handlerId}', ` + + 'which BpmnParse.parseSequenceFlow fails the deployment on ' + + '("Invalid incoming sequence flow of event subprocess"); a ' + + 'handler is entered by its trigger' + : `the flow '${sf.id}' leaves the event handler '${handlerId}', ` + + 'which BpmnParse.parseSequenceFlow fails the deployment on ' + + '("Invalid outgoing sequence flow of event subprocess"); a ' + + 'handler ends where its body ends', + `Take the flow '${sf.id}' off; the handler runs when its trigger fires.`, + ); + } } /** @@ -1421,8 +2152,8 @@ function checkLinkFlows( } } -/** The kinds that can repeat: every activity, and nothing else. */ -const REPEATABLE_KINDS = [ +/** The IR kinds of a `bpmn:Activity`: the ones that repeat and carry a `default`, and nothing else. */ +const ACTIVITY_KINDS = [ 'task', 'userTask', 'serviceTask', @@ -1432,13 +2163,13 @@ const REPEATABLE_KINDS = [ 'callActivity', ] as const; -type RepeatableElement = Extract< +type ActivityElement = Extract< FlowElement, - { kind: (typeof REPEATABLE_KINDS)[number] } + { kind: (typeof ACTIVITY_KINDS)[number] } >; -function isRepeatable(node: FlowElement): node is RepeatableElement { - return (REPEATABLE_KINDS as readonly string[]).includes(node.kind); +function isActivity(node: FlowElement): node is ActivityElement { + return (ACTIVITY_KINDS as readonly string[]).includes(node.kind); } /** @@ -1455,7 +2186,7 @@ function attachRepetition( // A sequence flow pushed nothing, and a kind that cannot repeat is left // alone. const mapped = flowElements[index]; - if (mapped === undefined || !isRepeatable(mapped)) return; + if (mapped === undefined || !isActivity(mapped)) return; const loop = readLoopCharacteristics(child, mapped.id, warnings); if (loop === undefined) return; flowElements[index] = { ...mapped, loop }; @@ -1806,7 +2537,7 @@ function mapSubProcess( * The BPMN attributes a `` declares that Operaton reads * nothing of: `parseTransaction` reads no attribute of its own and forces the * triggered-by-event property to false, so all three drop without changing what - * runs. Reported by hand, for the reason {@link warnDroppedDefaultFlow} gives. + * runs. Reported by hand, for the reason {@link warnIgnoredQuantityAttrs} gives. */ const IGNORED_TRANSACTION_ATTRS: ReadonlyMap = new Map([ ['method', 'Operaton reads it on a not at all'], @@ -1845,16 +2576,7 @@ function mapEventSubProcess( ): SubProcess { const id = requireId(el); refuseLoopCharacteristics(el, id); - - const incoming = (el.get('incoming') as ModdleElement[] | undefined) ?? []; - const outgoing = (el.get('outgoing') as ModdleElement[] | undefined) ?? []; - if (incoming.length > 0 || outgoing.length > 0) { - throw new UnsupportedEventFeatureError( - id, - 'an event handler carries incoming or outgoing sequence flows; it ' + - 'is triggered by its caught event, not wired into the surrounding flow', - ); - } + refuseIoMapping(el, id, 'checkActivityInputOutputSupported'); collectLaneDrops(el, id, warnings); warnNamedDrop(el, id, 'an event handler', warnings); @@ -1876,17 +2598,53 @@ function mapEventSubProcess( 'eventSubProcess', ); + const settings = readEngineAttributes(el, id, warnings); + const timerStarted = eventDefinitionsOf(startEvents[0]).some( + (def) => def.$type === 'bpmn:TimerEventDefinition', + ); return { kind: 'subProcess', id, triggeredByEvent: true, - ...readEngineAttributes(el, id, warnings), - ...readIoMapping(el, id, warnings), + ...(timerStarted + ? dropContinuationCopyOfTimerJobSettings(settings, id, warnings) + : settings), flowElements, sequenceFlows, }; } +/** + * A timer-started event sub-process prints as a host-less `on timer(...)`, + * whose head spells the three timer job keys once, for the timer job its + * start event creates ({@link splitTimerJobSettings}). The sub-process's own + * copy reaches only its async continuation job (`BpmnParse.createActivityOnScope` + * takes the priority, `parseAsynchronousContinuation` the lock and + * `DefaultFailedJobParseListener.parseSubProcess` the retry cycle off the + * sub-process element), and that copy has no spelling on the surface. + * Keeping it on the node would print it into the head beside the start's, + * where the compiler puts it back on the start or refuses the duplicate key. + */ +function dropContinuationCopyOfTimerJobSettings( + settings: EngineAttributes, + id: string, + warnings: ImportWarning[], +): Omit { + const { timer, continuation } = splitTimerJobSettings(settings); + for (const key of Object.keys(timer)) { + warnings.push({ + elementId: id, + category: 'extensionAttribute', + message: + `The '${key}' setting on '${id}' was not imported: the 'on timer' ` + + `head's ${key} configures the timer job its start event creates, ` + + "and the event sub-process's own copy, which only its async " + + 'continuation job takes, has no spelling in the script.', + }); + } + return continuation; +} + function mapEventSubProcessStart( startEl: ModdleElement, handlerId: string, @@ -1894,6 +2652,7 @@ function mapEventSubProcessStart( hostKind: ContainerHostKind, ): StartEvent { const id = requireId(startEl); + refuseIoMapping(startEl, id, 'ensureNoIoMappingDefined'); const defs = eventDefinitionsOf(startEl); if (defs.length !== 1) { throw new UnsupportedEventFeatureError( @@ -1949,15 +2708,13 @@ function mapEventSubProcessStart( // header has a label slot, so the pair is carried rather than dropped; // warnElidedNamedDrop reports it if the statement turns out not to print. const named = readNamed(startEl, id, warnings); - const formFields = readFormFields(startEl, id, warnings); return { kind: 'startEvent', id, ...named, eventDefinition, ...(isInterrupting === false ? { isInterrupting: false } : {}), - ...(formFields === undefined ? {} : { formFields }), - ...readStartAttributes(startEl), + ...readStartAttributes(startEl, id, 'eventSubProcess', warnings), ...readEngineAttributes(startEl, id, warnings), }; } @@ -2011,7 +2768,7 @@ function mapBoundaryEvent( ); } - refuseBoundaryInputOutput(el, id); + refuseIoMapping(el, id, 'ensureNoIoMappingDefined'); const defs = eventDefinitionsOf(el); if (defs.length !== 1) { @@ -2054,22 +2811,41 @@ function mapBoundaryEvent( }; } -function refuseBoundaryInputOutput(el: ModdleElement, id: string): void { - const values = extensionValues(el); - if (values.some((value) => value.$type === 'operaton:InputOutput')) { - throw new UnsupportedEventFeatureError( - id, - 'a boundary event carries an operaton:inputOutput mapping; Operaton ' + - 'forbids input/output variable mappings on a boundary event', - ); +/** + * `BpmnParse.ensureNoIoMappingDefined` (a start or a boundary event) and + * `BpmnParse.checkActivityInputOutputSupported` (a gateway or an event + * sub-process) fail the deployment on an `operaton:inputOutput` here; + * `method` says which, and the quote is the one it adds. + */ +function refuseIoMapping( + el: ModdleElement, + id: string, + method: 'ensureNoIoMappingDefined' | 'checkActivityInputOutputSupported', +): void { + if (!extensionValues(el).some((v) => v.$type === 'operaton:InputOutput')) { + return; } + const tag = xmlTagOf(el.$type); + const triggered = + el.get('triggeredByEvent') === true + ? " with attribute 'triggeredByEvent = true'" + : ''; + throw new UnsupportedExtensionFormError( + id, + `an operaton:inputOutput mapping on a <${tag}>, which BpmnParse.${method} ` + + 'fails the deployment on ("operaton:inputOutput mapping unsupported ' + + `for element type '${tag.slice(tag.indexOf(':') + 1)}'${triggered}")`, + ); } /** * Resolve one event definition on the CATCH side. An error or escalation * definition with no ref, or whose root carries no code, is catch-all: the - * missing code is what makes the handler match anything. A cancel and a link - * are read at one position each and refused everywhere else. + * missing code is what makes the handler match anything. A reference naming + * no root is not: the engine catches its text as the code + * ({@link readDanglingErrorCode}) or refuses the file + * (`createEscalationEventDefinitionForEscalationHandler`). A cancel and a + * link are read at one position each and refused everywhere else. */ function readCatchEventDefinition( defEl: ModdleElement, @@ -2084,7 +2860,9 @@ function readCatchEventDefinition( if (defEl.$type === 'bpmn:ErrorEventDefinition') { const ref = getEl(defEl, 'errorRef'); - const errorCode = ref ? readString(ref, 'errorCode') : undefined; + const errorCode = ref + ? readString(ref, 'errorCode') + : readDanglingErrorCode(defEl, ownerId, warnings); const codeVariable = readNamespacedAttr(defEl, 'errorCodeVariable'); const messageVariable = readNamespacedAttr(defEl, 'errorMessageVariable'); return { @@ -2097,6 +2875,16 @@ function readCatchEventDefinition( if (defEl.$type === 'bpmn:EscalationEventDefinition') { const ref = getEl(defEl, 'escalationRef'); + const written = unresolvedRef(defEl, 'escalationRef'); + if (ref === undefined && written !== undefined) { + throw new UnsupportedEventFeatureError( + ownerId, + danglingEscalationDetail( + written, + 'createEscalationEventDefinitionForEscalationHandler', + ), + ); + } const escalationCode = ref ? readString(ref, 'escalationCode') : undefined; const codeVariable = readNamespacedAttr(defEl, 'escalationCodeVariable'); return { @@ -2139,6 +2927,47 @@ function readCatchEventDefinition( throw new UnsupportedEventDefinitionError(ownerId, position, defEl.$type); } +/** + * The text of an `errorRef` naming no root, which is the code the engine + * catches, throws or maps: `parseBoundaryErrorEventDefinition`, + * `parseErrorStartEventDefinition`, `parseEndEvents` and + * `parseOperatonErrorEventDefinitions` each read + * `error == null ? errorRef : error.getErrorCode()`. `undefined` when no + * reference was written at all, which is the catch-all on the catch side. + * The export declares a root for every code in use, so the document written + * back names one. + */ +function readDanglingErrorCode( + defEl: ModdleElement, + ownerId: string, + warnings: ImportWarning[], +): string | undefined { + const written = unresolvedRef(defEl, 'errorRef'); + if (written === undefined || written === '') return undefined; + warnings.push({ + elementId: ownerId, + category: 'unmappedConstruct', + message: + `The errorRef '${written}' on '${ownerId}' names no bpmn:error root ` + + `and imports as the code '${written}': Operaton takes a dangling ` + + "reference's text as the code " + + '(BpmnParse.parseBoundaryErrorEventDefinition, ' + + 'parseErrorStartEventDefinition, parseEndEvents, ' + + 'parseOperatonErrorEventDefinitions), and the document written back ' + + 'declares an error root carrying it.', + }); + return written; +} + +/** Unlike an error, an escalation reference the engine cannot resolve fails the deployment; `method` is the reader that does. */ +function danglingEscalationDetail(written: string, method: string): string { + return ( + `its escalationRef '${written}' names no bpmn:escalation root, which ` + + 'Operaton refuses to deploy ("could not find escalation with id ' + + `'${written}'", BpmnParse.${method})` + ); +} + /** * A nameless link definition has nothing to match: * `BpmnParse.parseIntermediateLinkEventCatchBehavior` throws a @@ -2218,39 +3047,35 @@ function refuseUnsupportedCompensateFeatures( } /** - * `RAW_TEMPLATE` is declared before `STRING` in the grammar, so a quoted body - * opening with `${` lexes as a raw expression where a name is expected. An - * expression later in the name, and the `#{...}` spelling, both read back whole. + * The name is carried as written, an expression included: + * `BpmnParse.parseMessages` and `BpmnParse.parseSignals` evaluate every name + * through `createExpression`, and the script reads a quoted name opening with + * `${` or `#{` back as the same text. */ -const LEADING_EXPRESSION = /^\$\{/; - function resolveNamedRootRef( defEl: ModdleElement, refProperty: 'messageRef' | 'signalRef', ownerId: string, label: 'message' | 'signal', ): string { + const rootKind = label === 'message' ? 'bpmn:Message' : 'bpmn:Signal'; const ref = getEl(defEl, refProperty); - const name = ref ? readString(ref, 'name') : undefined; - if (name === undefined) { - const rootKind = label === 'message' ? 'bpmn:Message' : 'bpmn:Signal'; + const written = + ref === undefined ? unresolvedRef(defEl, refProperty) : undefined; + if (written !== undefined) { throw new UnsupportedEventFeatureError( ownerId, - `a ${label} definition must reference a ${rootKind} root with a non-empty name`, + `its ${refProperty} '${written}' names no ${rootKind} root: this tool ` + + 'matches the reference to a root id as written, where Operaton ' + + 'resolves a prefixed reference through the xmlns table ' + + '(BpmnParse.resolveName) and refuses an unresolved one', ); } - if (LEADING_EXPRESSION.test(name)) { - const expressionRemedy = - label === 'message' - ? ", which the process's own start needs in any case; anywhere " + - 'else the same expression reads back written as "#{...}"' - : ', or write the same expression as "#{...}"'; + const name = ref ? readString(ref, 'name') : undefined; + if (name === undefined) { throw new UnsupportedEventFeatureError( ownerId, - `a ${label} name that starts with an expression ("${name}") cannot be ` + - 'written back: this tool writes the name in quotes, and a quoted ' + - 'name opening with "${" reads as an expression rather than as a ' + - `name; give the ${label} a fixed name${expressionRemedy}`, + `a ${label} definition must reference a ${rootKind} root with a non-empty name`, ); } return name; @@ -2291,7 +3116,8 @@ function readTimerDefinition( /** * The conditional-narrowing attribute names. Neither is declared by the moddle - * extension, so both surface only in `$attrs`, under either prefix. + * extension, so both surface only in `$attrs`, under `operaton:` alone (a + * document's camunda: spelling reads as `operaton:` by the time this runs). */ const CONDITIONAL_NARROWING_ATTRS: readonly string[] = [ 'variableName', @@ -2305,14 +3131,12 @@ function readConditionalDefinition( ): string { const attrs = defEl.$attrs ?? {}; for (const localName of CONDITIONAL_NARROWING_ATTRS) { - for (const prefix of ['operaton', 'camunda']) { - if (attrs[`${prefix}:${localName}`] !== undefined) { - throw new UnsupportedEventFeatureError( - ownerId, - `a conditional definition's ${prefix}:${localName} narrows when the ` + - 'condition is (re-)evaluated, which this tool cannot represent', - ); - } + if (attrs[`operaton:${localName}`] !== undefined) { + throw new UnsupportedEventFeatureError( + ownerId, + `a conditional definition's operaton:${localName} narrows when the ` + + 'condition is (re-)evaluated, which this tool cannot represent', + ); } } @@ -2343,18 +3167,35 @@ function readThrowEventDefinition( ownerId: string, warnings: ImportWarning[], ): EventDefinition { - collectExtensionDrops(defEl, ownerId, warnings); + // 'throw': every position reaching here (an emit, a message/signal/ + // escalation end) fires with parseSignalEventDefinition's isThrowing=true, + // which is what makes it read `async` and `operaton:in`; a catch's own + // sweep (readCatchEventDefinition) stays unqualified since isThrowing=false there. + collectExtensionDrops(defEl, ownerId, warnings, 'throw'); collectUnmappedBpmnDrops(defEl, ownerId, warnings); warnDocumentationDrop(defEl, ownerId, 'an event definition', warnings); warnThrowSideBindingAttrs(defEl, ownerId, warnings); if (defEl.$type === 'bpmn:ErrorEventDefinition') { const ref = getEl(defEl, 'errorRef'); - const errorCode = ref ? readString(ref, 'errorCode') : undefined; + if (ref === undefined) { + const errorCode = readDanglingErrorCode(defEl, ownerId, warnings); + if (errorCode !== undefined) return { kind: 'error', errorCode }; + throw new UnsupportedEventFeatureError( + ownerId, + 'its error definition carries no errorRef, which Operaton refuses ' + + "to deploy (\"'errorRef' attribute is mandatory on error end " + + 'event", BpmnParse.parseEndEvents)', + ); + } + const errorCode = readString(ref, 'errorCode'); if (errorCode === undefined) { throw new UnsupportedEventFeatureError( ownerId, - 'a throw must resolve to a non-empty code', + `its errorRef names the bpmn:error root '${ref.id}', which carries ` + + "no code; Operaton refuses to deploy the throw (\"'errorCode' is " + + 'mandatory on errors referenced by throwing error event ' + + 'definitions", BpmnParse.parseEndEvents)', ); } return { kind: 'error', errorCode }; @@ -2368,11 +3209,30 @@ function readThrowEventDefinition( } const ref = getEl(defEl, 'escalationRef'); - const escalationCode = ref ? readString(ref, 'escalationCode') : undefined; + if (ref === undefined) { + const written = unresolvedRef(defEl, 'escalationRef'); + throw new UnsupportedEventFeatureError( + ownerId, + written === undefined + ? 'its escalation definition carries no escalationRef, which ' + + 'Operaton refuses to deploy ("escalationEventDefinition does ' + + "not have required attribute 'escalationRef'\", " + + 'BpmnParse.findEscalationForEscalationEventDefinition)' + : danglingEscalationDetail( + written, + 'findEscalationForEscalationEventDefinition', + ), + ); + } + const escalationCode = readString(ref, 'escalationCode'); if (escalationCode === undefined) { throw new UnsupportedEventFeatureError( ownerId, - 'a throw must resolve to a non-empty code', + `its escalationRef names the bpmn:escalation root '${ref.id}', which ` + + 'carries no code; Operaton refuses to deploy a throw of one ' + + '("throwing escalation event must have an \'escalationCode\'", ' + + 'BpmnParse.parseIntermediateThrowEvent; "escalation end event must ' + + "have an 'escalationCode'\", parseEndEvents)", ); } return { kind: 'escalation', escalationCode }; @@ -2439,12 +3299,21 @@ function warnLinkThrowEngineSettings( for (const listener of executionListeners ?? []) { report(`'${listener.event}' execution listener`, 'runs a listener on'); } + for (const block of extensionValues(el)) { + if (block.$type === 'operaton:Properties') { + report('operaton:properties block', 'reads a property list on'); + } + if (block.$type === 'operaton:InputOutput') { + report('operaton:inputOutput block', 'reads a mapping on'); + } + } } /** * The mirror of {@link warnThrowSideBindingAttrs}: an implementation on a - * message definition is what sends the message, which only a throw does, so the - * catch side reads the same names and imports none of them. + * message definition, and what `parseServiceTaskLike` reads beside it, is + * what sends the message, which only a throw does, so the catch side reads + * the same names and imports none of them. */ function warnCatchSideImplementationAttrs( defEl: ModdleElement, @@ -2452,19 +3321,33 @@ function warnCatchSideImplementationAttrs( warnings: ImportWarning[], ): void { if (defEl.$type !== 'bpmn:MessageEventDefinition') return; - for (const name of IMPLEMENTATION_ATTRS) { - if (readNamespacedAttr(defEl, name) === undefined) continue; + const report = (what: string): void => { warnings.push({ elementId: ownerId, category: 'extensionAttribute', message: - `The '${name}' setting on '${ownerId}' only takes effect on a throw ` + + `The ${what} on '${ownerId}' only takes effect on a throw ` + "('throw message' or 'emit message'); it has no effect on a catch and " + 'was not imported.', }); + }; + for (const name of [...IMPLEMENTATION_ATTRS, ...IMPLEMENTATION_EXTRA_ATTRS]) { + if (readNamespacedAttr(defEl, name) !== undefined) { + report(`'${name}' setting`); + } + } + for (const child of extensionValues(defEl)) { + if (IMPLEMENTATION_EXTRA_CHILDREN.includes(child.$type)) { + report(childSubject(child)); + } } } +/** `operaton:field 'to'`: {@link describeChild} without its article, for a sentence that leads with the child. */ +function childSubject(child: ModdleElement): string { + return describeChild(child, child.$type).replace(/^an /, ''); +} + /** * Report everything a start or end the printer may leave unprinted takes with * it: its label, its documentation, and a start's initiator. @@ -2476,11 +3359,11 @@ function warnCatchSideImplementationAttrs( */ function warnElidedNamedDrop( el: StartEvent | EndEvent, - siblings: readonly FlowElement[], + container: PrintContainer, startTriggerSuppressed: boolean, warnings: ImportWarning[], ): void { - if (!isElidedOnPrint(el, siblings, startTriggerSuppressed)) return; + if (!isElidedOnPrint(el, container, startTriggerSuppressed)) return; const isStart = el.kind === 'startEvent'; const report = ( category: ImportWarningCategory, @@ -2563,13 +3446,36 @@ function mapCallActivity( const named = readNamed(el, id, warnings); const calledElement = readString(el, 'calledElement'); + // `BpmnParse.parseCallActivity` refuses neither and both, and runs a + // `caseRef` alone through `CaseCallActivityBehavior`. + const caseRef = readNamespacedAttr(el, 'caseRef'); if (calledElement === undefined) { throw new UnsupportedCallActivityError( id, - 'it has no calledElement; there is nothing for the engine to invoke', + caseRef === undefined + ? 'it names neither a calledElement nor an operaton:caseRef, which ' + + 'BpmnParse.parseCallActivity refuses to deploy ("Missing ' + + "attribute 'calledElement' or 'caseRef'\")" + : `it names operaton:caseRef="${caseRef}" and no calledElement, so ` + + 'BpmnParse.parseCallActivity runs a case through ' + + 'CaseCallActivityBehavior, which this surface has no form for', + ); + } + if (caseRef !== undefined) { + throw new UnsupportedCallActivityError( + id, + `it names a calledElement beside operaton:caseRef="${caseRef}", which ` + + 'BpmnParse.parseCallActivity refuses to deploy ("The attributes ' + + "'calledElement' or 'caseRef' cannot be used together\")", + ); + } + const tenantId = readNamespacedAttr(el, 'calledElementTenantId'); + if (tenantId !== undefined) { + throw new UnsupportedCallActivityError( + id, + tenantPinDetail('calledElementTenantId', tenantId, 'called process'), ); } - refuseExecutionAffectingCallActivityAttrs(el, id); const binding = readVersionBinding( el, @@ -2578,7 +3484,11 @@ function mapCallActivity( (detail) => new UnsupportedCallActivityError(id, detail), warnings, ); - const { businessKey, inMappings, outMappings } = readCallMappings(el, id); + const { businessKey, inMappings, outMappings } = readCallMappings( + el, + id, + warnings, + ); const mapper = readCallVariableMapper(el, id, warnings); return { @@ -2633,27 +3543,19 @@ function readCallVariableMapper( return undefined; } -/** Refuse the call-activity attributes that change what the engine executes. */ -function refuseExecutionAffectingCallActivityAttrs( - el: ModdleElement, - id: string, -): void { - for (const [localName, detail] of EXECUTION_AFFECTING_CALL_ATTRS) { - if (readNamespacedAttr(el, localName) !== undefined) { - throw new UnsupportedCallActivityError(id, detail); - } - } +/** + * The one refusal `BpmnParse.parseTenantId` draws, on the call activity and + * the DMN task alike: `what` is the callable the tenant scopes (`called + * process`, `decision`). + */ +function tenantPinDetail(attr: string, value: string, what: string): string { + return ( + `it names operaton:${attr}="${value}", which pins the tenant ` + + `BpmnParse.parseTenantId resolves the ${what} against; dropping it ` + + `would change which ${what} runs, and this surface has no tenant setting` + ); } -const EXECUTION_AFFECTING_CALL_ATTRS: readonly (readonly [string, string])[] = [ - [ - 'calledElementTenantId', - 'it sets calledElementTenantId, which pins the tenant the engine ' + - 'resolves the called process against; dropping it would change ' + - 'which process is invoked', - ], -]; - /** * Resolve the `Binding`/`Version` pair a call activity, a * decision reference, and a form reference all pin their version with, the @@ -2661,6 +3563,11 @@ const EXECUTION_AFFECTING_CALL_ATTRS: readonly (readonly [string, string])[] = [ * tell a meaningful version from a dangling one (set while the binding is * absent or not `"version"`, where Operaton ignores it), so it is reported * here. + * + * A word outside the four `BpmnParse.parseBinding` matches leaves the + * callable's binding null, which `BaseCallableElement.isLatestBinding` reads + * as latest, so it imports as such; `parseFormDefinition` refuses the same + * word on a form reference, so there it refuses too. */ function readVersionBinding( el: ModdleElement, @@ -2690,13 +3597,30 @@ function readVersionBinding( 'the engine cannot resolve which version to use', ); } - noteRewrappedExpression(version, id, `${prefix}Version`, warnings); binding = { kind: 'version', version }; break; - default: + case 'versionTag': throw refusal( - `${prefix}Binding="${bindingValue}" is not a binding this tool can represent`, + prefix === 'formRef' + ? formRefBindingRefusal(bindingValue) + : `${prefix}Binding="versionTag" pins a version tag, which this ` + + 'surface has no setting for', ); + default: + if (prefix === 'formRef') + throw refusal(formRefBindingRefusal(bindingValue)); + binding = { kind: 'latest' }; + warnings.push({ + elementId: id, + category: 'unmappedConstruct', + message: + `The ${prefix}Binding="${bindingValue}" on '${id}' imports as ` + + 'binding: latest: BpmnParse.parseBinding sets no binding for that ' + + 'word and BaseCallableElement.isLatestBinding reads none as ' + + `latest, and this tool writes it back as ${prefix}Binding="latest", ` + + 'which the engine reads the same.', + }); + break; } if (version !== undefined && binding?.kind !== 'version') { @@ -2712,17 +3636,56 @@ function readVersionBinding( return binding; } +/** The word `formRefBinding` outside the three `parseFormDefinition` resolves is a deployment error. */ +function formRefBindingRefusal(word: string): string { + return ( + `formRefBinding="${word}" is outside the bindings ` + + 'BpmnParse.parseFormDefinition resolves (deployment, latest, version), ' + + 'so the engine refuses to deploy it' + ); +} + interface CallMappings { businessKey?: string; inMappings?: CallVariableMapping[]; outMappings?: CallVariableMapping[]; } -function readCallMappings(el: ModdleElement, id: string): CallMappings { +/** + * Report an attribute on an `operaton:in`/`operaton:out` that the engine + * passes over because `winner` was read first; `why` names the reader. + */ +function warnMappingAttrIgnored( + warnings: ImportWarning[], + ownerId: string, + tag: string, + attr: string, + winner: string, + why: string, +): void { + warnings.push({ + elementId: ownerId, + category: 'extensionAttribute', + message: + `The '${attr}' on an ${tag} of '${ownerId}' has no effect alongside ` + + `${winner} and was not imported: ${why}.`, + }); +} + +/** + * `BpmnParse.parseInputParameter` takes a non-empty `businessKey` and reads + * nothing else off that `operaton:in`, and a later one overwrites the + * earlier through `setBusinessKeyValueProvider`; every other element goes + * to {@link readCallVariableMapping}. + */ +function readCallMappings( + el: ModdleElement, + id: string, + warnings: ImportWarning[], +): CallMappings { const values = extensionValues(el); let businessKey: string | undefined; - let businessKeyCount = 0; const inMappings: CallVariableMapping[] = []; const outMappings: CallVariableMapping[] = []; @@ -2730,32 +3693,44 @@ function readCallMappings(el: ModdleElement, id: string): CallMappings { if (value.$type === 'operaton:In') { const candidateBusinessKey = readString(value, 'businessKey'); if (candidateBusinessKey !== undefined) { - businessKeyCount += 1; - if (businessKeyCount > 1) { - throw new UnsupportedCallActivityError( - id, - 'more than one operaton:in businessKey is set', - ); + if (businessKey !== undefined) { + warnings.push({ + elementId: id, + category: 'extensionAttribute', + message: + `The operaton:in businessKey="${businessKey}" on '${id}' has ` + + 'no effect alongside a later one and was not imported: ' + + 'BpmnParse.parseInputParameter hands each to ' + + 'setBusinessKeyValueProvider, and the last stands.', + }); } - if ( - readString(value, 'source') !== undefined || - readString(value, 'sourceExpression') !== undefined || - readString(value, 'target') !== undefined || - readString(value, 'variables') !== undefined || - value.get('local') === true - ) { - throw new UnsupportedCallActivityError( + const ignored = [ + ...['source', 'sourceExpression', 'target', 'variables'].filter( + (attr) => readString(value, attr) !== undefined, + ), + ...(value.get('local') === true ? ['local'] : []), + ]; + for (const attr of ignored) { + warnMappingAttrIgnored( + warnings, id, - 'an operaton:in businessKey is combined with ' + - 'source/sourceExpression/target/variables/local', + 'operaton:in', + attr, + 'businessKey', + 'BpmnParse.parseInputParameter reads the business key alone off ' + + 'that element', ); } businessKey = candidateBusinessKey; continue; } - inMappings.push(readCallVariableMapping(value, id, 'operaton:in')); + inMappings.push( + readCallVariableMapping(value, id, 'operaton:in', warnings), + ); } else if (value.$type === 'operaton:Out') { - outMappings.push(readCallVariableMapping(value, id, 'operaton:out')); + outMappings.push( + readCallVariableMapping(value, id, 'operaton:out', warnings), + ); } } @@ -2766,23 +3741,25 @@ function readCallMappings(el: ModdleElement, id: string): CallMappings { }; } +/** + * In `BpmnParse.parseCallableElementProvider`'s order: `variables="all"` + * returns before any source is read, and a non-empty `source` is taken + * before `sourceExpression` is looked at; an empty `source` is a deployment + * error under the strict validation the engine runs by default. + */ function readCallVariableMapping( value: ModdleElement, ownerId: string, tag: 'operaton:in' | 'operaton:out', + warnings: ImportWarning[], ): CallVariableMapping { const source = readString(value, 'source'); const sourceExpression = readString(value, 'sourceExpression'); const variables = readString(value, 'variables'); const target = readString(value, 'target'); const local = value.get('local') === true ? true : undefined; - - if (source !== undefined && sourceExpression !== undefined) { - throw new UnsupportedCallActivityError( - ownerId, - `an ${tag} carries both source and sourceExpression`, - ); - } + const ignored = (attr: string, winner: string, why: string): void => + warnMappingAttrIgnored(warnings, ownerId, tag, attr, winner, why); if (variables !== undefined) { if (variables !== 'all') { @@ -2792,21 +3769,35 @@ function readCallVariableMapping( 'import (only variables="all" is supported)', ); } - if ( - source !== undefined || - sourceExpression !== undefined || - target !== undefined - ) { - throw new UnsupportedCallActivityError( - ownerId, - `an ${tag} carries variables="all" combined with ` + - 'source/sourceExpression/target', + for (const attr of ['source', 'sourceExpression', 'target']) { + if (readString(value, attr) === undefined) continue; + ignored( + attr, + 'variables="all"', + 'BpmnParse.parseCallableElementProvider passes every variable and ' + + 'reads nothing else', ); } return { kind: 'all', ...(local === true ? { local } : {}) }; } + if (value.get('source') === '') { + throw new UnsupportedCallActivityError( + ownerId, + `an ${tag} carries source="", which ` + + 'BpmnParse.parseCallableElementProvider refuses to deploy ("Empty ' + + "attribute 'source' when passing variables\")", + ); + } + if (source !== undefined) { + if (sourceExpression !== undefined) { + ignored( + 'sourceExpression', + 'source', + 'BpmnParse.parseCallableElementProvider reads source first', + ); + } if (target === undefined) { throw new UnsupportedCallActivityError( ownerId, @@ -2890,7 +3881,8 @@ function warnLaneSetDrops( * {@link warnDocumentationDrop}, `extensionElements` by * {@link collectExtensionDrops}, `laneSets` by {@link collectLaneDrops}, * `loopCharacteristics` and its four children here by - * {@link readLoopCharacteristics}, `rootElements` by {@link xmlToIr}, and + * {@link readLoopCharacteristics}, `rootElements` by {@link xmlToIr}, + * `participants` and `messageFlows` by {@link warnCollaborationDrops}, and * `diagrams` is the DI data. */ const READ_BPMN_CHILDREN: ReadonlySet = new Set([ @@ -2907,6 +3899,8 @@ const READ_BPMN_CHILDREN: ReadonlySet = new Set([ 'loopCardinality', 'loopCharacteristics', 'loopDataInputRef', + 'messageFlows', + 'participants', 'rootElements', 'script', 'timeCycle', @@ -2963,9 +3957,10 @@ function collectUnmappedBpmnDrops( } } -/** The root kinds {@link xmlToIr} handles; `bpmn:Collaboration` is refused before mapping. */ +/** The root kinds {@link xmlToIr} handles, the collaboration by {@link warnCollaborationDrops}. */ const HANDLED_ROOT_KINDS: ReadonlySet = new Set([ 'bpmn:Process', + 'bpmn:Collaboration', 'bpmn:Error', 'bpmn:Escalation', 'bpmn:Message', @@ -3044,35 +4039,65 @@ function warnDataConstructDrop( }); } +/** + * `qualifier` narrows an {@link ENGINE_READS_ELSEWHERE} lookup to the one + * position that reader actually runs at (`'throw'`, `'initial'`); omitted, + * a drop is reported as read nowhere else, which is right for every other + * position the same tag or attribute can appear at. + */ function collectExtensionDrops( el: ModdleElement, ownerId: string, warnings: ImportWarning[], + qualifier?: string, ): void { - warnUnreadPrefixedAttrs(el, ownerId, warnings); - warnUnreadExtensionElements(el, ownerId, warnings); - warnUnreadDeclaredAttrs(el, ownerId, warnings); + warnUnreadPrefixedAttrs(el, ownerId, warnings, qualifier); + warnUnreadExtensionElements(el, ownerId, warnings, qualifier); + warnUnreadDeclaredAttrs(el, ownerId, warnings, qualifier); } /** - * Report `operaton:`/`camunda:` attributes {@link CONSUMED_EXTENSION_ATTRS} - * does not list for this owner kind. Any other namespace is left alone: that is - * where an editor stamps its bookkeeping, and reporting it would bury the drops - * that matter. + * Report `operaton:` attributes {@link CONSUMED_EXTENSION_ATTRS} does not list + * for this owner kind. Any other namespace is left alone: that is where an + * editor stamps its bookkeeping, and reporting it would bury the drops that + * matter. A document's camunda: spelling reads as `operaton:` by the time this + * runs (the namespace swap in {@link xmlToIr}), so one prefix covers both. */ function warnUnreadPrefixedAttrs( el: ModdleElement, ownerId: string, warnings: ImportWarning[], + qualifier?: string, ): void { for (const key of Object.keys(el.$attrs ?? {})) { const colon = key.indexOf(':'); if (colon === -1) continue; const prefix = key.slice(0, colon); const localName = key.slice(colon + 1); - if (prefix !== 'operaton' && prefix !== 'camunda') continue; + if (prefix !== 'operaton') continue; if (isConsumedHere(CONSUMED_EXTENSION_ATTRS, el.$type, localName)) continue; - warnUnimportedSetting(warnings, ownerId, `'${key}' setting`); + if (localName === 'failedJobRetryTimeCycle') { + warnings.push({ + elementId: ownerId, + category: 'extensionAttribute', + message: + `The '${key}' setting on '${ownerId}' was not imported: Operaton ` + + 'reads a retry cycle as an ' + + 'element and never as an attribute ' + + '(DefaultFailedJobParseListener.setFailedJobRetryTimeCycleValue ' + + 'through BpmnParseUtil.findOperatonExtensionElement), so the ' + + 'document written back runs the same.', + }); + continue; + } + warnUnimportedSetting( + warnings, + ownerId, + `'${key}' setting`, + el.$type, + localName, + qualifier, + ); } } @@ -3095,6 +4120,7 @@ function warnUnreadExtensionElements( el: ModdleElement, ownerId: string, warnings: ImportWarning[], + qualifier?: string, ): void { for (const value of extensionValues(el)) { if (isConsumedHere(CONSUMED_EXTENSION_ELEMENTS, el.$type, value.$type)) { @@ -3118,11 +4144,14 @@ function warnUnreadExtensionElements( ); continue; } - warnings.push({ - elementId: ownerId, - category: 'extensionAttribute', - message: `Extra configuration (${value.$type}) on '${ownerId}' was not imported.`, - }); + warnUnimportedSetting( + warnings, + ownerId, + childSubject(value), + el.$type, + value.$type, + qualifier, + ); } } @@ -3136,6 +4165,7 @@ function warnUnreadDeclaredAttrs( el: ModdleElement, ownerId: string, warnings: ImportWarning[], + qualifier?: string, ): void { for (const prop of el.$descriptor?.properties ?? []) { if (prop.ns === undefined || prop.ns.prefix !== 'operaton') continue; @@ -3145,28 +4175,56 @@ function warnUnreadDeclaredAttrs( } // Only what the document wrote: moddle stores a parsed value as an own property. if (!Object.prototype.hasOwnProperty.call(el, prop.name)) continue; - warnUnimportedSetting(warnings, ownerId, `'${prop.ns.name}' setting`); + warnUnimportedSetting( + warnings, + ownerId, + `'${prop.ns.name}' setting`, + el.$type, + prop.ns.localName, + qualifier, + ); } } -/** Report one engine setting re-export will not write back; `subject` names it in the sentence. */ +/** + * Report one setting re-export will not write back: `subject` names it in + * the sentence, and `ownerType` with `name` look up the engine's reader for + * it ({@link ENGINE_READS_ELSEWHERE}). `qualifier`, when given, narrows that + * lookup to the one position the row's reader actually runs at. + */ function warnUnimportedSetting( warnings: ImportWarning[], ownerId: string, subject: string, + ownerType: string, + name: string, + qualifier?: string, ): void { + const reads = ENGINE_READS_ELSEWHERE.get( + `${qualifier === undefined ? '' : `${qualifier}:`}${ownerType}/${name}`, + ); warnings.push({ elementId: ownerId, category: 'extensionAttribute', - message: `The ${subject} on '${ownerId}' was not imported ${KEPT_SETTINGS_NOTE}`, + message: + `The ${subject} on '${ownerId}' was not imported: this tool reads no ` + + `such setting on ${describeTag(ownerType)}, ` + + (reads === undefined + ? 'and the document written back carries none.' + : `though Operaton ${reads}, so the document written back runs without it.`), }); } +/** `bpmn:StartEvent` -> `a `, `operaton:TaskListener` -> `an `. */ +function describeTag(type: string): string { + return `${type.startsWith('operaton:') ? 'an' : 'a'} <${xmlTagOf(type)}>`; +} + /** * Report every attribute on a consumed extension child that no reader reads, - * descending through the children the readers do read. An - * `operaton:taskListener`'s `id` is the motivating case: Operaton addresses a - * timeout listener's job by it and the id would otherwise leave no trace. + * descending through the children the readers do read. An undeclared + * `operaton:` attribute on a listener is the motivating case: it would + * otherwise vanish with the listener that imports and leave no trace. * * Both spellings are swept, because moddle stores them apart: a declared * attribute parses into a typed property, an undeclared one lands in `$attrs`. @@ -3185,7 +4243,13 @@ function warnUnreadChildAttrs( if (consumed === undefined) return; const report = (name: string): void => - warnUnimportedSetting(warnings, ownerId, `'${name}' on ${where}`); + warnUnimportedSetting( + warnings, + ownerId, + `'${name}' on ${where}`, + el.$type, + name, + ); for (const prop of el.$descriptor?.properties ?? []) { if (prop.isAttr !== true) continue; @@ -3262,23 +4326,32 @@ function childElements(el: ModdleElement): ModdleElement[] { /** `${...}` and `#{...}` both name an EL expression; Operaton accepts either syntax. */ const EXPRESSION_BODY = /\$\{|#\{/; -/** The opening this surface has no form for; only a leading one is rewritten. */ -const DEFERRED_OPEN = /^\s*#\{/; - +/** + * A `#{...}` body the printer spells as bare DSL loses its opener, since the + * compiler writes a bare expression inside `${...}`; one it keeps quoted, an + * out-of-subset or composite body, prints and lowers as written and needs no + * report. Only a leading `#{` is an opener at all. + */ function noteRewrappedExpression( body: string | undefined, ownerId: string, slot: string, warnings: ImportWarning[], ): void { - if (body === undefined || !DEFERRED_OPEN.test(body)) return; + if ( + body === undefined || + !/^\s*#\{/.test(body) || + parseJuel(body).kind !== 'structured' + ) { + return; + } warnings.push({ elementId: ownerId, category: 'unmappedConstruct', message: - `The ${slot} on '${ownerId}' is written with "#{...}", which this ` + - 'surface has no form for: its text is written back inside "${...}", ' + - 'which Operaton evaluates identically.', + `The ${slot} on '${ownerId}' is written with "#{...}"; the script ` + + 'prints its body as bare DSL and the rebuilt document writes it ' + + 'inside "${...}", which Operaton evaluates identically.', }); } @@ -3289,30 +4362,68 @@ function mapStartEvent( hostKind: ContainerHostKind, ): StartEvent { const id = requireId(el); + refuseIoMapping(el, id, 'ensureNoIoMappingDefined'); const eventDefinition = readStartTrigger(el, id, warnings, hostKind); const named = readNamed(el, id, warnings); - const formFields = readFormFields(el, id, warnings); return { kind: 'startEvent', id, ...named, - ...(formFields === undefined ? {} : { formFields }), ...(eventDefinition === undefined ? {} : { eventDefinition }), - ...readStartAttributes(el), + ...readStartAttributes(el, id, hostKind, warnings), ...readEngineAttributes(el, id, warnings), }; } /** - * The extension attributes a start event carries wherever it sits. Both start - * mappers read it here rather than each for itself: `initiator` is declared - * read on `bpmn:StartEvent`, which silences the unread-attribute sweep for a - * handler's start as much as for a process's own, so a mapper that skipped it - * would drop an authored value without a word. + * The initiator and the start form, read off the process's own start alone: + * `BpmnParse.parseStartEvents` sends every other start to + * `parseScopeStartEvent`, which reads no `operaton:` attribute and no form + * (`parseProcessDefinitionStartEvent` and `parseStartFormHandlers` run for + * the process's alone), and the validator refuses both on a nested start. + * Both start mappers come through here because `initiator` and + * `operaton:formData` are declared read on `bpmn:StartEvent`, which silences + * the sweep for a nested start as much as for the process's own, so the drop + * has to be reported by hand. */ -function readStartAttributes(el: ModdleElement): { initiator?: string } { +function readStartAttributes( + el: ModdleElement, + id: string, + hostKind: ContainerHostKind, + warnings: ImportWarning[], +): Pick { const initiator = readNamespacedAttr(el, 'initiator'); - return initiator === undefined ? {} : { initiator }; + if (hostKind === 'process') { + const formFields = readFormFields(el, id, warnings); + return { + ...(formFields === undefined ? {} : { formFields }), + ...(initiator === undefined ? {} : { initiator }), + }; + } + if (initiator !== undefined) { + warnings.push({ + elementId: id, + category: 'extensionAttribute', + message: + `The 'operaton:initiator' setting on '${id}' was not imported: ` + + 'BpmnParse.parseScopeStartEvent reads no operaton: attribute off a ' + + "start that is not the process's own " + + '(parseProcessDefinitionStartEvent reads it there alone), so the ' + + 'document written back runs the same.', + }); + } + if (extensionValues(el).some((v) => v.$type === 'operaton:FormData')) { + warnings.push({ + elementId: id, + category: 'extensionAttribute', + message: + `The operaton:formData block on '${id}' was not imported: ` + + "BpmnParse.parseStartFormHandlers runs for the process's own start " + + 'alone and parseScopeStartEvent reads no form, so the document ' + + 'written back runs the same.', + }); + } + return {}; } /** @@ -3447,6 +4558,7 @@ function mapEndEvent( hostKind: ContainerHostKind, ): EndEvent { const id = requireId(el); + refuseEndOutputParameters(el, id); const defs = eventDefinitionsOf(el); if (defs.length === 0) { @@ -3455,7 +4567,7 @@ function mapEndEvent( kind: 'endEvent', id, ...named, - ...readEngineAttributes(el, id, warnings), + ...readThrowEventAttributes(el, id, undefined, warnings), }; } refuseMultipleEventDefinitions( @@ -3488,7 +4600,7 @@ function mapEndEvent( id, ...named, eventDefinition: { kind: carried }, - ...readEngineAttributes(el, id, warnings), + ...readThrowEventAttributes(el, id, undefined, warnings), }; } if ( @@ -3510,15 +4622,46 @@ function mapEndEvent( ) as EndEventDefinition; warnNamedDrop(el, id, 'a throw', warnings); + const binding = readThrownMessageBinding( + defEl, + id, + 'messageEndEvent', + warnings, + ); return { kind: 'endEvent', id, eventDefinition, - ...readThrownMessageBinding(defEl, id, warnings), - ...readEngineAttributes(el, id, warnings), + ...(binding === undefined ? {} : { binding }), + ...readThrowEventAttributes(el, id, binding, warnings), }; } +/** + * `BpmnParse.parseEndEvents` runs the mapping through + * `parseActivityInputOutput`, whose output check fails the deployment on + * this tag alone; the input half is read there and reported as a drop by + * the sweep, since the IR's end carries no mapping. + */ +function refuseEndOutputParameters(el: ModdleElement, id: string): void { + const io = onlyExtensionElement( + el, + 'operaton:InputOutput', + id, + 'BpmnParseUtil.parseInputOutput', + ); + const outputs = + (io?.get('outputParameters') as ModdleElement[] | undefined) ?? []; + if (outputs.length === 0) return; + throw new UnsupportedExtensionFormError( + id, + 'an operaton:outputParameter on a , which ' + + 'BpmnParse.checkActivityOutputParameterSupported fails the deployment ' + + 'on ("operaton:outputParameter not allowed for element type ' + + "'endEvent'\")", + ); +} + /** As {@link START_CARRIED_TAGS}, for the triggers an emit may carry. */ const EMIT_CARRIED_TAGS = { escalation: 'bpmn:EscalationEventDefinition', @@ -3528,19 +4671,44 @@ const EMIT_CARRIED_TAGS = { link: 'bpmn:LinkEventDefinition', } satisfies Record<(typeof EMIT_TRIGGERS)[number], string>; +/** + * A definition-less throw (the Modeler's milestone marker) is a step: + * `BpmnParse.parseIntermediateThrowEvent` gives it + * `IntermediateThrowNoneEventActivityBehavior`, whose `execute` is the bare + * `leave` a `TaskActivityBehavior` performs, and reads the same async and + * listener settings off it (`parseAsynchronousContinuationForActivity`, + * `parseExecutionListenersOnScope`). `createActivityOnScope` keeps the tag + * as the activity type, so history and Cockpit report `task` for it once + * written back; the warning names that rewrite, as the manual task's does. + */ function mapIntermediateThrowEvent( el: ModdleElement, warnings: ImportWarning[], -): IntermediateThrowEvent { +): IntermediateThrowEvent | Task { const id = requireId(el); const defs = eventDefinitionsOf(el); if (defs.length === 0) { - throw new UnsupportedEventFeatureError( + const named = readNamed(el, id, warnings); + warnings.push({ + elementId: id, + category: 'unmappedConstruct', + message: + `The bpmn:intermediateThrowEvent '${id}' carries no event ` + + 'definition and imports as a plain step: ' + + 'BpmnParse.parseIntermediateThrowEvent gives it ' + + 'IntermediateThrowNoneEventActivityBehavior, which only leaves, as ' + + "a task's behaviour does, so token flow, listeners, async and job " + + 'configuration are unchanged, but history and Cockpit will report ' + + "its activity type as 'task' rather than 'intermediateThrowEvent'.", + }); + return { + kind: 'task', id, - 'an emit with no event definition (a "none" intermediate throw) ' + - 'fires nothing this tool can represent', - ); + ...named, + ...readThrowEventAttributes(el, id, undefined, warnings), + ...readIoMapping(el, id, warnings), + }; } refuseMultipleEventDefinitions( id, @@ -3572,25 +4740,97 @@ function mapIntermediateThrowEvent( return { kind: 'intermediateThrowEvent', id, eventDefinition }; } warnNamedDrop(el, id, 'an emit', warnings); + // `parseActivity` runs the mapping on this throw as on the none throw + // above, which carries it as a step; the block is marked read on the tag + // for that arm's sake, so this node, which has no slot, reports it by hand. + // A second block still fails the deployment (Element.elementNS), so it is + // refused, not warned twice. + const io = onlyExtensionElement( + el, + 'operaton:InputOutput', + id, + 'BpmnParseUtil.parseInputOutput', + ); + if (io !== undefined) { + warnUnimportedSetting(warnings, id, childSubject(io), el.$type, io.$type); + } + const binding = readThrownMessageBinding( + defEl, + id, + 'intermediateMessageThrowEvent', + warnings, + ); return { kind: 'intermediateThrowEvent', id, eventDefinition, - ...readThrownMessageBinding(defEl, id, warnings), - ...readEngineAttributes(el, id, warnings), + ...(binding === undefined ? {} : { binding }), + ...readThrowEventAttributes(el, id, binding, warnings), }; } -/** The connector child under either prefix: `operaton:` is declared, `camunda:` arrives raw. */ -const CONNECTOR_TYPES: ReadonlySet = new Set([ - 'operaton:Connector', - 'camunda:connector', -]); +/** + * The engine attributes of an end or an intermediate throw, after the one + * extension block the tag itself carries for `parseServiceTaskLike`: + * `operaton:properties`, which `parseExternalServiceTask` reads off the event + * element of a thrown message bound with `operaton:type="external"` and + * nothing reads off any other shape of these two tags. The block is marked + * read on both ({@link THROW_EVENT_TAGS}), so every shape reports it here. + */ +function readThrowEventAttributes( + el: ModdleElement, + id: string, + binding: ServiceTaskBinding | undefined, + warnings: ImportWarning[], +): EngineAttributes { + for (const block of extensionValues(el)) { + if (block.$type !== 'operaton:Properties') continue; + warnThrownMessageExtraDrop( + id, + 'operaton:properties block', + binding?.kind === 'external' ? 'the event element' : undefined, + warnings, + ); + } + return readEngineAttributes(el, id, warnings); +} + +/** + * One drop of what `BpmnParse.parseExternalServiceTask` reads: with an + * external binding it reads `what` off `readOff` (the message definition or + * the event element, the two elements `parseServiceTaskLike` is handed) and + * this surface's throw has no position for it; under any other binding, or + * none, nothing reaches that reader. + */ +function warnThrownMessageExtraDrop( + id: string, + what: string, + readOff: 'the message definition' | 'the event element' | undefined, + warnings: ImportWarning[], +): void { + warnings.push({ + elementId: id, + category: 'extensionAttribute', + message: + readOff === undefined + ? `The ${what} on '${id}' was not imported: Operaton reads it in ` + + 'parseExternalServiceTask alone, which only a thrown message ' + + 'bound with operaton:type="external" reaches, so the event runs ' + + 'as written without it.' + : `The ${what} on '${id}' was not imported: ` + + `BpmnParse.parseExternalServiceTask reads it off ${readOff} of a ` + + 'thrown message bound with operaton:type="external", and this ' + + "surface's throw has no position for it, so the document written " + + 'back runs without it.', + }); +} -/** True when any extension child of `el` is a connector, under either prefix. */ +/** True when any extension child of `el` is a connector (a camunda: spelling types the same). */ function hasConnector(el: ModdleElement): boolean { - return extensionValues(el).some((value) => CONNECTOR_TYPES.has(value.$type)); + return extensionValues(el).some( + (value) => value.$type === 'operaton:Connector', + ); } /** @@ -3601,13 +4841,22 @@ function hasConnector(el: ModdleElement): boolean { * external worker only with `type="external"` beside it. A connector is that * implementation in element form, which this surface cannot keep, so it refuses * rather than turning the send into a no-op. + * + * `parseServiceTaskLike` reads the rest of a service task's settings off the + * definition too, and the throw statement has a slot for none of them: the + * result variable is refused where the engine refuses it beside a class or a + * delegate (`elementName` is the tag as that refusal names it) and where it + * stores the expression's value under it, and dropped where the `type` + * branch never reads it; the fields, the priority and the error mappings are + * dropped naming what reads each. */ function readThrownMessageBinding( defEl: ModdleElement, id: string, + elementName: 'intermediateMessageThrowEvent' | 'messageEndEvent', warnings: ImportWarning[], -): { binding?: ServiceTaskBinding } { - if (defEl.$type !== 'bpmn:MessageEventDefinition') return {}; +): ServiceTaskBinding | undefined { + if (defEl.$type !== 'bpmn:MessageEventDefinition') return undefined; if (hasConnector(defEl)) { throw new UnsupportedEventFeatureError( @@ -3623,15 +4872,20 @@ function readThrownMessageBinding( warnings, 'thrownMessage', ); - if (binding !== undefined) return { binding }; - if (readNamespacedAttr(defEl, 'type') !== undefined) { + if ( + binding === undefined && + readNamespacedAttr(defEl, 'type') !== undefined + ) { throw new UnsupportedServiceTaskFormError( id, detectUnsupportedServiceTaskForm(defEl, 'thrownMessage'), 'Thrown message', ); } - if (readNamespacedAttr(defEl, 'topic') !== undefined) { + if ( + binding === undefined && + readNamespacedAttr(defEl, 'topic') !== undefined + ) { warnings.push({ elementId: id, category: 'extensionAttribute', @@ -3641,7 +4895,69 @@ function readThrownMessageBinding( 'and was not imported.', }); } - return {}; + + const refusal = (construct: string): Error => + new UnsupportedEventFeatureError( + id, + `its message definition binds ${construct}`, + ); + if (binding !== undefined) { + refuseResultVariableBeside(binding, defEl, elementName, refusal); + } + const resultVariableAttr = writtenResultVariableAttr(defEl); + if (resultVariableAttr !== undefined) { + if (binding?.kind === 'expression') { + throw refusal( + `operaton:expression with operaton:${resultVariableAttr}=` + + `"${readNamespacedAttr(defEl, resultVariableAttr)}", under which ` + + "BpmnParse.parseServiceTaskLike stores the expression's value " + + '(ServiceTaskExpressionActivityBehavior); a thrown message in ' + + 'this script takes no result variable, so dropping it would ' + + 'change what runs', + ); + } + warnings.push({ + elementId: id, + category: 'extensionAttribute', + message: + `The '${resultVariableAttr}' setting on '${id}' was not imported: ` + + 'BpmnParse.parseServiceTaskLike hands it to an expression binding ' + + `alone, so ${ + binding === undefined + ? 'a definition naming no implementation' + : 'an operaton:type="external" binding' + } never writes it.`, + }); + } + + const where = `the message definition of '${id}'`; + for (const field of fieldChildren(defEl)) { + warnFieldDrop( + field, + id, + where, + binding !== undefined && carriesFields(binding) + ? `${FIELD_HAS_NO_HOME}; BpmnParse.parseServiceTaskLike reads it ` + + `off the definition into the ${binding.kind} it names, so the ` + + 'document written back runs that without it' + : `${FIELD_HAS_NO_HOME}, and Operaton injects a field into a class ` + + 'or a delegate binding and into no other', + warnings, + ); + } + + const external = binding?.kind === 'external'; + const readOff = external ? 'the message definition' : undefined; + const taskPriority = readNamespacedAttr(defEl, 'taskPriority'); + if (taskPriority !== undefined) { + if (external) requireIntegerOrExpression(taskPriority, id, 'taskPriority'); + warnThrownMessageExtraDrop(id, "'taskPriority' setting", readOff, warnings); + } + for (const mapping of extensionValues(defEl)) { + if (mapping.$type !== 'operaton:ErrorEventDefinition') continue; + warnThrownMessageExtraDrop(id, childSubject(mapping), readOff, warnings); + } + return binding; } /** The triggers an await may head, as the refusals below name them. */ @@ -3661,6 +4977,15 @@ function mapIntermediateCatchEvent( warnings: ImportWarning[], ): IntermediateCatchEvent { const id = requireId(el); + // A second block still fails the deployment (Element.elementNS); the + // single-block drop is reported by the owner sweep, which has no + // multiplicity of its own to check. + onlyExtensionElement( + el, + 'operaton:InputOutput', + id, + 'BpmnParseUtil.parseInputOutput', + ); if (el.get('parallelMultiple') === true) { throw new UnsupportedEventFeatureError( @@ -3741,16 +5066,20 @@ function refuseLoopCharacteristics(el: ModdleElement, id: string): void { /** * How often a step runs, or `undefined` for one that runs once. * - * Operaton reads `operaton:collection` and then `bpmn:loopDataInputRef` into - * one field, and `operaton:elementVariable` and then `bpmn:inputDataItem/@name` - * into another, the second spelling of each pair overwriting the first. Both - * pairs therefore import into one field here too, with a warning naming what - * was shadowed. What the engine refuses to deploy is refused here as well, - * rather than imported into a process that cannot start. `bpmn:standardLoopCharacteristics` - * is neither: `parseMultiInstanceLoopCharacteristics` looks only for the - * multi-instance child and returns null otherwise, so the engine builds the - * activity through its normal arm and runs it once, and a standard loop drops - * with a warning rather than refusing. + * Each pair of spellings imports into one field here, with a warning naming + * what was shadowed and the rule that decided it ({@link readCollection}, + * {@link readElementVariable}). What the engine refuses to deploy is refused + * here as well, rather than imported into a process that cannot start. + * `bpmn:standardLoopCharacteristics` is neither: + * `parseMultiInstanceLoopCharacteristics` looks only for the multi-instance + * child and returns null otherwise, so the engine builds the activity through + * its normal arm and runs it once, and a standard loop drops with a warning + * rather than refusing. + * + * A `completionCondition` reaches `createExpression` as text alone: a body + * with no `${`/`#{` opener is a literal there, which + * `MultiInstanceActivityBehavior.completionConditionSatisfied` throws on, and + * a `language` attribute is read by nothing. */ function readLoopCharacteristics( el: ModdleElement, @@ -3796,6 +5125,37 @@ function readLoopCharacteristics( const conditionEl = getEl(loopEl, 'completionCondition'); const completionCondition = conditionEl === undefined ? undefined : readString(conditionEl, 'body'); + if ( + completionCondition !== undefined && + !EXPRESSION_BODY.test(completionCondition) + ) { + warnings.push({ + elementId: id, + category: 'unmappedConstruct', + message: + `The bpmn:completionCondition on '${id}' is the bare text ` + + `${JSON.stringify(completionCondition)} with no "\${...}" or ` + + '"#{...}" opener: parseMultiInstanceLoopCharacteristics hands it to ' + + 'createExpression as a literal and ' + + 'MultiInstanceActivityBehavior.completionConditionSatisfied throws ' + + 'expressionNotBooleanException when the first run completes; the ' + + 'script writes it inside "${...}", which evaluates it.', + }); + } + const conditionLanguage = + conditionEl === undefined ? undefined : readString(conditionEl, 'language'); + if (conditionLanguage !== undefined) { + warnings.push({ + elementId: id, + category: 'unmappedConstruct', + message: + `The language=${JSON.stringify(conditionLanguage)} on the ` + + `bpmn:completionCondition of '${id}' was not imported: ` + + 'parseMultiInstanceLoopCharacteristics hands the text alone to ' + + 'createExpression and reads no language, so the imported step runs ' + + 'the same.', + }); + } noteRewrappedExpression( completionCondition, id, @@ -3856,6 +5216,13 @@ function readCardinality( * resolves the slot as an id reference, answering with the element when the * text names one and dropping it otherwise, so the dropped text is read back * from what the parse reported. + * + * `BpmnParse.parseMultiInstanceLoopCharacteristics` stores each spelling in + * one of two fields by whether its text contains `{`, the reference second, + * and `MultiInstanceActivityBehavior.resolveNrOfInstances` reads the + * expression field before the variable field. So two values of one shape + * overwrite each other and the reference wins; of two shapes, the expression + * wins whichever came second. */ function readCollection( loopEl: ModdleElement, @@ -3866,16 +5233,27 @@ function readCollection( const referenced = getEl(loopEl, 'loopDataInputRef')?.id ?? unresolvedRef(loopEl, 'loopDataInputRef'); - if (setting !== undefined && referenced !== undefined) { - warnShadowedRepetitionField(warnings, id, { - winner: 'bpmn:loopDataInputRef', - loser: 'operaton:collection', - names: 'the collection', - kept: referenced, - dropped: setting, - }); - } - return referenced ?? setting; + if (setting === undefined || referenced === undefined) { + return referenced ?? setting; + } + const isExpression = (text: string): boolean => text.includes('{'); + const sameShape = isExpression(setting) === isExpression(referenced); + const kept = sameShape || isExpression(referenced) ? referenced : setting; + warnShadowedRepetitionField(warnings, id, { + first: 'operaton:collection', + second: 'bpmn:loopDataInputRef', + names: 'the collection', + rule: sameShape + ? 'parseMultiInstanceLoopCharacteristics writes both into the same ' + + 'field and bpmn:loopDataInputRef second' + : 'parseMultiInstanceLoopCharacteristics stores an expression (a ' + + 'value containing "{") and a variable name in two fields, and ' + + 'MultiInstanceActivityBehavior.resolveNrOfInstances reads the ' + + 'expression field first', + kept, + dropped: kept === referenced ? setting : referenced, + }); + return kept; } function readElementVariable( @@ -3887,11 +5265,15 @@ function readElementVariable( const dataItem = getEl(loopEl, 'inputDataItem'); const itemName = dataItem === undefined ? undefined : readString(dataItem, 'name'); + // One field in the engine (`setCollectionElementVariable`), written twice. if (setting !== undefined && itemName !== undefined) { warnShadowedRepetitionField(warnings, id, { - winner: 'bpmn:inputDataItem', - loser: 'operaton:elementVariable', + first: 'bpmn:inputDataItem', + second: 'operaton:elementVariable', names: 'what each run sees', + rule: + 'parseMultiInstanceLoopCharacteristics writes both into the same ' + + 'field and bpmn:inputDataItem second', kept: itemName, dropped: setting, }); @@ -3915,13 +5297,15 @@ function readElementVariable( return elementVariable; } +/** `first` and `second` are the two spellings in the sentence's order; `rule` says which the engine keeps. */ function warnShadowedRepetitionField( warnings: ImportWarning[], id: string, shadow: { - winner: string; - loser: string; + first: string; + second: string; names: string; + rule: string; kept: string; dropped: string; }, @@ -3930,13 +5314,16 @@ function warnShadowedRepetitionField( elementId: id, category: 'extensionAttribute', message: - `Both ${shadow.winner} and ${shadow.loser} name ${shadow.names} on ` + - `'${id}'; Operaton reads ${shadow.winner} second, so ` + - `'${shadow.kept}' was imported and '${shadow.dropped}' was dropped.`, + `Both ${shadow.first} and ${shadow.second} name ${shadow.names} on ` + + `'${id}'; ${shadow.rule}, so '${shadow.kept}' was imported and ` + + `'${shadow.dropped}' was dropped.`, }); } -/** Operaton refuses to deploy an output mapping on a step that repeats. */ +/** + * `BpmnParse.checkActivityOutputParameterSupported` fails the deployment of + * an output mapping on a step that repeats. + */ function refuseOutputParameters(el: ModdleElement, id: string): void { const io = extensionValues(el).find( (value) => value.$type === 'operaton:InputOutput', @@ -3947,8 +5334,10 @@ function refuseOutputParameters(el: ModdleElement, id: string): void { throw new UnsupportedLoopCharacteristicsError( id, MULTI_INSTANCE, - "it maps an 'operaton:outputParameter', which Operaton refuses to " + - 'deploy on a repeated step', + "it maps an 'operaton:outputParameter', which " + + 'BpmnParse.checkActivityOutputParameterSupported fails the ' + + 'deployment on ("operaton:outputParameter not allowed for ' + + 'multi-instance constructs")', ); } } @@ -4023,7 +5412,6 @@ function mapUserTask(el: ModdleElement, warnings: ImportWarning[]): UserTask { const dueDate = readNamespacedAttr(el, 'dueDate'); const followUpDate = readNamespacedAttr(el, 'followUpDate'); const priority = readNamespacedAttr(el, 'priority'); - noteRewrappedExpression(priority, id, "'priority' setting", warnings); return { kind: 'userTask', @@ -4082,6 +5470,51 @@ function assignmentId(entry: string, prefix: string): string { return entry.slice(prefix.length, -1).trim(); } +/** + * A role's entries as `parsePotentialOwnerResourceAssignment` and + * `parsePotentialStarterResourceAssignment` sort them: `user(...)` to the + * users, `group(...)` and a bare entry to the groups. + */ +function splitCandidates(text: string): { users: string[]; groups: string[] } { + const users: string[] = []; + const groups: string[] = []; + for (const entry of splitAsEngine(text)) { + if (entry.startsWith(USER_PREFIX)) { + users.push(assignmentId(entry, USER_PREFIX)); + } else if (entry.startsWith(GROUP_PREFIX)) { + groups.push(assignmentId(entry, GROUP_PREFIX)); + } else { + groups.push(entry); + } + } + return { users, groups }; +} + +/** The role entries first and the attribute's text after, as one list the engine reads the same. */ +function mergeCandidates( + fromRoles: string[], + fromAttr: string | undefined, +): string | undefined { + const all = fromAttr === undefined ? fromRoles : [...fromRoles, fromAttr]; + return all.length === 0 ? undefined : all.join(','); +} + +/** + * The text of a role's ``. Every role reader fetches + * this child by tag (`Element.elementsNS`), so only that spelling reaches the + * engine; moddle gives the same `$type` to ``, and the `xsi:type` attribute is what + * tells the two apart. + */ +function formalExpressionTextOf(role: ModdleElement): string | undefined { + const rae = getEl(role, 'resourceAssignmentExpression'); + const expression = rae === undefined ? undefined : getEl(rae, 'expression'); + return expression?.$type === 'bpmn:FormalExpression' && + expression.$attrs['xsi:type'] === undefined + ? readString(expression, 'body') + : undefined; +} + /** * Merged as the engine builds its lists: `BpmnParse.parseTaskDefinition` * reads the roles first and `parseUserTaskCustomExtensions` appends the @@ -4094,7 +5527,7 @@ function readAssignment( warnings: ImportWarning[], ): Pick { const roles = (el.get('resources') as ModdleElement[] | undefined) ?? []; - const attrAssignee = readNamespacedAttr(el, 'assignee'); + const attrAssignee = readText(el, 'operaton:assignee'); const performers = roles.filter((r) => r.$type === 'bpmn:HumanPerformer'); if (performers.length > 1) { throw new UnsupportedAssignmentError( @@ -4124,17 +5557,7 @@ function readAssignment( ); continue; } - const rae = getEl(role, 'resourceAssignmentExpression'); - const expression = rae === undefined ? undefined : getEl(rae, 'expression'); - // Both role readers fetch this child by tag (Element.elementsNS), so - // only a reaches the engine. moddle gives the same - // $type to ; the - // xsi:type attribute is what tells the two apart. - const text = - expression?.$type === 'bpmn:FormalExpression' && - expression.$attrs['xsi:type'] === undefined - ? readString(expression, 'body') - : undefined; + const text = formalExpressionTextOf(role); if (text === undefined) { drop( 'it carries no formal expression, and Operaton reads nothing else ' + @@ -4157,17 +5580,7 @@ function readAssignment( assignee = text; became.push(['assignee', text]); } else { - const roleUsers: string[] = []; - const roleGroups: string[] = []; - for (const entry of splitAsEngine(text)) { - if (entry.startsWith(USER_PREFIX)) { - roleUsers.push(assignmentId(entry, USER_PREFIX)); - } else if (entry.startsWith(GROUP_PREFIX)) { - roleGroups.push(assignmentId(entry, GROUP_PREFIX)); - } else { - roleGroups.push(entry); - } - } + const { users: roleUsers, groups: roleGroups } = splitCandidates(text); users.push(...roleUsers); groups.push(...roleGroups); if (roleUsers.length > 0) @@ -4209,18 +5622,11 @@ function readAssignment( } } - const merged = ( - fromRoles: string[], - fromAttr: string | undefined, - ): string | undefined => { - const all = fromAttr === undefined ? fromRoles : [...fromRoles, fromAttr]; - return all.length === 0 ? undefined : all.join(','); - }; - const candidateUsers = merged( + const candidateUsers = mergeCandidates( users, readNamespacedAttr(el, 'candidateUsers'), ); - const candidateGroups = merged( + const candidateGroups = mergeCandidates( groups, readNamespacedAttr(el, 'candidateGroups'), ); @@ -4340,12 +5746,30 @@ function mapServiceTask( ): ServiceTask { const id = requireId(el); const named = readNamed(el, id, warnings); - const resultVariable = readNamespacedAttr(el, 'resultVariable'); + const binding = readServiceTaskBinding(el, id, element, warnings); + const resultVariable = readResultVariable(el, id, warnings); + // `parseServiceTaskLike` hands the variable to the expression behaviour + // alone; its `type` branches never read it, so the value deploys and does + // nothing, and the compile side warns at the same setting. + if ( + resultVariable !== undefined && + (binding.kind === 'external' || binding.kind === 'builtin') + ) { + warnings.push({ + elementId: id, + category: 'extensionAttribute', + message: + `The resultVariable '${resultVariable}' on '${id}' was imported as ` + + 'written, and the printed script draws a warning at the step: ' + + 'BpmnParse.parseServiceTaskLike hands it to an expression binding ' + + 'alone, so an operaton:type binding never writes it.', + }); + } return { kind: 'serviceTask', id, ...named, - binding: readServiceTaskBinding(el, id, element, warnings), + binding, ...(resultVariable === undefined ? {} : { resultVariable }), ...(element === undefined ? {} : { element }), ...readEngineAttributes(el, id, warnings), @@ -4353,6 +5777,42 @@ function mapServiceTask( }; } +/** + * `BpmnParse.parseResultVariable` reads `resultVariable` and falls back to + * the older `resultVariableName`, on a service-like task, a script task and a + * DMN task alike; the fallback is respelled on import and the shadowed one + * reported. + */ +function readResultVariable( + el: ModdleElement, + id: string, + warnings: ImportWarning[], +): string | undefined { + const current = readNamespacedAttr(el, 'resultVariable'); + const older = readNamespacedAttr(el, 'resultVariableName'); + if (older === undefined) return current; + if (current !== undefined) { + warnings.push( + buildShadowedImplementationWarning( + id, + 'resultVariableName', + 'operaton:resultVariable', + ), + ); + return current; + } + warnings.push({ + elementId: id, + category: 'unmappedConstruct', + message: + `The operaton:resultVariableName="${older}" on '${id}' imports as ` + + `resultVariable: "${older}": BpmnParse.parseResultVariable reads the ` + + 'two spellings as one, and this tool writes it back as ' + + 'operaton:resultVariable, which the engine reads the same.', + }); + return older; +} + /** The noun a refusal leads with, per tag. */ const SERVICE_TASK_LIKE_SUBJECT = { service: 'Service task', @@ -4428,9 +5888,7 @@ function refuseResultVariableBeside( refusal: (construct: string) => Error, ): void { if (binding.kind !== 'class' && binding.kind !== 'delegateExpression') return; - const written = ['resultVariable', 'resultVariableName'].find( - (attr) => readNamespacedAttr(el, attr) !== undefined, - ); + const written = writtenResultVariableAttr(el); if (written === undefined) return; throw refusal( `operaton:${binding.kind} with operaton:${written}, which Operaton ` + @@ -4440,6 +5898,15 @@ function refuseResultVariableBeside( ); } +/** The result-variable spelling the element writes, in the order `parseResultVariable` reads them. */ +function writtenResultVariableAttr( + el: ModdleElement, +): 'resultVariable' | 'resultVariableName' | undefined { + return (['resultVariable', 'resultVariableName'] as const).find( + (attr) => readNamespacedAttr(el, attr) !== undefined, + ); +} + /** * Refuse the field lists the engine's parse refuses, in its order: the shell * value shapes and flags come before the missing-field checks, and the @@ -4458,7 +5925,7 @@ function refuseBuiltinShapes( const method = `BpmnParse.${BUILTIN_FIELD_VALIDATOR[type]}`; if (type === 'shell') { - const evaluated = fields.find((field) => field.value.startsWith('${')); + const evaluated = fields.find((field) => EXPRESSION_OPEN.test(field.value)); if (evaluated !== undefined) { throw refusal( `${named} with the field '${evaluated.name}' written as an ` + @@ -4530,12 +5997,6 @@ function withExternalExtras( warnings: ImportWarning[], ): ServiceTaskBinding { const taskPriority = readNamespacedAttr(el, 'taskPriority'); - const propertiesEl = firstExtensionElement( - el, - 'operaton:Properties', - id, - warnings, - ); const definitions = extensionValues(el).filter( (value) => value.$type === 'operaton:ErrorEventDefinition', ); @@ -4543,7 +6004,9 @@ function withExternalExtras( if (binding.kind !== 'external') { const present = [ ...(taskPriority === undefined ? [] : ["'taskPriority' setting"]), - ...(propertiesEl === undefined ? [] : ['operaton:properties block']), + ...extensionValues(el) + .filter((value) => value.$type === 'operaton:Properties') + .map(() => 'operaton:properties block'), ...definitions.map( (defEl) => 'operaton:errorEventDefinition' + @@ -4564,6 +6027,12 @@ function withExternalExtras( return binding; } + const propertiesEl = onlyExtensionElement( + el, + 'operaton:Properties', + id, + 'BpmnParseUtil.parseOperatonExtensionProperties', + ); const properties = propertiesEl === undefined ? [] @@ -4574,25 +6043,10 @@ function withExternalExtras( message, }); }); - const errorMappings = definitions.map((defEl) => - readErrorMapping(defEl, id, warnings), + const errorMappings = definitions.flatMap( + (defEl) => readErrorMapping(defEl, id, warnings) ?? [], ); - if ( - taskPriority !== undefined && - !FORM_BOUND_TEXT.test(taskPriority) && - !EXPRESSION_OPEN.test(taskPriority) - ) { - warnings.push({ - elementId: id, - category: 'extensionAttribute', - message: - `The taskPriority '${taskPriority}' on '${id}' was imported as ` + - 'written, and the printed script draws an error at the step: ' + - 'BpmnParse.parsePriority parses a constant as an integer and fails ' + - 'the deployment on any other.', - }); - } - noteRewrappedExpression(taskPriority, id, "'taskPriority' setting", warnings); + requireIntegerOrExpression(taskPriority, id, 'taskPriority'); return { ...binding, ...(taskPriority === undefined ? {} : { taskPriority }), @@ -4602,43 +6056,53 @@ function withExternalExtras( } /** - * Checked in the order `parseOperatonErrorEventDefinitions` branches. moddle - * deletes an `errorRef` naming no root, so a definition written without one - * and one whose reference dangles arrive alike; both refuse. + * Checked in the order `parseOperatonErrorEventDefinitions` branches: a + * definition with no `errorRef` is skipped whole, one with a reference and no + * `expression` fails the deployment, and a reference naming no root is read + * as the code ({@link readDanglingErrorCode}). moddle deletes a reference it + * cannot resolve, so the skipped shape and the dangling one are told apart + * by the recorded text alone. `undefined` for the skipped shape. */ function readErrorMapping( defEl: ModdleElement, ownerId: string, warnings: ImportWarning[], -): ErrorMapping { +): ErrorMapping | undefined { warnThrowSideBindingAttrs(defEl, ownerId, warnings); warnDocumentationDrop(defEl, ownerId, 'an error mapping', warnings); const ref = getEl(defEl, 'errorRef'); - const written = unresolvedRef(defEl, 'errorRef'); - if (ref === undefined && written === undefined) { - throw new UnsupportedErrorMappingError( - ownerId, - 'its errorRef names no error root', - ); + const errorCode = ref + ? readString(ref, 'errorCode') + : readDanglingErrorCode(defEl, ownerId, warnings); + if (ref === undefined && errorCode === undefined) { + warnings.push({ + elementId: ownerId, + category: 'unmappedConstruct', + message: + `The operaton:errorEventDefinition on '${ownerId}' carries no ` + + 'errorRef and was not imported: ' + + 'BpmnParse.parseOperatonErrorEventDefinitions skips one without ' + + 'it, so the document written back runs the same.', + }); + return undefined; } const condition = readString(defEl, 'expression'); + noteRewrappedExpression( + condition, + ownerId, + 'operaton:errorEventDefinition expression', + warnings, + ); if (condition === undefined) { throw new UnsupportedErrorMappingError( ownerId, 'the operaton:errorEventDefinition carries no expression', ); } - if (ref === undefined) { - throw new UnsupportedErrorMappingError( - ownerId, - `its errorRef '${written}' names no error root`, - ); - } - const errorCode = readString(ref, 'errorCode'); if (errorCode === undefined) { throw new UnsupportedErrorMappingError( ownerId, - `its errorRef names the error root '${ref.id}', which carries no code`, + `its errorRef names the error root '${ref?.id}', which carries no code`, ); } return { errorCode, condition }; @@ -4741,6 +6205,11 @@ function readDecisionBinding( } warnShadowedImplementation(el, id, 'an operaton:decisionRef', [], warnings); + const tenantId = readNamespacedAttr(el, 'decisionRefTenantId'); + if (tenantId !== undefined) { + throw refusal(tenantPinDetail('decisionRefTenantId', tenantId, 'decision')); + } + const mapping = readNamespacedAttr(el, 'mapDecisionResult'); const mapDecisionResult = DECISION_RESULT_MAPPINGS.find((m) => m === mapping); if (mapping !== undefined && mapDecisionResult === undefined) { @@ -4767,6 +6236,7 @@ function readDecisionBinding( const DECISION_MODIFIER_ATTRS = [ 'decisionRefBinding', 'decisionRefVersion', + 'decisionRefTenantId', 'mapDecisionResult', ] as const; @@ -4808,13 +6278,14 @@ function buildShadowedImplementationWarning( id: string, attr: string, winner: string, + subject = `'${id}'`, ): ImportWarning { return { elementId: id, category: 'extensionAttribute', message: - `The '${attr}' setting on '${id}' has no effect alongside ${winner} ` + - 'and was not imported.', + `The '${attr}' setting on ${subject} has no effect alongside ` + + `${winner} and was not imported.`, }; } @@ -4910,20 +6381,66 @@ function mapScriptTask( // importing the body here would keep a script the engine never runs. throw new UnsupportedExtensionFormError( id, - externalResourceDetail(`the script on '${id}'`, resource), + externalResourceDetail(`the script on '${id}'`, resource), + ); + } + const named = readNamed(el, id, warnings); + // moddle reports a missing attribute as `undefined` and a present-but-empty + // one as `''`; `BpmnParse.parseScriptTaskElement` defaults only the former + // to `ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE` ("juel"), while + // `ScriptUtil.getScript` refuses the latter outright. + const rawFormat = el.get('scriptFormat') as string | undefined; + if (rawFormat === '') { + throw new UnsupportedExtensionFormError( + id, + `the script on '${id}' has an empty scriptFormat; ` + + '`ScriptUtil.getScript` refuses to deploy it', + ); + } + const body = el.get('script'); + if (typeof body !== 'string') { + throw new UnsupportedExtensionFormError( + id, + `the script on '${id}' has neither a script body nor a resource; ` + + '`ScriptUtil.getScript` refuses to deploy it with neither', ); } - const named = readNamed(el, id, warnings); - const format = readString(el, 'scriptFormat') ?? ''; - const body = el.get('script'); - const code = typeof body === 'string' ? body : ''; - const resultVariable = readNamespacedAttr(el, 'resultVariable'); + let format: string; + if (rawFormat === undefined) { + format = 'juel'; + warnings.push({ + elementId: id, + category: 'extensionAttribute', + message: + `The script on '${id}' has no scriptFormat; ` + + '`BpmnParse.parseScriptTaskElement` substitutes ' + + '`ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE` (juel), and it was ' + + 'imported as such.', + }); + } else { + const canonical = scriptFormatOf(rawFormat); + if (canonical === undefined) { + format = rawFormat; + warnings.push({ + elementId: id, + category: 'extensionAttribute', + message: + `The script on '${id}' names the language '${rawFormat}', which ` + + 'the DSL has no fence alias for; it was imported as written, and ' + + 'the printed script draws an error there.', + }); + } else { + format = canonical; + } + } + checkScriptBody(body, id, `the script on '${id}'`, warnings); + const resultVariable = readResultVariable(el, id, warnings); return { kind: 'scriptTask', id, ...named, format, - code, + code: body, ...(resultVariable === undefined ? {} : { resultVariable }), ...readEngineAttributes(el, id, warnings), ...readIoMapping(el, id, warnings), @@ -4941,6 +6458,7 @@ function mapDefaultingGateway( warnings: ImportWarning[], ): ExclusiveGateway | InclusiveGateway { const id = requireId(el); + refuseIoMapping(el, id, 'checkActivityInputOutputSupported'); const named = readNamed(el, id, warnings, readString(el, 'name')); const defaultFlowId = getEl(el, 'default')?.id; @@ -4958,6 +6476,7 @@ function mapParallelGateway( warnings: ImportWarning[], ): ParallelGateway { const id = requireId(el); + refuseIoMapping(el, id, 'checkActivityInputOutputSupported'); const named = readNamed(el, id, warnings, readString(el, 'name')); return { kind: 'parallelGateway', @@ -4977,6 +6496,7 @@ function mapEventBasedGateway( warnings: ImportWarning[], ): EventBasedGateway { const id = requireId(el); + refuseIoMapping(el, id, 'checkActivityInputOutputSupported'); if (readNamespacedFlag(el, 'asyncAfter') === true) { throw new UnsupportedEventFeatureError( id, @@ -5001,7 +6521,7 @@ function mapEventBasedGateway( * nothing of: `BpmnParse.parseEventBasedGateway` reads neither `instantiate` * nor `eventGatewayType`, which appear in the engine's schema and nowhere in * its parser. Reported by hand, for the reason - * {@link warnDroppedDefaultFlow} gives. + * {@link warnIgnoredQuantityAttrs} gives. * * `gatewayDirection` is left out on purpose. It restates the flows already * imported, carries no execution meaning, and is stamped on most exported @@ -5031,6 +6551,14 @@ function warnIgnoredWaitAttrs( } } +/** + * A condition body the engine evaluates as text rather than as an expression + * always fails the flow: `JuelExpressionManager.createExpression` builds a + * literal from a body with no `${`/`#{` opener, and + * `UelExpressionCondition.evaluate` throws on the string it yields. The + * script has no spelling for that body but inside `${...}`, so the rewrap is + * reported as the change in what runs that it is. + */ function mapSequenceFlow( el: ModdleElement, warnings: ImportWarning[], @@ -5041,15 +6569,59 @@ function mapSequenceFlow( const sourceRef = requireFlowEndpoint(el, 'sourceRef', id); const targetRef = requireFlowEndpoint(el, 'targetRef', id); - const expressionEl = el.get('conditionExpression') as - ModdleElement | undefined; + const expressionEl = getEl(el, 'conditionExpression'); + const dropped = + expressionEl === undefined ? DROPPED_CONDITIONS.get(el) : undefined; if (expressionEl !== undefined) { checkConditionExpressionForm(expressionEl, id, warnings); } + if (dropped !== undefined) { + if (dropped.xsiType !== 'tFormalExpression') { + throw conditionTypeRefusal(dropped.xsiType, id); + } + // The dropped element never reaches `checkConditionExpressionForm`, but + // `BpmnParse.parseConditionExpression` still reads its `language` and + // `resource` the same way, so it refuses or warns on the same grounds. + refuseOrWarnScriptedCondition( + dropped.language, + dropped.resource, + id, + warnings, + ); + } const conditionExpression = expressionEl !== undefined - ? ((expressionEl.get('body') as string | undefined) ?? undefined) - : undefined; + ? readString(expressionEl, 'body') + : dropped === undefined || dropped.body.trim().length === 0 + ? undefined + : dropped.body; + const written = expressionEl !== undefined || dropped !== undefined; + if (written && conditionExpression === undefined) { + warnings.push({ + elementId: id, + category: 'unmappedConstruct', + message: + `The condition on '${id}' has an empty body: ` + + 'UelExpressionCondition.evaluate reads it as a string and fails the ' + + 'flow on every run ("condition expression returns non-Boolean"); ' + + 'the flow was imported with no condition.', + }); + } else if ( + conditionExpression !== undefined && + !EXPRESSION_BODY.test(conditionExpression) + ) { + warnings.push({ + elementId: id, + category: 'unmappedConstruct', + message: + `The condition on '${id}' is the bare text ` + + `${JSON.stringify(conditionExpression)} with no "\${...}" or ` + + '"#{...}" opener: UelExpressionCondition.evaluate reads it as a ' + + 'string and fails the flow on every run ("condition expression ' + + 'returns non-Boolean"); the script writes it inside "${...}", which ' + + 'evaluates it.', + }); + } noteRewrappedExpression( conditionExpression, id, @@ -5057,6 +6629,26 @@ function mapSequenceFlow( warnings, ); + // The exporter names a conditioned flow by its condition text and an + // unconditioned one not at all, so only a name that differs is lost. + const name = readString(el, 'name'); + const derived = + conditionExpression === undefined + ? undefined + : conditionLabel(conditionExpression); + if (name !== undefined && name !== derived) { + warnings.push({ + elementId: id, + category: 'label', + message: + `The name ${JSON.stringify(name)} on the flow '${id}' was not ` + + 'imported: the script has no label for a flow, and the rebuilt ' + + (derived === undefined + ? 'document leaves this one unnamed.' + : `document names this one by its condition (${JSON.stringify(derived)}).`), + }); + } + return { id, sourceRef, @@ -5065,22 +6657,65 @@ function mapSequenceFlow( }; } +/** The one sentence `parseConditionExpression` fails the deployment with on a type it does not accept. */ +function conditionTypeRefusal( + xsiType: string, + id: string, +): UnsupportedConditionExpressionError { + return new UnsupportedConditionExpressionError( + id, + `it is typed xsi:type="${xsiType}", which ` + + 'BpmnParse.parseConditionExpression fails the deployment on ' + + '("Invalid type, only tFormalExpression is currently supported")', + ); +} + /** - * Operaton's `parseConditionExpression` reads `resource` only inside the - * `language != null` branch: with a `language` it builds a `ScriptCondition` - * that runs the deployed script (or the inline body, absent a resource) in - * that language, and this surface writes a UEL expression, never a script, so - * it refuses rather than importing a body the engine does not evaluate. - * Without a `language` the resource reaches nobody: the engine still builds a - * UEL condition from the body, so the attribute merely goes unread and warns. + * Operaton's `parseConditionExpression` fails the deployment on an `xsi:type` + * that does not resolve to `tFormalExpression` and accepts an absent one. + * moddle resolves a prefixed type the same way, so the element's `$type` is + * `bpmn:FormalExpression` exactly when the engine accepts a written prefixed + * type, and the raw attribute under `$attrs` says whether one was written at + * all; an unprefixed type it resolves against the document's default + * namespace, so in a prefixed document that element never reaches here and + * is read back by {@link recordDroppedConditions} instead. + * It reads `resource` only inside the `language != null` branch: with a + * `language` it builds a `ScriptCondition` that runs the deployed script (or + * the inline body, absent a resource) in that language, and this surface + * writes a UEL expression, never a script, so it refuses rather than importing + * a body the engine does not evaluate. Without a `language` the resource + * reaches nobody: the engine still builds a UEL condition from the body, so + * the attribute merely goes unread and warns. */ function checkConditionExpressionForm( expressionEl: ModdleElement, id: string, warnings: ImportWarning[], ): void { - const language = readString(expressionEl, 'language'); - const resource = readNamespacedAttr(expressionEl, 'resource'); + const xsiType = expressionEl.$attrs['xsi:type']; + if (xsiType !== undefined && expressionEl.$type !== 'bpmn:FormalExpression') { + throw conditionTypeRefusal(xsiType, id); + } + refuseOrWarnScriptedCondition( + readString(expressionEl, 'language'), + readNamespacedAttr(expressionEl, 'resource'), + id, + warnings, + ); +} + +/** + * The refusal or warning `BpmnParse.parseConditionExpression` earns from a + * condition's `language`/`resource`, shared by the moddle-parsed element and + * a {@link DroppedCondition} read back off the source text: the engine reads + * both attributes the same way regardless of which path found the element. + */ +function refuseOrWarnScriptedCondition( + language: string | undefined, + resource: string | undefined, + id: string, + warnings: ImportWarning[], +): void { if (language !== undefined) { throw new UnsupportedConditionExpressionError( id, @@ -5157,6 +6792,19 @@ function readString(el: ModdleElement, name: string): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined; } +/** + * A string-valued moddle property kept as written, `""` included, for the + * five texts the engine stores verbatim and the export writes back as such: a + * flow node's label, an assignee, a form field's label and its default, and a + * listener's binding attribute (`class`, `expression`, `delegateExpression`). + * Every other reader folds an empty attribute into absent through + * {@link readString}. + */ +function readText(el: ModdleElement, name: string): string | undefined { + const value = el.get(name); + return typeof value === 'string' ? value : undefined; +} + /** * Read a `name`, dropping it when it equals `derived`: that is the label the * export direction derives, so neither the IR nor any DSL printed from it @@ -5169,7 +6817,7 @@ function readDerivableName( id: string, derived: string = humanize(id), ): string | undefined { - const name = readString(el, 'name'); + const name = readText(el, 'name'); return name === undefined || name === derived ? undefined : name; } @@ -5244,24 +6892,16 @@ function documentationChildren(el: ModdleElement): ModdleElement[] { } /** - * Read an extension attribute under either prefix, `operaton:` winning when - * both are set. Both lookups go through moddle's `get`, which falls back to the - * raw `$attrs` map for an undeclared property. That is how `camunda:*` is read - * without registering the conflicting `camunda-bpmn-moddle` extension. + * Read an extension attribute. `get` falls back to the raw `$attrs` map for an + * undeclared property, which is how a document's camunda: spelling reads here + * too: the namespace swap in {@link xmlToIr} makes it arrive under this prefix. */ function readNamespacedAttr( el: ModdleElement, localName: string, ): string | undefined { - const operaton = el.get(`operaton:${localName}`); - if (typeof operaton === 'string' && operaton.length > 0) { - return operaton; - } - const camunda = el.get(`camunda:${localName}`); - if (typeof camunda === 'string' && camunda.length > 0) { - return camunda; - } - return undefined; + const value = el.get(`operaton:${localName}`); + return typeof value === 'string' && value.length > 0 ? value : undefined; } function extensionValues(el: ModdleElement): ModdleElement[] { @@ -5277,14 +6917,18 @@ function readRunSettings( ownerId: string, warnings: ImportWarning[], ): Omit[0], 'jobPriority'> { - const retryCycleEl = firstExtensionElement( + // The reader runs on an async step, a timer-driven event and a typed + // throw alone (`DefaultFailedJobParseListener.parseActivity` and its event + // arms); the refusal names that rather than deriving it per element kind. + const retryCycleEl = onlyExtensionElement( el, 'operaton:FailedJobRetryTimeCycle', ownerId, - warnings, + 'DefaultFailedJobParseListener.setFailedJobRetryTimeCycleValue', + ' on an async step, a timer-driven event or a typed throw', ); return { - asyncBefore: readNamespacedFlag(el, 'asyncBefore'), + asyncBefore: readAsyncBefore(el, ownerId, warnings), asyncAfter: readNamespacedFlag(el, 'asyncAfter'), exclusive: readNamespacedFlag(el, 'exclusive'), retryCycle: @@ -5292,24 +6936,113 @@ function readRunSettings( }; } +/** + * `BpmnParse.isAsyncBefore` reads `operaton:async="true"` as `asyncBefore` + * on every element it parses a continuation for, the repetition element + * included. The descriptor declares the current spelling alone, so the older + * one is read raw and its respelling named. + */ +function readAsyncBefore( + el: ModdleElement, + ownerId: string, + warnings: ImportWarning[], +): boolean | undefined { + const declared = readNamespacedFlag(el, 'asyncBefore'); + if (readNamespacedAttr(el, 'async') !== 'true') return declared; + const subject = + el.$type === MULTI_INSTANCE + ? `the repetition of '${ownerId}'` + : `'${ownerId}'`; + warnings.push({ + elementId: ownerId, + category: 'unmappedConstruct', + message: + `The operaton:async="true" on ${subject} imports as asyncBefore: true: ` + + 'BpmnParse.isAsyncBefore reads the two spellings as one, and this ' + + 'tool writes it back as operaton:asyncBefore, which the engine reads ' + + 'the same.', + }); + return true; +} + +/** + * `BpmnParse.parsePriority` parses a constant with `Integer.parseInt` and + * fails the deployment on any other, so the value refuses here rather than + * reaching a script the validator refuses at the same setting. + */ +function requireIntegerOrExpression( + value: string | undefined, + ownerId: string, + attr: string, +): string | undefined { + if ( + value === undefined || + FORM_BOUND_TEXT.test(value) || + EXPRESSION_OPEN.test(value) + ) { + return value; + } + throw new UnsupportedExtensionFormError( + ownerId, + `operaton:${attr}="${value}", which BpmnParse.parsePriority refuses to ` + + `deploy ("Value '${value}' for attribute '${attr}' is not a valid number")`, + ); +} + function readJobSettings( el: ModdleElement, ownerId: string, warnings: ImportWarning[], ): JobSettings { - const jobPriority = readNamespacedAttr(el, 'jobPriority'); - noteRewrappedExpression( - jobPriority, - ownerId, - "'jobPriority' setting", - warnings, + const run = readRunSettings(el, ownerId, warnings); + const timer = eventDefinitionsOf(el).find( + (def) => def.$type === 'bpmn:TimerEventDefinition', ); return jobSettings({ - ...readRunSettings(el, ownerId, warnings), - jobPriority, + ...run, + ...(timer === undefined + ? {} + : { + exclusive: timerJobExclusive(timer, run.exclusive, ownerId, warnings), + }), + jobPriority: requireIntegerOrExpression( + readNamespacedAttr(el, 'jobPriority'), + ownerId, + 'jobPriority', + ), }); } +/** + * `BpmnParse.parseTimer` locks the timer job by `operaton:exclusive` on the + * definition; the same attribute on the event tag reaches only the async + * continuation job (`parseAsynchronousContinuation`). The surface spells one + * value for both, so the definition's wins where the two are written apart, + * and the tag's stands in where the definition carries none. + */ +function timerJobExclusive( + defEl: ModdleElement, + tagValue: boolean | undefined, + ownerId: string, + warnings: ImportWarning[], +): boolean | undefined { + const value = readNamespacedFlag(defEl, 'exclusive'); + if (value === undefined) return tagValue; + if (tagValue !== undefined && tagValue !== value) { + warnings.push({ + elementId: ownerId, + category: 'extensionAttribute', + message: + `'${ownerId}' writes operaton:exclusive="${tagValue}" on the event, ` + + 'which governs its async continuation job, and ' + + `operaton:exclusive="${value}" on its timer definition, which ` + + 'governs the timer job; this tool keeps one value for both and ' + + "took the timer definition's.", + }); + } + return value; +} + function readEngineAttributes( el: ModdleElement, ownerId: string, @@ -5324,23 +7057,17 @@ function readEngineAttributes( } /** - * Read a boolean extension attribute under either prefix, `operaton:` winning. - * The `operaton:` spelling is declared and carries a schema default, so `get` - * answers with that default for an attribute the document never wrote; only an - * own property is an authored value. `camunda:` is undeclared and arrives raw. + * Read a boolean extension attribute. The `operaton:` spelling carries a + * schema default, so `get` answers with that default for an attribute the + * document never wrote; only an own property is an authored value. */ function readNamespacedFlag( el: ModdleElement, localName: string, ): boolean | undefined { - if (Object.prototype.hasOwnProperty.call(el, localName)) { - const operaton = el.get(`operaton:${localName}`); - if (typeof operaton === 'boolean') return operaton; - } - const camunda = el.get(`camunda:${localName}`); - if (camunda === 'true') return true; - if (camunda === 'false') return false; - return undefined; + if (!Object.prototype.hasOwnProperty.call(el, localName)) return undefined; + const value = el.get(`operaton:${localName}`); + return typeof value === 'boolean' ? value : undefined; } function readFormFields( @@ -5348,11 +7075,11 @@ function readFormFields( ownerId: string, warnings: ImportWarning[], ): FormField[] | undefined { - const formData = firstExtensionElement( + const formData = onlyExtensionElement( el, 'operaton:FormData', ownerId, - warnings, + 'DefaultFormHandler.parseFormData', ); if (formData === undefined) { return undefined; @@ -5389,12 +7116,34 @@ function warnCarriedAsWritten( ); } +/** + * A form field id or an io parameter name is the variable the engine sets + * (`FormFieldHandler.handleSubmit`, `InputParameter.execute`, + * `OutputParameter.execute`), and the script writes it as a bare name, so one + * it cannot spell is refused: a minted name would change the variable. + */ +function refuseUnspellableVariable( + name: string, + what: string, + ownerId: string, +): void { + if (isWritableName(name)) return; + throw new UnsupportedExtensionFormError( + ownerId, + `${what} '${name}' names a variable the script cannot spell (a name is ` + + "letters, digits and '_', with '-' between them, and no keyword), and " + + 'the engine sets the variable under that name, so writing another ' + + 'would change what runs', + ); +} + function readFormField( field: ModdleElement, ownerId: string, warnings: ImportWarning[], ): FormField { const fieldId = requireId(field); + refuseUnspellableVariable(fieldId, 'operaton:formField', ownerId); const type = importFormFieldType(readString(field, 'type'), fieldId, ownerId); const ctx: FieldContext = { fieldId, @@ -5410,8 +7159,8 @@ function readFormField( }, }; - const label = readString(field, 'label'); - const defaultValue = readString(field, 'defaultValue'); + const label = readText(field, 'label'); + const defaultValue = readText(field, 'defaultValue'); let datePattern = readString(field, 'datePattern'); if (datePattern !== undefined && type !== 'date') { ctx.report( @@ -5429,20 +7178,18 @@ function readFormField( : readPropertyEntries(propertiesEl, 'id', ctx.subject, ctx.report); // FormFieldHandler.createFormField converts the evaluated default through - // the type on every render, and EnumFormType.validateValue refuses an id - // outside the map; a literal is decided here, an expression at run time. - if ( - type === 'enum' && - defaultValue !== undefined && - !EXPRESSION_BODY.test(defaultValue) && - !values.some((value) => value.id === defaultValue) - ) { - warnCarriedAsWritten( - ctx, - `default '${defaultValue}'`, - "it names none of the field's values, and EnumFormType.validateValue " + - 'refuses it on every render of the form', + // the type on every render; a literal is decided here, an expression at + // run time. + if (defaultValue !== undefined && !EXPRESSION_BODY.test(defaultValue)) { + const reason = literalDefaultReason( + defaultValue, + type, + datePattern, + values, ); + if (reason !== undefined) { + warnCarriedAsWritten(ctx, `default '${defaultValue}'`, reason); + } } return { @@ -5457,6 +7204,58 @@ function readFormField( }; } +/** The ISO shape a date default takes when it was written for a pattern the field does not name. */ +const ISO_DATE_ONLY_TEXT = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Why a literal default fails the conversion its type runs on every render, + * or `undefined` when it converts. `LongFormType.convertValue` parses with + * `Long.valueOf`; `BooleanFormType.convertValue` with `Boolean.valueOf`, + * case-insensitively; `DateFormType` parses under the + * field's pattern, or under "dd/MM/yyyy" from + * `ProcessEngineConfigurationImpl.initFormTypes` when none is written; and + * `EnumFormType.validateValue` refuses an id outside the map. + */ +function literalDefaultReason( + value: string, + type: FormFieldType, + datePattern: string | undefined, + values: FormFieldValue[], +): string | undefined { + switch (type) { + case 'number': + return FORM_BOUND_TEXT.test(value) + ? undefined + : 'LongFormType.convertValue parses it with Long.valueOf on every ' + + 'render of the form, and it is not an integer'; + case 'boolean': { + const lower = value.toLowerCase(); + return lower === 'true' || lower === 'false' + ? undefined + : 'BooleanFormType.convertValue reads it through Boolean.valueOf on ' + + 'every render of the form'; + } + case 'date': + return datePattern === undefined && ISO_DATE_ONLY_TEXT.test(value) + ? 'DateFormType parses it on every render of the form under the ' + + 'engine\'s own "dd/MM/yyyy" (ProcessEngineConfigurationImpl.' + + 'initFormTypes), since the field names no pattern, and an ISO ' + + 'date does not fit it' + : undefined; + case 'enum': + return values.some((entry) => entry.id === value) + ? undefined + : "it names none of the field's values, and EnumFormType.validateValue " + + 'refuses it on every render of the form'; + case 'string': + return undefined; + default: { + const exhaustive: never = type; + throw new Error(`unhandled form field type ${String(exhaustive)}`); + } + } +} + /** * `FormTypes.parseFormPropertyType` reads the `operaton:value` children on an * enum field alone, into a `LinkedHashMap`: a repeated id keeps its first @@ -5541,7 +7340,10 @@ function readConstraints( throw refuse('no validator is registered under that name'); } if (read.some((constraint) => constraint.name === name)) { - throw refuse('the script holds each constraint once per field'); + throw refuse( + 'DefaultFormHandler.parseValidation deploys both, and this script ' + + 'holds each constraint once per field', + ); } const flag = FLAG_VALIDATORS[name]; if (flag !== undefined) { @@ -5657,25 +7459,30 @@ function importFormFieldType( } /** - * The first `` child of `type`, reporting each further one as - * a drop. Operaton reads one per element, and the consumption table answers per - * `(owner, $type)`, so it would mark every occurrence read. + * The one `` child of `type`. `Element.elementNS` throws + * on a second child of one tag and `BpmnParse.execute` lets that bubble, so + * the document fails to deploy wherever `reader`, the engine method that + * asks for the child, reaches it; the callers mirror those readers. `when` + * names the shape the reader runs on where a caller cannot cheaply gate the + * refusal on it. */ -function firstExtensionElement( +function onlyExtensionElement( el: ModdleElement, type: string, ownerId: string, - warnings: ImportWarning[], + reader: string, + when = '', ): ModdleElement | undefined { const matches = extensionValues(el).filter((value) => value.$type === type); - for (let i = 1; i < matches.length; i += 1) { - warnings.push({ - elementId: ownerId, - category: 'extensionAttribute', - message: - `Extra configuration (${type} #${i + 1}) on '${ownerId}' was not ` + - 'imported; only the first one on an element is read.', - }); + if (matches.length > 1) { + const tag = xmlTagOf(type); + throw new UnsupportedExtensionFormError( + ownerId, + `${matches.length} <${tag}> blocks, which Element.elementNS throws on ` + + `when ${reader} reads them${when} ("Parsing exception: multiple ` + + `elements with tag name '${tag.slice(tag.indexOf(':') + 1)}' ` + + 'found"), and BpmnParse.execute lets that fail the deployment', + ); } return matches[0]; } @@ -5686,17 +7493,17 @@ function readIoMapping( ownerId: string, warnings: ImportWarning[], ): IoMapped { - const io = firstExtensionElement( + const io = onlyExtensionElement( el, 'operaton:InputOutput', ownerId, - warnings, + 'BpmnParseUtil.parseInputOutput', ); if (io === undefined) return {}; return ioMapped( - readIoParameters(io, 'input', ownerId), - readIoParameters(io, 'output', ownerId), + readIoParameters(io, 'input', ownerId, warnings), + readIoParameters(io, 'output', ownerId, warnings), ); } @@ -5704,6 +7511,7 @@ function readIoParameters( io: ModdleElement, direction: 'input' | 'output', ownerId: string, + warnings: ImportWarning[], ): IoParameter[] { const tag = `operaton:${direction}Parameter`; const params = @@ -5716,17 +7524,23 @@ function readIoParameters( `an ${tag} has no name, so there is nothing to bind its value to`, ); } + refuseUnspellableVariable(name, tag, ownerId); return { name, - value: readParameterValue(param, ownerId, `${tag} '${name}'`), + value: readParameterValue(param, ownerId, `${tag} '${name}'`, warnings), }; }); + const executeMethod = + direction === 'input' + ? 'executeInputParameters' + : 'executeOutputParameters'; refuseRepeatedExtensionKey( read.map((param) => param.name), ownerId, (name) => - `two ${tag} children share name="${name}", and one element binds each ` + - 'parameter name once per direction', + `two ${tag} children share name="${name}"; Operaton runs both, the ` + + `last write winning (IoMapping.${executeMethod}), and this ` + + 'script binds each parameter name once per direction', ); return read; } @@ -5736,24 +7550,20 @@ function readIoParameters( * or `operaton:entry`: verbatim body text, or exactly one nested value. The * moddle descriptor declares the nested value as a repeating property so that * two of them stay visible here; a single-valued one would keep the last and - * hide the loss. + * hide the loss. `BpmnParseUtil.parseNestedParamValueProvider` reads the one + * child element and never the body text beside it, so that combination is + * carried as the child with a warning rather than refused. */ function readParameterValue( holder: ModdleElement, ownerId: string, where: string, + warnings: ImportWarning[], ): IoValue { const text = readString(holder, 'value'); const nested = (holder.get('definitions') as ModdleElement[] | undefined) ?? []; - if (text !== undefined && nested.length > 0) { - throw new UnsupportedExtensionFormError( - ownerId, - `${where} carries both body text and a nested <${nested[0].$type}> ` + - 'value, and a value is one or the other', - ); - } if (nested.length > 1) { throw new UnsupportedExtensionFormError( ownerId, @@ -5762,7 +7572,19 @@ function readParameterValue( ); } if (nested.length === 1) { - return readNestedValue(nested[0], ownerId, where, 'value'); + if (text !== undefined) { + warnings.push({ + elementId: ownerId, + category: 'extensionAttribute', + message: + `${where} carries both body text and a nested ` + + `<${nested[0].$type}> value: BpmnParseUtil` + + '.parseNestedParamValueProvider reads the nested value and never ' + + 'the text, and the document written back carries the nested ' + + 'value alone.', + }); + } + return readNestedValue(nested[0], ownerId, where, 'value', warnings); } return { kind: 'text', text: text ?? '' }; } @@ -5779,13 +7601,14 @@ function readNestedValue( ownerId: string, where: string, position: 'value' | 'item', + warnings: ImportWarning[], ): IoValue { switch (def.$type) { case 'operaton:List': return { kind: 'list', items: ((def.get('items') as ModdleElement[] | undefined) ?? []).map( - (item) => readNestedValue(item, ownerId, where, 'item'), + (item) => readNestedValue(item, ownerId, where, 'item', warnings), ), }; case 'operaton:Map': @@ -5793,10 +7616,15 @@ function readNestedValue( kind: 'map', entries: ( (def.get('entries') as ModdleElement[] | undefined) ?? [] - ).map((entry) => readMapEntry(entry, ownerId, where)), + ).map((entry) => readMapEntry(entry, ownerId, where, warnings)), }; case 'operaton:Script': - return readScriptValue(def, ownerId, `the operaton:script in ${where}`); + return readScriptValue( + def, + ownerId, + `the operaton:script in ${where}`, + warnings, + ); case 'operaton:Value': if (position === 'item') { return { kind: 'text', text: readString(def, 'value') ?? '' }; @@ -5819,6 +7647,7 @@ function readMapEntry( entry: ModdleElement, ownerId: string, where: string, + warnings: ImportWarning[], ): { key: string; value: IoValue } { const key = readString(entry, 'key'); if (key === undefined) { @@ -5834,6 +7663,7 @@ function readMapEntry( entry, ownerId, `operaton:entry '${key}' in ${where}`, + warnings, ), }; } @@ -5842,12 +7672,15 @@ function readMapEntry( * The rule broken by naming a deployment resource where only an inline body * can be written, shared between an `operaton:script` value's own `resource` * and a `bpmn:scriptTask`'s `operaton:resource`: the same rule under two - * spellings of the attribute, stated once. + * spellings of the attribute, stated once. `ScriptUtil.getScript` prefers a + * resource over an inline body whenever both are given, so the deployed + * script runs whether or not this surface can carry the body too. */ function externalResourceDetail(where: string, resource: string): string { return ( - `${where} names an external resource ("${resource}"); only an inline ` + - 'script body can be written here' + `${where} names an external resource ("${resource}"); ` + + 'ScriptUtil.getScript runs the resource in place of the body, and only ' + + 'an inline body can be written here' ); } @@ -5855,6 +7688,7 @@ function readScriptValue( script: ModdleElement, ownerId: string, where: string, + warnings: ImportWarning[], ): ScriptValue { const resource = readString(script, 'resource'); if (resource !== undefined) { @@ -5871,7 +7705,42 @@ function readScriptValue( 'body in', ); } - return { kind: 'script', format, code: readString(script, 'value') ?? '' }; + const code = readString(script, 'value') ?? ''; + checkScriptBody(code, ownerId, where, warnings); + return { kind: 'script', format, code }; +} + +/** + * The grammar's fence terminal ends at the first three backticks after its + * tag, so a body holding three has no fence that can enclose it. An empty + * body (moddle reads a whitespace-only one as empty too) is carried: the + * engine deploys it, since `ScriptUtil.getScript` checks the source for null + * and not for emptiness, but the printed fence draws the validator's + * empty-body error. + */ +function checkScriptBody( + code: string, + ownerId: string, + where: string, + warnings: ImportWarning[], +): void { + if (code.includes('```')) { + throw new UnsupportedExtensionFormError( + ownerId, + `${where} contains three consecutive backticks, which no script fence ` + + 'this language has can enclose', + ); + } + if (code.trim() === '') { + warnings.push({ + elementId: ownerId, + category: 'extensionAttribute', + message: + `The body of ${where} is empty: ScriptUtil.getScript deploys it, ` + + 'since it checks the source for null and not for emptiness, and ' + + 'the printed script draws an empty-body error there.', + }); + } } interface ListenerSpec { @@ -5882,7 +7751,16 @@ interface ListenerSpec { warnings: ImportWarning[]; } -/** Emission order. Members are read in the order they are refused in. */ +/** + * Emission order, which is also import order: `CoreModelElement + * .addListenerToMap` and `TaskDefinition.addTaskListener` both append to a + * per-event list rather than replace, so several listeners on one event (a + * logging listener beside a metrics listener) are an ordinary shape and run + * in document order. A `timeout` listener is different: + * `TaskDefinition.addTimeoutTaskListener` keys it by its own id, so several + * `timeout` listeners each fire on their own timer rather than sharing an + * order. Members are read in the order they are refused in. + */ function readListeners( el: ModdleElement, spec: ListenerSpec, @@ -5893,19 +7771,11 @@ function readListeners( ); if (found.length === 0) return undefined; - const listeners = found.map((listener) => { + return found.map((listener) => { const event = readListenerEvent(listener, spec); const rest = extra(listener, event); return { event, binding: readListenerBinding(listener, spec), ...rest }; }); - refuseRepeatedExtensionKey( - listeners.map((listener) => listener.event), - spec.ownerId, - (event) => - `two ${spec.tag} children share event="${event}", and one element ` + - 'writes each listener event once', - ); - return listeners; } function readExecutionListeners( @@ -5942,7 +7812,7 @@ function readTaskListeners( warnings, }, (listener, event) => { - const timer = readListenerTimer(listener, ownerId, event); + const timer = readListenerTimer(listener, ownerId, event, warnings); return timer === undefined ? {} : { timer }; }, ); @@ -5985,63 +7855,158 @@ function readListenerBinding( } /** - * Resolve the single executable binding a listener names. Every alternative is - * read before any is acted on: naming two leaves the engine to pick, naming - * none never runs, so both refuse. + * `BpmnParse.parseTaskListener` checks none of `class`, `expression`, or + * `delegateExpression` for emptiness before deploying, so an empty one on a + * task listener never refuses. An empty `class` or `delegateExpression` fails + * once the listener's own event fires and the engine tries to run nothing; + * an empty `expression` evaluates to the empty text instead + * ({@link resolveListenerBinding}). + */ +function buildEmptyTaskListenerBindingWarning( + ownerId: string, + tag: string, + attr: 'class' | 'delegateExpression', +): ImportWarning { + return { + elementId: ownerId, + category: 'extensionAttribute', + message: + `An ${tag} on '${ownerId}' has ${attr}="": ` + + 'BpmnParse.parseTaskListener checks no listener attribute for ' + + 'emptiness, so the task deploys and the listener fails when its ' + + 'event fires, and the document written back carries the empty text.', + }; +} + +/** + * Resolve the single executable binding a listener runs. `BpmnParse + * .parseExecutionListener`/`parseTaskListener` read `class`, then + * `expression`, then `delegateExpression`, then an `operaton:script` child, + * and deploy whichever comes first; naming more than one loses nothing the + * engine would have run, so every loser is carried as a warning rather than + * refused. Naming none never runs, so that alone refuses. Each binding + * attribute is read through {@link readText} rather than {@link readString}, + * so an empty `class`, `expression`, or `delegateExpression` is seen and not + * folded into "absent". */ function resolveListenerBinding( listener: ModdleElement, spec: ListenerSpec, ): ListenerBinding { - const { ownerId, tag } = spec; - const className = readString(listener, 'class'); - const expression = readString(listener, 'expression'); - const delegate = readString(listener, 'delegateExpression'); + const { ownerId, tag, warnings } = spec; + const isExecutionListener = spec.type === 'operaton:ExecutionListener'; + const className = readText(listener, 'class'); + const expression = readText(listener, 'expression'); + const delegate = readText(listener, 'delegateExpression'); const script = getEl(listener, 'script'); - const present = [ - ...(className === undefined ? [] : ['class']), - ...(expression === undefined ? [] : ['expression']), - ...(delegate === undefined ? [] : ['delegateExpression']), - ...(script === undefined ? [] : ['an operaton:script child']), - ]; - if (present.length > 1) { + const present: ('class' | 'expression' | 'delegateExpression' | 'script')[] = + []; + if (className !== undefined) present.push('class'); + if (expression !== undefined) present.push('expression'); + if (delegate !== undefined) present.push('delegateExpression'); + if (script !== undefined) present.push('script'); + + if (present.length === 0) { throw new UnsupportedExtensionFormError( ownerId, - `an ${tag} carries ${present.length} bindings (${present.join(', ')}), ` + - 'and a listener names exactly one', + `an ${tag} carries no binding: one of class, expression, ` + + 'delegateExpression, or an operaton:script child is what it runs', ); } - - if (className !== undefined) return { kind: 'class', className }; - if (expression !== undefined) return { kind: 'expression', expression }; - if (delegate !== undefined) { - return { kind: 'delegateExpression', expression: delegate }; + const [winner, ...losers] = present; + // Named against the listener: on a step, "on 'Svc'" would read as the + // step's own binding. + for (const loser of losers) { + warnings.push( + buildShadowedImplementationWarning( + ownerId, + loser, + winner, + `an ${tag} on '${ownerId}'`, + ), + ); } - if (script !== undefined) { - return readScriptValue(script, ownerId, `the operaton:script in an ${tag}`); + + switch (winner) { + case 'class': + if (className === '') { + if (isExecutionListener) { + throw new UnsupportedExtensionFormError( + ownerId, + `an ${tag} has class="", which BpmnParse.parseExecutionListener ` + + `refuses ("Attribute 'class' cannot be empty")`, + ); + } + warnings.push( + buildEmptyTaskListenerBindingWarning(ownerId, tag, 'class'), + ); + } + return { kind: 'class', className: className! }; + case 'expression': + // `ExpressionExecutionListener.notify` and `ExpressionTaskListener + // .notify` both call `expression.getValue`, and JUEL reads `""` as the + // empty string, so neither listener kind fails on it. + if (expression === '') { + warnings.push({ + elementId: ownerId, + category: 'extensionAttribute', + message: + `An ${tag} on '${ownerId}' has expression="": ` + + `${isExecutionListener ? 'ExpressionExecutionListener' : 'ExpressionTaskListener'} ` + + 'evaluates the empty text rather than refusing it, and the ' + + 'document written back carries it.', + }); + } + return { kind: 'expression', expression: expression! }; + case 'delegateExpression': + if (delegate === '') { + if (isExecutionListener) { + throw new UnsupportedExtensionFormError( + ownerId, + `an ${tag} has delegateExpression="", which ` + + 'BpmnParse.parseExecutionListener refuses ("Attribute ' + + "'delegateExpression' cannot be empty\")", + ); + } + warnings.push( + buildEmptyTaskListenerBindingWarning( + ownerId, + tag, + 'delegateExpression', + ), + ); + } + return { kind: 'delegateExpression', expression: delegate! }; + case 'script': + return readScriptValue( + script!, + ownerId, + `the operaton:script in an ${tag}`, + warnings, + ); } - throw new UnsupportedExtensionFormError( - ownerId, - `an ${tag} carries no binding: one of class, expression, ` + - 'delegateExpression, or an operaton:script child is what it runs', - ); } function readListenerTimer( listener: ModdleElement, ownerId: string, event: (typeof TASK_LISTENER_EVENTS)[number], + warnings: ImportWarning[], ): Extract | undefined { const defs = eventDefinitionsOf(listener); if (event !== 'timeout') { if (defs.length > 0) { - throw new UnsupportedExtensionFormError( - ownerId, - `an operaton:taskListener with event="${event}" carries a ` + - `${defs[0].$type}, which only a timeout listener takes`, - ); + warnings.push({ + elementId: ownerId, + category: 'extensionAttribute', + message: + `The ${defs[0].$type} on an operaton:taskListener with ` + + `event="${event}" was not imported: BpmnParse.parseTaskListener ` + + 'reads no event definition off a listener that is not a timeout, ' + + 'and the document written back carries none.', + }); } return undefined; } @@ -6059,6 +8024,21 @@ function readListenerTimer( 'bpmn:timerEventDefinition children, and a timeout has one due time', ); } + // A timeout listener has no job settings on the surface, so the lock flag + // `parseTimer` reads off its definition has nowhere to land. + const exclusive = readNamespacedFlag(defs[0], 'exclusive'); + if (exclusive !== undefined) { + warnings.push({ + elementId: ownerId, + category: 'extensionAttribute', + message: + `The operaton:exclusive="${exclusive}" on the timer of an ` + + `operaton:taskListener with event="timeout" on '${ownerId}' was not ` + + 'imported: this tool has no setting for it there, though Operaton ' + + 'locks the timeout job by it (BpmnParse.parseTimeoutTaskListener ' + + 'through parseTimer), so the document written back runs without it.', + }); + } return { kind: 'timer', ...readTimerDefinition(defs[0], ownerId) }; } @@ -6139,12 +8119,15 @@ function withInjectedFields( return fields.length === 0 ? binding : { ...binding, fields }; } +/** The grammar's `RAW_TEMPLATE` terminal opens directly after the quote. */ +const RAW_TEMPLATE_OPEN = /^[$#]\{/; + /** * The three slots Operaton writes a field's value in, and whether it evaluates * that slot rather than injecting it verbatim. The IR holds one text for all - * three and picks the slot back off its leading `${`, the same reading - * `renderIoValue` does, so a slot whose text disagrees with it has no spelling - * here and is reported instead of coming back as the other one. + * three and picks the slot back off its leading `${` or `#{`, so a slot whose + * text disagrees with it has no spelling here and is reported instead of + * coming back as the other one. */ const FIELD_VALUE_SLOTS = [ { @@ -6192,9 +8175,7 @@ function readField( // body to `createExpression` untrimmed, and // `getStringValueFromAttributeOrElement` reads `childElement.getText()` // without trimming either, so the whitespace an indented body carries is - // part of the composite expression the engine evaluates. A body that does - // not open with `${` cannot round-trip through a slot this tool picks by - // that prefix, and is reported rather than quietly reshaped. + // part of the composite expression the engine evaluates. const written = FIELD_VALUE_SLOTS.map((slot) => ({ slot, value: readString(field, slot.property), @@ -6221,15 +8202,31 @@ function readField( ); } - if (chosen.value.startsWith('${') !== chosen.slot.evaluated) { + // The compiler writes a value opening with `${` or `#{`, after any + // whitespace, into the expression slot and any other into `stringValue`, + // and the script spells the first as a raw template, whose opener follows + // its quote directly; a quoted literal opening with an expression is + // refused. A value the round trip would move to the other slot, or one the + // script cannot spell, is reported rather than quietly reshaped. + const evaluatedBack = EXPRESSION_OPEN.test(chosen.value); + let writtenBack: string | undefined; + if (chosen.slot.evaluated && !evaluatedBack) { + writtenBack = + 'a stringValue attribute, and the engine would inject that text ' + + 'rather than evaluate it'; + } else if (chosen.slot.evaluated && !RAW_TEMPLATE_OPEN.test(chosen.value)) { + writtenBack = + 'a quoted literal the compiler refuses, since a raw template opens ' + + 'directly after its quote'; + } else if (!chosen.slot.evaluated && evaluatedBack) { + writtenBack = + 'an operaton:expression child, and the engine would evaluate it ' + + 'rather than inject the text'; + } + if (writtenBack !== undefined) { return drop( `${chosen.slot.subject} holding '${oneLine(chosen.value)}' would be ` + - 'written back as ' + - (chosen.slot.evaluated - ? 'a stringValue attribute, and the engine would inject that text ' + - 'rather than evaluate it' - : 'an operaton:expression child, and the engine would evaluate it ' + - 'rather than inject the text'), + `written back as ${writtenBack}`, ); } diff --git a/packages/transform/test/ast-to-ir.test.ts b/packages/transform/test/ast-to-ir.test.ts index 45a1ec7..d00e538 100644 --- a/packages/transform/test/ast-to-ir.test.ts +++ b/packages/transform/test/ast-to-ir.test.ts @@ -378,6 +378,27 @@ describe('astToIr: while loop', () => { expect(outgoing).toHaveLength(2); expect(flow(result, loopId, 'Done')).toBeDefined(); }); + + // Revert: the back-edge added without `body.exitFlowId` -> the inner exit + // loses its reserved id, and `irToXml` refuses the default the inner gateway + // still declares. + it("a body ending in a loop carries that loop's reserved exit on the back-edge", async () => { + const outerLoop = makeGatewayLoopId('P_0'); + const innerLoop = makeGatewayLoopId('P_0_0'); + const result = await ir( + `process P { while (x) { while (b) { user A } } user C }`, + ); + expect(result.sequenceFlows).toEqual([ + edge(innerLoop, 'A', { condition: '${b}' }), + edge('A', innerLoop), + edge(outerLoop, innerLoop, { condition: '${x}' }), + edge(innerLoop, outerLoop, { id: makeDefaultFlowId(innerLoop) }), + edge(outerLoop, 'C', { id: makeDefaultFlowId(outerLoop) }), + edge('StartEvent_P', outerLoop), + edge('C', 'EndEvent_P'), + ]); + await expect(irToXml(result)).resolves.toBeDefined(); + }); }); describe('astToIr: do-while loop', () => { @@ -772,6 +793,16 @@ describe('astToIr: script task', () => { scriptTask('total', 'javascript', 'total = amount * 1.1;\n'), ); }); + + // `ScriptingEngines.getScriptEngineForLanguage` lowercases the language + // before it looks an engine up, so a mixed-case tag still lowers to the + // canonical spelling rather than being carried through as written. + it('a mixed-case fence tag lowers to its canonical scriptFormat', async () => { + const source = 'process P { script total ```JS\nx = 1;\n``` }'; + expect(only(await ir(source), 'scriptTask')).toEqual( + scriptTask('total', 'javascript', 'x = 1;\n'), + ); + }); }); describe('astToIr: task-kind lowering (step/send/receive/decide)', () => { @@ -1226,7 +1257,8 @@ describe('astToIr: call activity lowering', () => { label: "Fulfil order", process: "fulfilment-process", binding: deployment, - businessKey: "\${execution.processBusinessKey}" + businessKey: "\${execution.processBusinessKey}", + mapperDelegate: callMapperBean ) { in * in orderId @@ -1245,6 +1277,7 @@ describe('astToIr: call activity lowering', () => { calledElement: 'fulfilment-process', binding: { kind: 'deployment' }, businessKey: '${execution.processBusinessKey}', + mapper: { kind: 'delegateExpression', expression: '${callMapperBean}' }, inMappings: [ { kind: 'all' }, { kind: 'variable', source: 'orderId', target: 'orderId' }, @@ -1279,6 +1312,22 @@ describe('astToIr: call activity lowering', () => { ]); }); + it('lowers a literal mapping source to its bare text, not wrapped in ${...}', async () => { + const result = await ir( + `process p { call X(process: "p") { in y = "u" } }`, + ); + expect(only(result, 'callActivity').inMappings).toEqual([ + { kind: 'expression', sourceExpression: 'u', target: 'y' }, + ]); + }); + + it('lowers a literal businessKey to its bare text, not wrapped in ${...}', async () => { + const result = await ir( + `process p { call F(process: "fulfilment", businessKey: "abc") }`, + ); + expect(only(result, 'callActivity').businessKey).toBe('abc'); + }); + /** The binding a `call X` carrying the given extra member lowers to. */ const callBinding = async (member: string) => only( @@ -1451,6 +1500,7 @@ describe('astToIr: on-handler triggers', () => { ['on error', errorDef()], ['on error("")', errorDef('')], ['on error(X, coed: c)', errorDef('X')], + ['on escalation', { kind: 'escalation' }], ['on escalation(LS, code: v)', escalationDef('LS', 'v')], [ 'on error(PF, code: c, message: m)', @@ -2262,6 +2312,51 @@ describe('astToIr: engine attributes on `on` handlers, placed by host slot', () expect(start.jobPriority).toBeUndefined(); }); + // The timer job is the start event's: `BpmnParse.parseTimer` takes its + // priority off that activity and `DefaultFailedJobParseListener.parseStartEvent` + // its retry cycle off that element, and neither looks at the enclosing + // sub-process. Revert: hand the timer keys to the sub-process and the start + // in both rows loses them. + it.each([ + [ + 'a timer handler puts the three timer-job keys on its start and nothing on the sub-process', + '', + {}, + ], + [ + 'the async flags stay on the sub-process, whose continuation job they create', + 'asyncBefore: true, asyncAfter: true, ', + { asyncBefore: true, asyncAfter: true }, + ], + ])('%s', async (_title, asyncFlags, onSubProcess) => { + const result = await afterA( + `on timer(every: "R/PT1H", ${asyncFlags}jobPriority: 5, exclusive: false, retryCycle: "R1/PT1M", alongside) { service R(class: "x.R") }`, + ); + const handlerId = makeEventSubProcessId('p_1'); + const startId = makeStartEventId(handlerId, new Set()); + const endId = makeEndEventId(handlerId, new Set()); + expect(subProcess(result, handlerId)).toEqual({ + kind: 'subProcess', + id: handlerId, + triggeredByEvent: true, + flowElements: [ + { + kind: 'startEvent', + id: startId, + eventDefinition: timerDef('cycle', 'R/PT1H'), + isInterrupting: false, + exclusive: false, + jobPriority: '5', + retryCycle: 'R1/PT1M', + }, + serviceTask('R', classBinding('x.R')), + { kind: 'endEvent', id: endId }, + ], + sequenceFlows: [edge(startId, 'R'), edge('R', endId)], + ...onSubProcess, + }); + }); + it('places a hosted handler engine attributes on the boundary event', async () => { const result = await afterA( 'on A: error(PF, asyncBefore: true, jobPriority: 50) { service R(class: "x.R") }', @@ -2271,25 +2366,6 @@ describe('astToIr: engine attributes on `on` handlers, placed by host slot', () expect(boundary.asyncBefore).toBe(true); expect(boundary.jobPriority).toBe('50'); }); - - // A host-less handler lowers to an event sub-process, which carries - // `operaton:inputOutput` the way any sub-process does. Reachable on import - // too: `mapEventSubProcess` reads the mapping, so dropping it here would - // break the round trip in silence. - it('carries a host-less handler io parameters onto the event sub-process node', async () => { - const result = await afterA( - 'on error(PF) { input reason = "boom" output code = "c" } { service R(class: "x.R") }', - 'error PF', - ); - const handler = subProcess(result, makeEventSubProcessId('p_1')); - expect(handler.inputParameters).toEqual([ - ioParam('reason', textValue('boom')), - ]); - expect(handler.outputParameters).toEqual([ioParam('code', textValue('c'))]); - - const start = only(handler, 'startEvent'); - expect(start).not.toHaveProperty('inputParameters'); - }); }); describe('astToIr: input/output parameter value forms', () => { @@ -2315,9 +2391,24 @@ describe('astToIr: input/output parameter value forms', () => { value: textValue('${amount}'), }, { - form: 'a dotted bareword as its plain path, stripping the ${...} wrapper', + form: 'a #{...} template body verbatim as text', + written: '"#{amount}"', + value: textValue('#{amount}'), + }, + { + form: 'a bareword variable wrapped in ${...}, evaluated rather than read as fixed text', + written: 'amount', + value: textValue('${amount}'), + }, + { + form: 'a dotted bareword wrapped in ${...} as an EL path, not stripped', written: 'com.example.X', - value: textValue('com.example.X'), + value: textValue('${com.example.X}'), + }, + { + form: 'an integer literal wrapped in ${...} so the engine reads it as a number, not fixed text', + written: '5', + value: textValue('${5}'), }, { form: 'a fenced script as its normalized format and inner code', @@ -2329,6 +2420,11 @@ describe('astToIr: input/output parameter value forms', () => { written: '["x", "y"]', value: listValue([textValue('x'), textValue('y')]), }, + { + form: 'a list item by its own form: a bareword wrapped, a literal bare', + written: '[amount, "s"]', + value: listValue([textValue('${amount}'), textValue('s')]), + }, { form: 'an empty list literal as an empty item list', written: '[]', @@ -2522,7 +2618,7 @@ describe('astToIr: task listeners', () => { `process p { user T { on start(class: "x.S") on create(class: "x.C") - on assign(class: "x.A") + on assignment(class: "x.A") on end(class: "x.E") on complete(class: "x.K") on update(class: "x.U") @@ -2536,7 +2632,7 @@ describe('astToIr: task listeners', () => { ]); expect(task.taskListeners?.map((l) => l.event)).toEqual([ 'create', - 'assign', + 'assignment', 'complete', 'update', 'delete', @@ -2545,11 +2641,11 @@ describe('astToIr: task listeners', () => { it('reads every binding form the execution listeners read', async () => { const result = await ir( - 'process p { user T { on create(expression: "${bean.run()}") on assign ```groovy\nx = 1\n``` } }', + 'process p { user T { on create(expression: "${bean.run()}") on assignment ```groovy\nx = 1\n``` } }', ); expect(only(result, 'userTask').taskListeners).toEqual([ { event: 'create', binding: exprBinding('${bean.run()}') }, - { event: 'assign', binding: scriptValue('groovy', 'x = 1\n') }, + { event: 'assignment', binding: scriptValue('groovy', 'x = 1\n') }, ]); }); @@ -2677,7 +2773,7 @@ describe('astToIr: field injection and form references', () => { ); const unbound = await ir( 'process p { service Run(expression: "${bean.run()}") { field greeting = "hello" }' + - ' user Review(formRef: "review-form") { on assign ```groovy\nx = 1\n``` { field role = "clerk" } } }', + ' user Review(formRef: "review-form") { on assignment ```groovy\nx = 1\n``` { field role = "clerk" } } }', ); const user = only(unbound, 'userTask'); @@ -3033,32 +3129,6 @@ describe('astToIr: conditioned parallel branches', () => { expect(result.flowElements.some((fe) => fe.id === joinId)).toBe(false); }); - it('a condition on one branch turns both gateways inclusive and adds the default flow to the join', async () => { - const result = await ir( - `process P { parallel { if (a > 1) { user A } { user B } } }`, - ); - - const gateways = result.flowElements.filter( - (fe): fe is InclusiveGateway => fe.kind === 'inclusiveGateway', - ); - expect(gateways.map((g) => g.id)).toEqual([forkId, joinId]); - expect(gateways[0]!.defaultFlowId).toBe(defaultFlowId); - expect('defaultFlowId' in gateways[1]!).toBe(false); - expect( - result.flowElements.some((fe) => fe.kind === 'parallelGateway'), - ).toBe(false); - - expect(result.sequenceFlows).toEqual([ - edge(forkId, 'A', { condition: '${a > 1}' }), - edge('A', joinId), - edge(forkId, 'B'), - edge('B', joinId), - edge(forkId, joinId, { id: defaultFlowId }), - edge('StartEvent_P', forkId), - edge(joinId, 'EndEvent_P'), - ]); - }); - it('the first else branch takes the reserved default flow id and a second else is a plain branch', async () => { const result = await ir( `process P { parallel { if (a > 1) { user A } else { user B } else { user C } } }`, @@ -3078,7 +3148,7 @@ describe('astToIr: conditioned parallel branches', () => { ).toEqual([]); }); - it('an empty conditioned branch routes its condition straight to the join, and the join survives only while something can still reach it', async () => { + it('an empty conditioned branch routes its condition straight to the join', async () => { const result = await ir( `process P { parallel { if (a > 1) { } { user B } } }`, ); @@ -3086,24 +3156,91 @@ describe('astToIr: conditioned parallel branches', () => { result.sequenceFlows.filter( (f) => f.sourceRef === forkId && f.targetRef === joinId, ), - ).toEqual([ - edge(forkId, joinId, { condition: '${a > 1}' }), - edge(forkId, joinId, { id: defaultFlowId }), - ]); - - // Every branch ends, so nothing walks out of the fork: written with an - // otherwise, the fork needs no fallback flow and the join is pruned; - // written without one, the invented fallback keeps the join alive. The - // validator's termination guard reads the same rule the other way round. - const otherwise = await ir( - `process P { parallel { if (a > 1) { end X } else { end Y } } }`, - ); - expect(otherwise.flowElements.some((fe) => fe.id === joinId)).toBe(false); - - const noOtherwise = await ir( - `process P { parallel { if (a > 1) { end X } { end Y } } }`, - ); - expect(noOtherwise.flowElements.some((fe) => fe.id === joinId)).toBe(true); + ).toEqual([edge(forkId, joinId, { condition: '${a > 1}' })]); + }); + + // A condition on one branch makes both gateways inclusive. Every element + // and flow of the process, so a row pins where the fallback runs and that + // none is written where an unconditioned sibling would leave it dead. + // Revert: the reservation back to any inclusive fork -> the unconditioned + // rows find a fallback. + const inclusive = (id: string, flowId?: string): InclusiveGateway => ({ + kind: 'inclusiveGateway', + id, + ...(flowId === undefined ? {} : { defaultFlowId: flowId }), + }); + const start: FlowElement = { kind: 'startEvent', id: 'StartEvent_P' }; + const end: FlowElement = { kind: 'endEvent', id: 'EndEvent_P' }; + const task = (id: string): FlowElement => ({ kind: 'userTask', id }); + const stop = (id: string): FlowElement => ({ kind: 'endEvent', id }); + it.each([ + [ + 'an unconditioned sibling always runs, so no fallback is reserved', + 'parallel { if (a > 1) { user A } { user B } }', + [start, inclusive(forkId), inclusive(joinId), task('A'), task('B'), end], + [ + edge(forkId, 'A', { condition: '${a > 1}' }), + edge('A', joinId), + edge(forkId, 'B'), + edge('B', joinId), + edge('StartEvent_P', forkId), + edge(joinId, 'EndEvent_P'), + ], + ], + [ + 'every branch conditioned reserves the fallback straight to the join', + 'parallel { if (a > 1) { user A } if (b) { user B } }', + [ + start, + inclusive(forkId, defaultFlowId), + inclusive(joinId), + task('A'), + task('B'), + end, + ], + [ + edge(forkId, 'A', { condition: '${a > 1}' }), + edge('A', joinId), + edge(forkId, 'B', { condition: '${b}' }), + edge('B', joinId), + edge(forkId, joinId, { id: defaultFlowId }), + edge('StartEvent_P', forkId), + edge(joinId, 'EndEvent_P'), + ], + ], + [ + 'with every branch ending, an unconditioned sibling leaves nothing to reach the join', + 'parallel { if (a > 1) { end X } { end Y } }', + [start, inclusive(forkId), stop('X'), stop('Y')], + [ + edge(forkId, 'X', { condition: '${a > 1}' }), + edge(forkId, 'Y'), + edge('StartEvent_P', forkId), + ], + ], + [ + 'with every branch ending and conditioned, the fallback alone keeps the join', + 'parallel { if (a > 1) { end X } if (b) { end Y } }', + [ + start, + inclusive(forkId, defaultFlowId), + inclusive(joinId), + stop('X'), + stop('Y'), + end, + ], + [ + edge(forkId, 'X', { condition: '${a > 1}' }), + edge(forkId, 'Y', { condition: '${b}' }), + edge(forkId, joinId, { id: defaultFlowId }), + edge('StartEvent_P', forkId), + edge(joinId, 'EndEvent_P'), + ], + ], + ])('%s', async (_title, statement, elements, flows) => { + const result = await ir(`process P { ${statement} }`); + expect(result.flowElements).toEqual(elements); + expect(result.sequenceFlows).toEqual(flows); }); }); diff --git a/packages/transform/test/cfg-analysis.test.ts b/packages/transform/test/cfg-analysis.test.ts index e8b4c59..d385c0c 100644 --- a/packages/transform/test/cfg-analysis.test.ts +++ b/packages/transform/test/cfg-analysis.test.ts @@ -457,4 +457,27 @@ describe('cfg analysis', () => { expect(cfg.postDominates('nope', 'm')).toBe(false); expect(cfg.postDominates('m', 'nope')).toBe(false); }); + + it('keeps totality for a node unreachable from the entry and for the two sentinels', () => { + // "orphan" has no path from the entry, so it is absent from the + // dominator tree, but it does reach the exit, so the post-dominator + // tree numbers it independently: the two interval trees answer these + // cases on their own terms rather than sharing one bounds check. + const cfg = analyzeCfg( + graph( + [start('start'), task('reachable'), task('orphan'), end('end')], + 'start>reachable reachable>end orphan>end', + ), + ); + + expect(cfg.dominates('orphan', 'orphan')).toBe(false); + expect(cfg.dominates(VIRTUAL_ENTRY, 'orphan')).toBe(false); + expect(cfg.dominates(VIRTUAL_EXIT, 'orphan')).toBe(false); + expect(cfg.dominates(VIRTUAL_ENTRY, 'end')).toBe(true); + expect(cfg.dominates('end', VIRTUAL_ENTRY)).toBe(false); + + expect(cfg.postDominates(VIRTUAL_EXIT, 'orphan')).toBe(true); + expect(cfg.postDominates('orphan', VIRTUAL_EXIT)).toBe(false); + expect(cfg.postDominates(VIRTUAL_EXIT, VIRTUAL_EXIT)).toBe(true); + }); }); diff --git a/packages/transform/test/helpers/bpmn-doc.ts b/packages/transform/test/helpers/bpmn-doc.ts index a28958e..ba611d9 100644 --- a/packages/transform/test/helpers/bpmn-doc.ts +++ b/packages/transform/test/helpers/bpmn-doc.ts @@ -52,7 +52,7 @@ export const dualDefs = wrap(HEAD_DUAL, DEFINITIONS_CLOSE); export const bpmnDoc = doc(HEAD_BPMN); export const operatonDoc = doc(HEAD_OPERATON); -/** Declares only the deprecated `camunda:` prefix, for the alias fixtures. */ +/** Declares the camunda namespace (every Camunda Modeler export), which `xmlToIr` reads as operaton:. */ export const camundaDoc = doc(HEAD_CAMUNDA); /** Declares both prefixes, for fixtures that write one against the other. */ export const dualDoc = doc(HEAD_DUAL); diff --git a/packages/transform/test/ir-to-dsl.test.ts b/packages/transform/test/ir-to-dsl.test.ts index ec3d1f8..fcc1b54 100644 --- a/packages/transform/test/ir-to-dsl.test.ts +++ b/packages/transform/test/ir-to-dsl.test.ts @@ -26,6 +26,7 @@ import { astToIr } from '../src/ast-to-ir.js'; import { xmlToIr } from '../src/xml-to-ir.js'; import { isGateway } from '../src/ir/types.js'; import { bpmnDoc } from './helpers/bpmn-doc.js'; +import { byId, only } from './helpers/ir-query.js'; import { around, boundaryEvent, @@ -153,15 +154,17 @@ async function reDesugar(dsl: string): Promise { */ const MODEL_REFUSAL = { reservedId: 'matches a reserved synthesized-id pattern', + mintedId: 'is the id the compiler generates for the implicit', cancelOutsideAttempt: "A cancel end belongs directly inside an 'attempt' block", undoOutsideBlock: 'An undo block belongs directly inside the', hostOutsideContainer: "Could not resolve reference to Statement named 'Elsewhere'", orphanStep: 'This step can never run', - duplicateTimeout: "Duplicate 'on timeout' listener", + secondDefaultStart: "this is the process's second plain or timer start", deadElse: 'could never run', undoAlongside: 'there is no running flow to run alongside', + formDefaultShape: 'The default ', } as const; /** @@ -185,6 +188,18 @@ async function printed( return dsl; } +/** + * The source without its `var` lines. The header declares every variable the + * model reads in a position the script writes bare, whether or not the print + * kept that position, so a dropped condition leaves its name there and the + * body is where its absence is asserted. + */ +const bodyOf = (source: string): string => + source + .split('\n') + .filter((line) => !line.startsWith(' var ')) + .join('\n'); + /** * Assert local idempotence up to id normalization: `irToDsl(ir)` re-parses and * re-desugars to an IR with the same normalized element + edge multisets as @@ -358,6 +373,7 @@ const INVOICE_IR: BpmnProcess = { /** The whole printed source for {@link IF_ELSE_IR}, asserted from two angles. */ const IF_ELSE_SOURCE = 'process p {\n' + + ' var amount: any\n' + ' start S\n' + ' user A(label: "A task")\n' + ' if (amount > 1000) {\n' + @@ -382,6 +398,7 @@ describe('irToDsl: structured restructuring', () => { 'restructures a desugared while to `while (...) { }`, with no process label where the IR has no name', WHILE_IR, 'process p {\n' + + ' var count: any\n' + ' start S\n' + ' while (count < 10) {\n' + ' user W(label: "Work")\n' + @@ -393,6 +410,7 @@ describe('irToDsl: structured restructuring', () => { 'restructures a desugared do-while to `do { } while (...)`', DO_WHILE_IR, 'process p {\n' + + ' var count: any\n' + ' start S\n' + ' do {\n' + ' user W(label: "Work")\n' + @@ -409,6 +427,7 @@ describe('irToDsl: structured restructuring', () => { 'restructures the canonical invoice import to if/else under a labeled process header', INVOICE_IR, 'process invoice-approval(label: "Invoice Approval") {\n' + + ' var amount: any\n' + ' start ReviewStart\n' + ' user ReviewInvoice(label: "Review invoice", assignee: "demo")\n' + ' if (amount > 1000) {\n' + @@ -787,7 +806,7 @@ describe('irToDsl: an external task prints its priority, properties and mappings 'service V(topic: "t", taskPriority: "${amount > 1000 ? 90 : 10}")', ], [ - 'a taskPriority opening with #{ prints inside "${...}", the one raw form the surface has', + 'a taskPriority opening with #{ prints as written', around( serviceTask('V', { kind: 'external', @@ -795,7 +814,7 @@ describe('irToDsl: an external task prints its priority, properties and mappings taskPriority: '#{x}', }), ), - 'service V(topic: "t", taskPriority: "${x}")', + 'service V(topic: "t", taskPriority: "#{x}")', ], [ 'properties then mappings print after the fields, a declared code by its name and an undeclared one under a synthesized header', @@ -1022,6 +1041,7 @@ describe('irToDsl: sub-process emission', () => { expect(await printed(SUB_WITH_IF)).toBe( 'process proc {\n' + + ' var ok: any\n' + ' start PStart\n' + ' subprocess sub {\n' + ' start SubStart\n' + @@ -1415,6 +1435,12 @@ describe('irToDsl: event layer (message / signal / timer / conditional)', () => ' on condition("${bean.check()}") {\n', undefined, ], + [ + 'a message name that is an expression as a raw template', + messageDef('${orderType}'), + ' on message("${orderType}") {\n', + undefined, + ], ] as const)( 'prints %s in the handler header', async (_title, def, header, interrupting) => { @@ -1532,7 +1558,7 @@ describe('irToDsl: triggered start events', () => { ], [{ id: 'F', sourceRef: 'StartEvent_p', targetRef: 'E' }], ); - expect(await printed(ir, 'reservedId')).toContain( + expect(await printed(ir, 'mintedId')).toContain( 'start StartEvent_p message("OrderReceived")', ); }); @@ -1587,7 +1613,7 @@ describe('irToDsl: ends spelling their own word', () => { [ 'a synthesized terminate end, rather than dropping it', typedEvent('endEvent', 'EndEvent_p', { kind: 'terminate' }), - ['reservedId'], + ['mintedId'], 'end EndEvent_p terminate', ], [ @@ -1602,7 +1628,7 @@ describe('irToDsl: ends spelling their own word', () => { [ 'a synthesized cancel end, rather than dropping it', typedEvent('endEvent', 'EndEvent_p', { kind: 'cancel' }), - ['reservedId', 'cancelOutsideAttempt'], + ['mintedId', 'cancelOutsideAttempt'], 'end EndEvent_p cancel', ], ] as const)('prints %s', async (_title, end, refused, expected) => { @@ -1623,7 +1649,7 @@ describe('irToDsl: ends spelling their own word', () => { ], [{ id: 'F', sourceRef: 'S', targetRef: 'EndEvent_1' }], ); - const dsl = await printed(ir, 'reservedId'); + const dsl = await printed(ir); expect(dsl).toContain('end EndEvent_1 terminate(label: "Abandon all")'); const ends = (await reDesugar(dsl)).flowElements.filter( @@ -1701,23 +1727,27 @@ describe('irToDsl: event layer (intermediate catch / await)', () => { // The whole source per row, so the missing id token is asserted too: a catch // has no name slot, and `Catch_1` appears nowhere. it.each([ - ['a message catch', messageDef('M'), ' await message("M")\n'], + ['a message catch', messageDef('M'), '', ' await message("M")\n'], [ 'a duration timer catch', timerDef('duration', 'PT1H'), + '', ' await timer("PT1H")\n', ], - ['a signal catch', signalDef('S'), ' await signal("S")\n'], + ['a signal catch', signalDef('S'), '', ' await signal("S")\n'], [ 'a conditional catch, bare DSL in the expression subset', conditionDef('${amount > 100}'), + ' var amount: any\n', ' await condition(amount > 100)\n', ], ] as const)( 'prints %s inline between the surrounding steps', - async (_title, def, statement) => { + async (_title, def, header, statement) => { expect(await printed(catchBody(def))).toBe( - 'process proc {\n start S\n user Before\n' + + 'process proc {\n' + + header + + ' start S\n user Before\n' + statement + ' user After\n end E\n}\n', ); @@ -1975,6 +2005,36 @@ describe('irToDsl: boundary events', () => { for (const text of has) expect(dsl).toContain(text); }); + // The compiler lays handlers down in statement order and derives an event + // sub-process's id from its statement index, so a print that reorders them + // renumbers the ids on the next compile. + it('prints boundary handlers and event sub-processes in the order the model holds them', async () => { + const ir = boundaryIr( + 'T', + [ + eventHandler('OnM', 'MStart', messageDef('m'), false), + boundaryEvent('Boundary_T_message', 'T', messageDef('n')), + { kind: 'userTask', id: 'J' }, + { kind: 'endEvent', id: 'Caught' }, + ], + [ + edge('Boundary_T_message', 'J', { id: 'F3' }), + edge('J', 'Caught', { id: 'F4' }), + ], + ); + expect((await printed(ir)).split('\n').slice(4, -2)).toEqual([ + ' on message("m", alongside) {', + ' start MStart', + ' user OnM_Work', + ' end OnM_End', + ' }', + ' on T: message("n") {', + ' user J', + ' end Caught', + ' }', + ]); + }); + it('degrades a rejoin into the main flow to a goto and prints the main-flow node once', async () => { const ir: BpmnProcess = minimalProcess( [ @@ -2129,30 +2189,6 @@ describe('irToDsl: boundary events', () => { expect(dsl.match(/^ *on A: /gm)).toHaveLength(1); }); - it('prints a boundary event before a host-less handler in the same container', async () => { - const ir = boundaryIr( - 'Review', - [ - triggeredSub('OnPF', [ - typedEvent('startEvent', 'PFStart', errorDef('PF')), - { kind: 'endEvent', id: 'PFEnd' }, - ]), - boundaryEvent( - 'Boundary_Review_timer', - 'Review', - timerDef('duration', 'PT2H'), - ), - { kind: 'endEvent', id: 'Late' }, - ], - [{ id: 'F3', sourceRef: 'Boundary_Review_timer', targetRef: 'Late' }], - ); - const dsl = await printed(ir); - expect(dsl.indexOf('on Review: timer')).toBeGreaterThan(-1); - expect(dsl.indexOf('on error(PF) {')).toBeGreaterThan( - dsl.indexOf('on Review: timer'), - ); - }); - it('prints a boundary event inside the sub-process container that holds its host', async () => { const ir: BpmnProcess = minimalProcess( [ @@ -2292,64 +2328,183 @@ describe('irToDsl: synthesized terminal omission', () => { expect(dsl).not.toContain('Throw_'); }); - // `StartEvent_1` is the id a modeler mints, so a labeled start drawn in one - // arrives under a synthesized-shaped id rather than an authored one. Printing - // it would write a name the validator rejects, so the statement is left out - // and the label it carried is reported at import instead. - it('reports the label a synthesized start and end take with them, and prints neither', async () => { + // Only the exact ids the compiler mints for a container are synthesized; + // `StartEvent_1` is the id a modelling tool mints, an authored name like + // any other, and it keeps its statement, its label and its initiator. + it("prints a modelling tool's default start and end under their own ids, label and initiator kept", async () => { const { ir, warnings } = await xmlToIr(bpmnDoc` - + `); - expect(warnings.map((w) => [w.category, w.elementId])).toEqual([ - ['label', 'StartEvent_1'], - ['label', 'EndEvent_1'], - ]); - expect(warnings[0]?.message).toContain('Order Received'); - expect(warnings[1]?.message).toContain('Order Filed'); + expect(warnings).toEqual([]); - const dsl = irToDsl(ir); - expect(dsl).not.toContain('StartEvent_1'); - expect(dsl).not.toContain('EndEvent_1'); - expect(dsl).not.toContain('Order Received'); - expect(dsl).not.toContain('Order Filed'); + const { source, warnings: printWarnings } = printDsl(ir); + expect(printWarnings).toEqual([]); + expect(source).toBe( + [ + 'process p {', + ' start StartEvent_1(label: "Order Received", initiator: "who")', + ' user Approve', + ' end EndEvent_1(label: "Order Filed")', + '}', + '', + ].join('\n'), + ); + expect((await validate(source)).diagnostics).toEqual([]); + expect((await reDesugar(source)).flowElements).toEqual(ir.flowElements); }); - // The trigger moves into the `on` header, so nothing else holds the start - // inside a handler body either. - it("reports the label an event handler's synthesized trigger start takes with it", async () => { - const { ir, warnings } = await xmlToIr(bpmnDoc` + // The trigger moves into the `on` header, so nothing else holds a start + // under the handler's minted id inside the body either; a start under any + // other id is authored and keeps its statement. + it.each([ + [ + "the handler's minted start id, so the label is reported and the start left out", + 'StartEvent_Handler', + [['label', 'StartEvent_Handler']], + ' on error {\n }\n', + ], + [ + "a modelling tool's start id, so the start prints with its label", + 'StartEvent_9', + [], + ' on error {\n start StartEvent_9(label: "Restock Heard")\n }\n', + ], + ] as const)( + "an event handler's trigger start carries %s", + async (_title, startId, expectedWarnings, handler) => { + const { ir, warnings } = await xmlToIr(bpmnDoc` - + `); - expect(warnings.map((w) => [w.category, w.elementId])).toEqual([ - ['label', 'StartEvent_9'], - ]); - expect(warnings[0]?.message).toContain('Restock Heard'); + expect(warnings.map((w) => [w.category, w.elementId])).toEqual( + expectedWarnings, + ); + expect(await printed(ir)).toContain(handler); + }, + ); +}); - const dsl = irToDsl(ir); - expect(dsl).toContain('on error {'); - expect(dsl).not.toContain('StartEvent_9'); - expect(dsl).not.toContain('Restock Heard'); +// An id the model may carry and the script cannot spell (`Task.1`, a keyword, +// a trailing hyphen) prints under a name minted from it, with one report per +// rename, at every site an id is written: the process head, every statement +// head, a `goto`, a boundary's host, and a throw's or catch's name. +describe('irToDsl: ids the script cannot spell print under a minted name', () => { + const ir = processIr( + 'WFP-6-', + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'userTask', id: 'Task.1' }, + { kind: 'userTask', id: 'user' }, + chainedSub('Sub.1', [ + { kind: 'startEvent', id: 'S2' }, + { kind: 'task', id: 'Step.2' }, + { kind: 'endEvent', id: 'E2' }, + ]), + typedEvent('intermediateThrowEvent', 'Notify.1', messageDef('M')), + { kind: 'userTask', id: 'Review-' }, + { kind: 'endEvent', id: 'E' }, + boundaryEvent('B', 'Task.1', timerDef('duration', 'PT1H')), + ], + [ + ...flowChain('S', 'Task.1', 'user', 'Sub.1', 'Notify.1', 'Review-', 'E'), + edge('B', 'Review-'), + ], + ); + + // Revert: print `el.id` at any one site and the source stops parsing. + it('prints every site under the minted name, reports each rename once, and the source compiles clean', async () => { + const { source, warnings } = printDsl(ir); + expect(source).toBe( + [ + 'process WFP_6_ {', + ' start S', + ' user Task_1', + ' user _user', + ' subprocess Sub_1 {', + ' start S2', + ' step Step_2', + ' end E2', + ' }', + ' emit message Notify_1("M")', + ' user Review_', + ' end E', + ' on Task_1: timer("PT1H") {', + ' goto Review_', + ' }', + '}', + '', + ].join('\n'), + ); + expectReports( + warnings, + ['renamedId', 'WFP-6-'], + ['renamedId', 'Task.1'], + ['renamedId', 'user'], + ['renamedId', 'Sub.1'], + ['renamedId', 'Step.2'], + ['renamedId', 'Notify.1'], + ['renamedId', 'Review-'], + ); + expect((await validate(source)).diagnostics).toEqual([]); + expect((await reDesugar(source)).id).toBe('WFP_6_'); + }); + + // The mint from `Task.1` lands on `Task_1`, which the document already + // spells for another element; resolving against every id, not just the + // names minted so far, is what pushes it to the next free suffix. + it('a minted name that another id already spells takes the next free suffix, and the source compiles clean', async () => { + const { source, warnings } = printDsl( + chained([ + { kind: 'startEvent', id: 'S' }, + { kind: 'userTask', id: 'Task.1' }, + { kind: 'userTask', id: 'Task_1' }, + { kind: 'endEvent', id: 'E' }, + ]), + ); + expect(source).toBe( + [ + 'process proc {', + ' start S', + ' user Task_1_2', + ' user Task_1', + ' end E', + '}', + '', + ].join('\n'), + ); + expectReports(warnings, ['renamedId', 'Task.1']); + expect((await validate(source)).diagnostics).toEqual([]); }); }); // Leaving a synthesized plain end out anywhere but its block's tail would wire // its predecessor into whatever follows on the page, so it prints under its -// reserved id and is reported. Rows 2 and 3 spell that id in their source -// because the compiler never mints one in those positions; `EndEvent_1` is the -// id a modelling tool mints, and the parser accepts it without validating. +// reserved id and is reported. The compiler never mints one in the positions +// rows 2 and 3 put it in, so those rows compile an authored end there and +// give it the minted id afterwards. describe("irToDsl: a synthesized plain end that is not its block's tail", () => { const start = (id: string): FlowElement => ({ kind: 'startEvent', id }); const step = (id: string): FlowElement => ({ kind: 'task', id }); const end = (id: string): FlowElement => ({ kind: 'endEvent', id }); + const renamed = (ir: BpmnProcess, from: string, to: string): BpmnProcess => ({ + ...ir, + flowElements: ir.flowElements.map((el) => + el.id === from ? { ...el, id: to } : el, + ), + sequenceFlows: ir.sequenceFlows.map((f) => ({ + ...f, + sourceRef: f.sourceRef === from ? to : f.sourceRef, + targetRef: f.targetRef === from ? to : f.targetRef, + })), + }); it.each([ [ @@ -2358,6 +2513,7 @@ describe("irToDsl: a synthesized plain end that is not its block's tail", () => [ 'process p {', ' error E', + ' var x: any', ' start S', ' step A', ' if (x) {', @@ -2370,79 +2526,86 @@ describe("irToDsl: a synthesized plain end that is not its block's tail", () => ], [ 'a plain end whose chain a later goto-reached step follows prints before that step, and its label rides along', - 'process p { start S step A if (x) { goto B } step C end EndEvent_2(label: "Order filed") step B }', + 'process p { start S step A if (x) { goto B } step C end Done(label: "Order filed") step B end Fin }', [ 'process p {', + ' var x: any', ' start S', ' step A', ' if (x) {', ' goto B', ' }', ' step C', - ' end EndEvent_2(label: "Order filed")', + ' end EndEvent_p(label: "Order filed")', ' step B', + ' end Fin', '}', ], - [['refusedStatement', 'EndEvent_2']], + [['refusedStatement', 'EndEvent_p']], ], [ 'a plain end inside a branch prints at once, so the branch does not fall through', - 'process p { start S step A if (x) { end EndEvent_1 } step X }', + 'process p { start S step A if (x) { end Done } step X end Fin }', [ 'process p {', + ' var x: any', ' start S', ' step A', ' if (x) {', - ' end EndEvent_1', + ' end EndEvent_p', ' }', ' step X', + ' end Fin', '}', ], - [['refusedStatement', 'EndEvent_1']], + [['refusedStatement', 'EndEvent_p']], ], [ - 'of three chains each ending in its own plain end, the first two print in place and the last stays elided', + 'of three chains, the synthesized end closing the first prints in place while the authored ends print as any end does', + // The second and third starts carry a trigger: the validator refuses a + // second plain start, as `BpmnParse.selectInitial` does. minimalProcess( [ start('S1'), step('A'), - end('EndEvent_1'), - start('S2'), + end('EndEvent_p'), + typedEvent('startEvent', 'S2', messageDef('M2')), step('B'), - end('EndEvent_2'), - start('S3'), + end('E2'), + typedEvent('startEvent', 'S3', messageDef('M3')), step('C'), - end('EndEvent_3'), + end('E3'), ], [ edge('S1', 'A'), - edge('A', 'EndEvent_1'), + edge('A', 'EndEvent_p'), edge('S2', 'B'), - edge('B', 'EndEvent_2'), + edge('B', 'E2'), edge('S3', 'C'), - edge('C', 'EndEvent_3'), + edge('C', 'E3'), ], ), [ 'process p {', ' start S1', ' step A', - ' end EndEvent_1', - ' start S2', + ' end EndEvent_p', + ' start S2 message("M2")', ' step B', - ' end EndEvent_2', - ' start S3', + ' end E2', + ' start S3 message("M3")', ' step C', + ' end E3', '}', ], - [ - ['refusedStatement', 'EndEvent_1'], - ['refusedStatement', 'EndEvent_2'], - ], + [['refusedStatement', 'EndEvent_p']], ], ] as const)('%s', async (_title, fixture, source, reports) => { - const ir = typeof fixture === 'string' ? await reDesugar(fixture) : fixture; - expect(await expectIdempotent(ir, 'reservedId')).toEqual( + const ir = + typeof fixture === 'string' + ? renamed(await reDesugar(fixture), 'Done', 'EndEvent_p') + : fixture; + expect(await expectIdempotent(ir, 'mintedId')).toEqual( `${source.join('\n')}\n`, ); expectReports(printDsl(ir).warnings, ...reports); @@ -2548,6 +2711,8 @@ describe('irToDsl: authored terminal in a guard clause', () => { ); const dsl = await printed(ir); expect(dsl).toEqual(`process p { + var x: any + var z: any start S user A if (x) { @@ -2615,15 +2780,17 @@ describe('irToDsl: routes leaving a loop beside the two it is built from', () => 'pre-test', PRE_TEST_IR, 'process p {\n' + + ' var more: any\n' + + ' var escalate: any\n' + ' start S\n' + ' while (more) {\n' + ' user Work\n' + ' }\n' + ' if (escalate) {\n' + - ' goto Escalate\n' + + ' user Escalate\n' + + ' } else {\n' + + ' end E\n' + ' }\n' + - ' end E\n' + - ' user Escalate\n' + ' end E2\n' + '}\n', ], @@ -2631,6 +2798,8 @@ describe('irToDsl: routes leaving a loop beside the two it is built from', () => 'post-test', POST_TEST_IR, 'process p {\n' + + ' var rework: any\n' + + ' var escalate: any\n' + ' start S\n' + ' do {\n' + ' user Review\n' + @@ -2687,6 +2856,115 @@ describe('irToDsl: routes leaving a loop beside the two it is built from', () => ); }); +describe('irToDsl: a split inside a loop body whose every route is conditioned', () => { + /** + * The approve-review loop: `Approve` splits under a condition per route, one + * route leaving the loop for `Pay` and the rest running through a step into + * the loop gateway, which routes back under `${clarified}` and out under + * `${!clarified}`. No route anywhere is unconditioned and no split names a + * fallback, which is the shape a modeler draws. + */ + const reviewLoopIr = ( + staying: readonly (readonly [step: string, condition: string])[], + exit: 'E' | 'E2' = 'E2', + ): BpmnProcess => + minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'userTask', id: 'Approve' }, + gateway('G1'), + ...staying.map(([id]): FlowElement => ({ kind: 'userTask', id })), + gateway('G2'), + { kind: 'userTask', id: 'Pay' }, + { kind: 'endEvent', id: 'E' }, + ...(exit === 'E2' ? [{ kind: 'endEvent', id: 'E2' } as const] : []), + ], + [ + edge('S', 'Approve'), + edge('Approve', 'G1'), + edge('G1', 'Pay', { condition: '${approved}' }), + ...staying.map(([id, condition]) => edge('G1', id, { condition })), + ...staying.map(([id]) => edge(id, 'G2')), + edge('G2', 'Approve', { condition: '${clarified}' }), + edge('G2', exit, { condition: '${!clarified}' }), + edge('Pay', 'E'), + ], + ); + + const loopHead = (declared: string): string => + `process p {\n${declared} start S\n do {\n user Approve\n`; + const loopTail = ' }\n' + ' } while (clarified)\n'; + const reviewBranch = ' } else if (!approved) {\n' + ' user Review\n'; + + // Revert symptoms: `emitRoutes` without the enclosing-construct continuation + // -> every branch is a jump, `Review` is hoisted after `end E2`, and its + // route into the loop gateway is a dropped-edge marker; `cleanJoin` without + // the containment check -> the shared-end row takes `E` as the join, `Review` + // is walked past the printed loop head, and `G2` draws the dropped-edge + // marker with `end E` inside the `do`. + it.each([ + [ + 'walks the one route that stays in the loop inline and jumps on the one that leaves', + reviewLoopIr([['Review', '${!approved}']]), + loopHead(' var approved: any\n var clarified: any\n') + + ' if (approved) {\n' + + ' goto Pay\n' + + reviewBranch + + loopTail + + ' end E2\n' + + ' user Pay\n' + + ' end E\n' + + '}\n', + ], + [ + 'walks both routes that stay in the loop inline', + reviewLoopIr([ + ['Review', '${!approved && !escalated}'], + ['Escalate', '${escalated}'], + ]), + loopHead( + ' var approved: any\n var escalated: any\n var clarified: any\n', + ) + + ' if (approved) {\n' + + ' goto Pay\n' + + ' } else if (!approved && !escalated) {\n' + + ' user Review\n' + + ' } else if (escalated) {\n' + + ' user Escalate\n' + + loopTail + + ' end E2\n' + + ' user Pay\n' + + ' end E\n' + + '}\n', + ], + [ + 'keeps the split inside the loop when its leaving route and the loop exit share one end', + reviewLoopIr([['Review', '${!approved}']], 'E'), + loopHead(' var approved: any\n var clarified: any\n') + + ' if (approved) {\n' + + ' goto Pay\n' + + reviewBranch + + loopTail + + ' end E\n' + + ' user Pay\n' + + ' goto E\n' + + '}\n', + ], + ])('%s', async (_title, ir, expected) => { + const { source, warnings } = printDsl(ir); + expect(source).toBe(expected); + // The chain closes with no `else`, so the printed split gains the fallback + // the model never named; the loop's exit condition has no place after + // `} while (clarified)`. + expectReports( + warnings, + ['inventedFallback', 'G1'], + ['droppedFlowCondition', 'G2'], + ); + await printed(ir); + }); +}); + /** * A multi-out real node whose routes end apart, so neither branch has a join * to walk to and each keeps its edge as a jump. The second lands on a one-out @@ -2933,6 +3211,11 @@ describe('irToDsl: form fields print their parens and block only with something 'birthDate: date "Date of birth" (pattern: "dd/MM/yyyy", required: true)', ], ], + [ + 'a number default opening with #{ prints quoted so it re-lexes as a raw template', + { id: 'seed', type: 'number', defaultValue: '#{seed}' }, + ['seed: number = "#{seed}"'], + ], [ 'a number prints an all-digit bound bare and a negative bound quoted', { @@ -2979,6 +3262,49 @@ describe('irToDsl: form fields print their parens and block only with something ); expect(formBlockLines(dsl)).toEqual(expected); }); + + // The engine evaluates a default as an expression, so the text a modeler + // wrote is the constant it sees; bare, `maybe` would read a variable and + // `1 + 1` would compute, and a bare `-3` lowers to `${-3}`. + it('prints a number or boolean default bare only when it re-lexes as the same literal', async () => { + const dsl = await printed( + around({ + kind: 'userTask', + id: 'T', + formFields: [ + { id: 'a', type: 'boolean', defaultValue: 'maybe' }, + { id: 'b', type: 'number', defaultValue: '1 + 1' }, + { id: 'c', type: 'number', defaultValue: '-3' }, + { id: 'd', type: 'number', defaultValue: '1.5' }, + { id: 'e', type: 'boolean', defaultValue: 'true' }, + { id: 'f', type: 'number', defaultValue: '${x}' }, + { id: 'g', type: 'number', defaultValue: '1.50' }, + { id: 'h', type: 'number', defaultValue: '1.0' }, + { id: 'i', type: 'number', defaultValue: '007' }, + { id: 'j', type: 'number', defaultValue: '0.5' }, + { id: 'k', type: 'number', defaultValue: '9007199254740993' }, + ], + }), + 'formDefaultShape', + ); + // A non-canonical number re-lexes but lowers to another text (`1.50` to + // `1.5`), so it stays quoted. So does a value past `Number`'s safe + // precision (`9007199254740993`, one above 2^53): the double rounds it + // to `9007199254740992` before it ever reaches the printed literal. + expect(formBlockLines(dsl)).toEqual([ + 'a: boolean = "maybe"', + 'b: number = "1 + 1"', + 'c: number = "-3"', + 'd: number = 1.5', + 'e: boolean = true', + 'f: number = "${x}"', + 'g: number = "1.50"', + 'h: number = "1.0"', + 'i: number = "007"', + 'j: number = 0.5', + 'k: number = "9007199254740993"', + ]); + }); }); describe('irToDsl: engine attributes', () => { @@ -3091,7 +3417,7 @@ describe('irToDsl: engine attributes', () => { }; it('renders the parens on every statement kind that takes them, in a fixed setting order', async () => { - const dsl = await printed(ENGINE_IR, 'reservedId'); + const dsl = await printed(ENGINE_IR); expect(dsl).toContain('start S(asyncAfter: true)'); expect(dsl).toContain( 'user U(label: "Review", assignee: "ana", ' + @@ -3129,6 +3455,83 @@ describe('irToDsl: engine attributes', () => { expect(dsl).toContain('versionTag: "3.1"'); }); + /** `S -> E` beside one timer handler whose start `startId` carries the timer job's three settings. */ + const timerHandler = ( + startId: string, + subProcessSettings: JobSettings = {}, + ): BpmnProcess => + processIr( + 'p', + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'endEvent', id: 'E' }, + { + ...triggeredSub('EventSubProcess_p_1', [ + { + kind: 'startEvent', + id: startId, + eventDefinition: timerDef('cycle', 'R/PT1H'), + isInterrupting: false, + exclusive: false, + jobPriority: '5', + retryCycle: 'R1/PT1M', + }, + { kind: 'endEvent', id: 'EndEvent_EventSubProcess_p_1' }, + ]), + ...subProcessSettings, + }, + ], + [edge('S', 'E')], + ); + + // Revert: leave the start's settings off the `on` head and the first two + // rows print a `start StartEvent_...(...)` statement under a reserved id. + it.each<[string, BpmnProcess, string[]]>([ + [ + "a synthesized start's timer-job settings print on the `on timer` head", + timerHandler('StartEvent_EventSubProcess_p_1'), + [ + ' on timer(every: "R/PT1H", exclusive: false, jobPriority: 5, retryCycle: "R1/PT1M", alongside) {', + ' }', + ], + ], + [ + "the sub-process's async flags print ahead of the start's timer-job settings", + timerHandler('StartEvent_EventSubProcess_p_1', { asyncBefore: true }), + [ + ' on timer(every: "R/PT1H", asyncBefore: true, exclusive: false, jobPriority: 5, retryCycle: "R1/PT1M", alongside) {', + ' }', + ], + ], + [ + 'an authored start keeps them on its own statement', + timerHandler('Tick'), + [ + ' on timer(every: "R/PT1H", alongside) {', + ' start Tick(exclusive: false, jobPriority: 5, retryCycle: "R1/PT1M")', + ' }', + ], + ], + ])('%s', async (_title, ir, handlerLines) => { + const dsl = await printed(ir); + // The handler closes the process: its lines sit above the final brace. + expect(dsl.split('\n').slice(-2 - handlerLines.length, -2)).toEqual( + handlerLines, + ); + // The compiler puts the three back on the start either way. + const back = (await reDesugar(dsl)).flowElements.find( + (fe) => fe.kind === 'subProcess' && fe.triggeredByEvent === true, + ); + const start = back?.kind === 'subProcess' && only(back, 'startEvent'); + expect( + start && { + exclusive: start.exclusive, + jobPriority: start.jobPriority, + retryCycle: start.retryCycle, + }, + ).toEqual({ exclusive: false, jobPriority: '5', retryCycle: 'R1/PT1M' }); + }); + it('prints neither parens nor braces for a node carrying no engine attributes', async () => { const dsl = await printed( minimalProcess( @@ -3174,7 +3577,7 @@ describe('irToDsl: engine attributes', () => { expect(dsl).not.toContain('"false"'); }); - it('prints an all-digit priority bare and any other value quoted, a #{ opening or padding rewritten to the raw ${...} form', async () => { + it('prints an all-digit priority bare and any other value quoted, a #{ opening as written and padding trimmed so it re-lexes as a raw template', async () => { const dsl = await printed( minimalProcess( [ @@ -3202,7 +3605,7 @@ describe('irToDsl: engine attributes', () => { 'user B(priority: "${p}", jobPriority: "${order.rush}")', ); expect(dsl).toContain( - 'user C(priority: "${p}", jobPriority: "${order.rush}")', + 'user C(priority: "${p}", jobPriority: "#{order.rush}")', ); }); }); @@ -3266,28 +3669,28 @@ describe('irToDsl: whether a synthesized terminal prints', () => { 'end', { asyncBefore: true }, 'end EndEvent_p(asyncBefore: true)', - ['reservedId'], + ['mintedId'], ], [ 'a terminate on an end, which cannot be re-derived', 'end', { eventDefinition: { kind: 'terminate' } }, 'end EndEvent_p terminate', - ['reservedId'], + ['mintedId'], ], [ 'a listener on an end', 'end', { executionListeners: DONE_LISTENERS }, 'end EndEvent_p {\n on end(class: "com.example.Done")\n }', - ['reservedId'], + ['mintedId'], ], [ "an id carrying another kind's synthesized prefix on an end, authored here", 'end', { id: 'StartEvent_p' }, 'end StartEvent_p', - ['reservedId'], + ['mintedId'], ], ['nothing on an end', 'end', {}, null, []], [ @@ -3302,14 +3705,14 @@ describe('irToDsl: whether a synthesized terminal prints', () => { 'start', { asyncBefore: true }, 'start StartEvent_p(asyncBefore: true)', - ['reservedId'], + ['mintedId'], ], [ "an id carrying another kind's synthesized prefix on a start, authored here", 'start', { id: 'EndEvent_p' }, 'start EndEvent_p', - ['reservedId'], + ['mintedId'], ], ['nothing on a start', 'start', {}, null, []], ['a label alone on a start', 'start', { name: 'Order Received' }, null, []], @@ -3330,11 +3733,11 @@ describe('irToDsl: whether a synthesized terminal prints', () => { expect(dsl).not.toContain(`${side} ${id}`); expect(dsl).not.toContain(`goto ${id}`); if ('name' in payload) expect(dsl).not.toContain(payload.name); - expect(dsl).toContain(UNSTRUCTURED_MARKER); + expect(dsl).toContain(`${UNSTRUCTURED_MARKER} (dropped edge into`); } else { expect(dsl).toContain(expected); expect(dsl).toContain(`goto ${id}`); - expect(dsl).not.toContain(UNSTRUCTURED_MARKER); + expect(dsl).not.toContain('dropped edge'); } }, ); @@ -3405,6 +3808,40 @@ describe('irToDsl: input/output parameters', () => { ); }); + it.each([ + [ + 'an expression map key escapes its opener so it re-lexes as the literal it is', + mapValue([ + mapEntry('${dyn}', textValue('v')), + mapEntry('#{other}', textValue('w')), + ]), + '{ "\\${dyn}": "v", "\\#{other}": "w" }', + ], + [ + 'a value with a line break prints on one line', + textValue('l1\nl2'), + '"l1\\nl2"', + ], + [ + 'a raw template with a quote inside escapes it once', + textValue('${fn("a")}'), + '"${fn(\\"a\\")}"', + ], + ['a #{ template prints as written', textValue('#{x}'), '"#{x}"'], + ] as const)('%s', async (_title, value, expected) => { + const ir = around({ + kind: 'userTask', + id: 'U', + inputParameters: [ioParam('v', value)], + }); + const dsl = await printed(ir); + expect(dsl).toContain(`input v = ${expected}`); + const task = byId(await reDesugar(dsl), 'U'); + expect(task.kind === 'userTask' && task.inputParameters).toEqual([ + ioParam('v', value), + ]); + }); + it('prints a script value as a fenced block carrying its format', async () => { const dsl = await printed( around({ @@ -3543,9 +3980,6 @@ describe('irToDsl: listeners', () => { }, ], }), - // The model carries two of one listener; the surface holds one, so it - // is the model the compiler refuses, not the print. - 'duplicateTimeout', ); expect(dsl).toContain( 'user U {\n' + @@ -3851,6 +4285,7 @@ describe('irToDsl: repeated activities', () => { 'for each line in lines', ], [{ collection: 'lines' }, 'for each in lines'], + [{ collection: 'check-close' }, 'for each in check-close'], [ { collection: '${order.lines}', elementVariable: 'line' }, 'for each line in "${order.lines}"', @@ -3969,6 +4404,84 @@ describe('irToDsl: repeated activities', () => { }); }); +// BPMN has no slot for `var`, so the print declares what the body reads bare: +// every root the validator would otherwise report as undeclared, and nothing +// a form field, a catch binding, an io parameter, an element variable or a +// repetition's engine counters already type. +describe('irToDsl: the header declares every variable the body reads bare', () => { + it('declares each bare read once, typed any, in the order it appears', async () => { + const ir = minimalProcess( + [ + { + kind: 'startEvent', + id: 'S', + formFields: [{ id: 'approved', type: 'boolean' }], + }, + { + kind: 'task', + id: 'Batch', + loop: { + cardinality: '${count}', + completionCondition: '${nrOfCompletedInstances > 1}', + }, + }, + { + ...callActivity('Sub', 'sub'), + inMappings: [{ kind: 'variable', source: 'source', target: 'y' }], + outMappings: [{ kind: 'variable', source: 'result', target: 'z' }], + }, + { + ...serviceTask('Fetch', { + kind: 'external', + topic: 't', + errorMappings: [ + { + errorCode: 'FAIL', + condition: '${externalTask.retries > 2 && flag}', + }, + ], + }), + outputParameters: [ioParam('x', textValue('1'))], + }, + boundaryEvent( + 'Boundary_Fetch_error', + 'Fetch', + errorDef('FAIL', { codeVariable: 'c' }), + ), + { kind: 'userTask', id: 'Repair' }, + { kind: 'endEvent', id: 'Failed' }, + typedEvent( + 'intermediateCatchEvent', + 'Wait', + conditionDef('${x && c == "FAIL"}'), + ), + gateway('G', 'F_else'), + { kind: 'userTask', id: 'A' }, + { kind: 'userTask', id: 'B' }, + gateway('J'), + { kind: 'endEvent', id: 'E' }, + ], + [ + ...flowChain('S', 'Batch', 'Sub', 'Fetch', 'Wait', 'G'), + edge('Boundary_Fetch_error', 'Repair'), + edge('Repair', 'Failed'), + edge('G', 'A', { condition: '${approved}' }), + edge('G', 'B', { id: 'F_else' }), + edge('A', 'J'), + edge('B', 'J'), + edge('J', 'E'), + ], + ); + + const dsl = await printed(ir); + expect(dsl.split('\n').filter((line) => line.startsWith(' var '))).toEqual( + [' var count: any', ' var source: any', ' var flag: any'], + ); + const { diagnostics } = await validate(dsl); + expect(diagnostics.map((d) => d.message)).toEqual([]); + }); +}); + /** * The four settings a repetition writes on the loop element itself * (`runAsyncBefore`, `runAsyncAfter`, `runExclusive`, `runRetryCycle`) sit in @@ -4139,27 +4652,24 @@ const REPORT = { says: [ 'leaves as a jump', 'or as a marker', + 'written on its jump', 'at most one branch is left running', ], }, - implicitSplit: { + emptySplit: { category: 'degradedSplit', - says: ['takes every route it can at once', 'which takes one of them'], + says: ['no route out', 'left out of the script'], + }, + crossBranchJump: { + category: 'refusedStatement', + says: ['crosses', 'draws an error'], + // The reserved-name report's remedy, which renames nothing here. + never: ['Rename the step'], }, inventedFallback: { category: 'defaultFlow', says: ['names no fallback', 'what runs changes'], }, - inventedStepFallback: { - category: 'defaultFlow', - says: [ - 'this tool carries a fallback on a split alone', - 'carrying on is what was meant', - ], - // The import reports the fallback the model named, so claiming the model - // named none would contradict it. - never: ['names no fallback'], - }, raceCondition: { category: 'droppedCondition', says: [ @@ -4203,6 +4713,10 @@ const REPORT = { category: 'droppedSetting', says: ['engine settings', 'block structure', 'runs without them'], }, + renamedId: { + category: 'renamedId', + says: ['written as', 'activity id'], + }, } as const satisfies Record< string, { @@ -4522,13 +5036,13 @@ describe('warnings: edges with no form in the script', () => { expect(ring.source).toContain( `${UNSTRUCTURED_MARKER} (dropped edge into Ring1)`, ); - expect(ring.source).not.toContain('goto'); - // `S` leaves on two routes, which is its own report, and the ring is - // reached twice: once from the branch that opens on it, and once from the - // sweep that picks up what the walk left unprinted. + expect(ring.source).not.toContain('goto Ring'); + // `S` leaves on two routes with no merge to close them, which is its own + // report, and the ring is reached twice: once from the jump that opens on + // it, and once from the sweep that picks up what the walk left unprinted. expectReports( ring.warnings, - ['implicitSplit', 'S'], + ['degradedSplit', 'S'], ['droppedEdge', 'Ring1'], ['droppedEdge', 'Ring1'], ); @@ -4540,93 +5054,95 @@ describe('warnings: edges with no form in the script', () => { ); }); - it('an unnamed plain start heads the printed body whatever its position among the starts', async () => { - // Idempotence is the whole assertion: the re-desugared IR keeps both - // starts only when the elided one heads the body; elsewhere it strands the - // message start's trailing goto and draws "This step can never run". - await expectIdempotent( - minimalProcess( - [ - typedEvent('startEvent', 'B', messageDef('M')), - { kind: 'startEvent', id: 'StartEvent_p' }, - { kind: 'userTask', id: 'V' }, - { kind: 'endEvent', id: 'E' }, - ], - [edge('B', 'V'), edge('StartEvent_p', 'V'), edge('V', 'E')], - ), + // Only the exact `StartEvent_p` is the compiler's; the suffixed id it + // mints past a taken name is not, so a second plain start keeps its + // statement rather than vanishing. The print reports nothing: the two + // plain starts are the model's, which the import refuses ahead of the + // printer and the validator refuses on the way back. + it('a second plain start beside the synthesized one prints under its own id, and the validator reports the second default start', async () => { + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'StartEvent_p' }, + { kind: 'startEvent', id: 'StartEvent_p_2' }, + { kind: 'userTask', id: 'V' }, + { kind: 'endEvent', id: 'E' }, + ], + [edge('StartEvent_p', 'V'), edge('StartEvent_p_2', 'V'), edge('V', 'E')], ); - }); + const { source, warnings } = printDsl(ir); - it('a second unnamed plain start prints under its reserved id and is reported, rather than vanishing', async () => { - const { source, warnings } = printDsl( + expect(warnings).toEqual([]); + expect(source.split('\n').filter((l) => l.startsWith(' start '))).toEqual([ + ' start StartEvent_p_2', + ]); + await printed(ir, 'secondDefaultStart'); + }); +}); + +describe('irToDsl: several starts entering one step', () => { + it.each([ + [ + 'two starts that both end at once print back to back, and the implicit end stays unwritten', + 'process p { start S1 start S2 condition(x) }', + [ + 'process p {', + ' var x: any', + ' start S1', + ' start S2 condition(x)', + '}', + ], + ], + [ + 'starts entering a synthesized split print ahead of the branch, which prints once', + 'process p { start A start B message("M") if (x) { user T1 } else { user T2 } end E }', + [ + 'process p {', + ' var x: any', + ' start A', + ' start B message("M")', + ' if (x) {', + ' user T1', + ' } else {', + ' user T2', + ' }', + ' end E', + '}', + ], + ], + [ + // The compiler re-derives the elided start only at a body that opens + // with no `start`, so its chain prints alone and the starts sharing its + // step jump onto it as one group. + 'the unnamed plain start heads the body alone wherever the model lists it, and the starts sharing its step jump onto it together', minimalProcess( [ - { kind: 'startEvent', id: 'StartEvent_p1' }, - { kind: 'startEvent', id: 'StartEvent_p2' }, + typedEvent('startEvent', 'B', messageDef('M')), + { kind: 'startEvent', id: 'StartEvent_p' }, + typedEvent('startEvent', 'C', signalDef('S')), { kind: 'userTask', id: 'V' }, { kind: 'endEvent', id: 'E' }, ], [ - edge('StartEvent_p1', 'V'), - edge('StartEvent_p2', 'V'), + edge('B', 'V'), + edge('StartEvent_p', 'V'), + edge('C', 'V'), edge('V', 'E'), ], ), - ); - - expectReports(warnings, ['refusedStatement', 'StartEvent_p2']); - const lines = source.split('\n'); - expect(lines.filter((l) => l.trim().startsWith('start '))).toEqual([ - ' start StartEvent_p2', - ]); - // The printed source is meant to fail on exactly that name, which is - // what tells the reader which step to rename. - const errors = (await validate(source)).diagnostics - .filter((d) => d.severity === 1) - .map((d) => [ - lines[d.range.start.line], - typeof d.message === 'string' ? d.message : d.message.value, - ]); - expect(errors).toEqual([ [ - ' start StartEvent_p2', - expect.stringContaining(MODEL_REFUSAL.reservedId), - ], - ]); - }); - - it('a start entering a synthesized split prints as a dropped-edge marker, not as a jump', async () => { - const base = await reDesugar( - 'process p {\n' + - ' var x: boolean\n' + - ' start A\n' + - ' if (x) {\n' + - ' user T1\n' + - ' } else {\n' + - ' user T2\n' + - ' }\n' + - ' end E\n' + - '}\n', - ); - const split = base.flowElements.find( - (el) => el.kind === 'exclusiveGateway' && el.id.endsWith('_split'), - )!; - const ir: BpmnProcess = { - ...base, - flowElements: [ - ...base.flowElements, - typedEvent('startEvent', 'B', messageDef('M')), + 'process p {', + ' user V', + ' end E', + ' start B message("M")', + ' start C signal("S")', + ' goto V', + '}', ], - sequenceFlows: [...base.sequenceFlows, edge('B', split.id)], - }; - - const { source, warnings } = printDsl(ir); - - expectReports(warnings, ['droppedEdge', split.id]); - expect(source).toContain( - `${UNSTRUCTURED_MARKER} (dropped edge into ${split.id})`, - ); - expect(source).not.toMatch(new RegExp(`goto\\s+${split.id}\\b`)); + ], + ] as const)('%s', async (_title, fixture, source) => { + const ir = typeof fixture === 'string' ? await reDesugar(fixture) : fixture; + expect(await expectIdempotent(ir)).toEqual(`${source.join('\n')}\n`); + expect(printDsl(ir).warnings).toEqual([]); }); }); @@ -4658,62 +5174,221 @@ describe('warnings: a split the script has no form for', () => { expect(source).not.toContain('parallel {'); expect(source).toContain('goto X'); expect(source).toContain('goto Y'); - expect(source).not.toContain(UNSTRUCTURED_MARKER); + // The marker names the split the jumps stand for, and no edge is dropped. + expect(source).toContain( + `${UNSTRUCTURED_MARKER} (split Gateway_p_1_fork degraded to jumps; was parallel)`, + ); + expect(source).not.toContain('dropped edge'); expectReports(warnings, ['degradedSplit', 'Gateway_p_1_fork']); }); }); -describe('a step whose own routes split', () => { +describe('irToDsl: a step whose own routes split', () => { /** - * A step with two routes on, one of them weighed. Legal BPMN, and a shape a - * jump cannot carry: a jump ends its block, so a second route written as one - * severs the first route's chain behind it. + * `A` leaves on `routes`, which meet again at `merge` before `E`. The + * engine leaves a step by every route whose condition holds or that carries + * none, and by the fallback alone when none was taken, so the routes print + * as the fork block that reads back the same way. */ - const implicitSplitIr = (condition?: string): BpmnProcess => + const stepSplitIr = ( + routes: readonly (readonly [target: string, route: Route])[], + merge: FlowElement, + defaultFlowId?: string, + ): BpmnProcess => minimalProcess( [ { kind: 'startEvent', id: 'S' }, - { kind: 'userTask', id: 'A' }, - { kind: 'userTask', id: 'B' }, - { kind: 'userTask', id: 'C' }, + { + kind: 'userTask', + id: 'A', + ...(defaultFlowId === undefined ? {} : { defaultFlowId }), + }, + ...routes.map(([id]): FlowElement => ({ kind: 'userTask', id })), + merge, { kind: 'endEvent', id: 'E' }, ], [ edge('S', 'A'), - edge('A', 'B', condition === undefined ? {} : { condition }), - edge('A', 'C'), - edge('B', 'E'), - edge('C', 'E'), + ...routes.map(([id, route]) => edge('A', id, route)), + ...routes.map(([id]) => edge(id, merge.id)), + edge(merge.id, 'E'), ], ); - it('keeps both routes, their condition, and source that compiles', async () => { - const ir = implicitSplitIr('${ok}'); - const dsl = await printed(ir); + const block = (...body: string[]): string => + body.map((line) => ` ${line}\n`).join(''); - expect(dsl).toContain('if (ok) {'); - // The route that would have been severed behind a jump. - expect(realReachability(await reDesugar(dsl))).toEqual( - realReachability(ir), - ); - }); - - it('reports the choice it wrote, naming the step the routes leave', () => { - const { warnings } = printDsl(implicitSplitIr('${ok}')); - - expectReports(warnings, ['implicitSplit', 'A']); - }); + // Revert symptoms: `followLinear` handing every multi-route step to the + // choice chain -> the two-plain-routes row prints `if (true)`; the + // if/else shape not kept -> the fallback row prints `parallel {`; + // `emitJumps` unweighed -> the implicit-merge row loses its marker line. + it.each< + [ + title: string, + ir: BpmnProcess, + body: string, + reports: (readonly [keyof typeof REPORT, string])[], + refused: (keyof typeof MODEL_REFUSAL)[], + gains?: string[], + ] + >([ + [ + 'two plain routes print as a parallel block', + stepSplitIr( + [ + ['B', {}], + ['C', {}], + ], + { kind: 'parallelGateway', id: 'J' }, + ), + block( + 'parallel {', + ' {', + ' user B', + ' }', + ' {', + ' user C', + ' }', + '}', + 'end E', + ), + [], + [], + ], + [ + 'two plain routes into an implicit merge degrade to jumps under the marker', + stepSplitIr( + [ + ['B', {}], + ['C', {}], + ], + { kind: 'userTask', id: 'D' }, + ), + block( + `${UNSTRUCTURED_MARKER} (split A degraded to jumps; was parallel)`, + 'if (true) {', + ' goto B', + '} else {', + ' goto C', + '}', + 'user B', + 'user D', + 'end E', + 'user C', + 'goto D', + ), + [['degradedSplit', 'A']], + [], + ], + [ + 'a weighed route beside a plain one prints as an inclusive block', + stepSplitIr( + [ + ['B', { condition: '${ok}' }], + ['C', {}], + ], + { kind: 'inclusiveGateway', id: 'J' }, + ), + block( + 'parallel {', + ' if (ok) {', + ' user B', + ' }', + ' {', + ' user C', + ' }', + '}', + 'end E', + ), + [], + [], + ], + [ + 'one weighed route beside the fallback keeps the if/else, which routes the same', + stepSplitIr( + [ + ['B', { condition: '${ok}' }], + ['C', { id: 'F_c' }], + ], + gateway('M'), + 'F_c', + ), + block('if (ok) {', ' user B', '} else {', ' user C', '}', 'end E'), + [], + [], + ], + [ + 'a weighed route, a plain one and the fallback print as an inclusive block whose fallback nothing reaches', + stepSplitIr( + [ + ['B', { condition: '${ok}' }], + ['C', {}], + ['D', { id: 'F_d' }], + ], + { kind: 'inclusiveGateway', id: 'J' }, + 'F_d', + ), + block( + 'parallel {', + ' if (ok) {', + ' user B', + ' }', + ' {', + ' user C', + ' }', + ' else {', + ' user D', + ' }', + '}', + 'end E', + ), + [['deadFallback', 'A']], + ['deadElse'], + ], + [ + 'two weighed routes and no fallback print as an inclusive block that invents one', + stepSplitIr( + [ + ['B', { condition: '${a}' }], + ['C', { condition: '${b}' }], + ], + { kind: 'inclusiveGateway', id: 'J' }, + ), + block( + 'parallel {', + ' if (a) {', + ' user B', + ' }', + ' if (b) {', + ' user C', + ' }', + '}', + 'end E', + ), + [['inventedFallback', 'A']], + [], + // The block closes with no `else`, so the recompiled split gains a + // fallback into the merge that the model never had. + ['A->E'], + ], + ])('%s', async (_title, ir, body, reports, refused, gains = []) => { + const { source, warnings } = printDsl(ir); - it('says it whether or not a route is weighed, the model taking both either way', () => { - expectReports(printDsl(implicitSplitIr()).warnings, ['implicitSplit', 'A']); + expect(bodyOf(source)).toBe( + 'process p {\n start S\n user A\n' + body + '}\n', + ); + expectReports(warnings, ...reports); + await printed(ir, ...refused); + expect(realReachability(await reDesugar(source))).toEqual( + new Set([...realReachability(ir), ...gains]), + ); }); - it('says it for a step the loop around it left one route to print', () => { - // `Review` runs the escape and the route back at once. The loop prints the - // route back as its closing brace, leaving one route at the step's own - // position, and the script runs the escape in place of looping rather than - // beside it. What splits is what the model gives the step, so the question - // is asked of that. + it('prints the one route a loop left a step as the fall-through, as it does for a fork', () => { + // `Review` runs the escape and the route back at once. The loop prints + // the route back as its closing brace, leaving one route at the step's + // own position, and that route carries a condition the plain flow on has + // nowhere to write. const { warnings } = printDsl( minimalProcess( [ @@ -4736,11 +5411,7 @@ describe('a step whose own routes split', () => { ), ); - expectReports( - warnings, - ['implicitSplit', 'Review'], - ['droppedFlowCondition', 'Review'], - ); + expectReports(warnings, ['droppedFlowCondition', 'Review']); }); it('says nothing where a step has one route on', () => { @@ -4759,6 +5430,437 @@ describe('a step whose own routes split', () => { }); }); +describe('irToDsl: a split degraded to jumps keeps its conditions on them', () => { + it('writes each weighed route on its own jump under a marker naming the split and its kind', async () => { + // The merge is entered by a boundary chain too, so it is not the fork's + // own and the fork has no block. Its routes leave as jumps, each under the + // condition it carried, and the marker says which split they stand for. + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'userTask', id: 'Order' }, + { kind: 'inclusiveGateway', id: 'Fork' }, + { kind: 'userTask', id: 'Charge' }, + { kind: 'userTask', id: 'Invoice' }, + { kind: 'inclusiveGateway', id: 'Join' }, + boundaryEvent('Late', 'Order', timerDef('duration', 'PT1M')), + { kind: 'userTask', id: 'Remind' }, + { kind: 'endEvent', id: 'E' }, + ], + [ + edge('S', 'Order'), + edge('Order', 'Fork'), + edge('Fork', 'Charge', { condition: '${online}' }), + edge('Fork', 'Invoice', { condition: '${cash}' }), + edge('Charge', 'Join'), + edge('Invoice', 'Join'), + edge('Late', 'Remind'), + edge('Remind', 'Join'), + edge('Join', 'E'), + ], + ); + const { source, warnings } = printDsl(ir); + + expect(source).toContain( + ` ${UNSTRUCTURED_MARKER} (split Fork degraded to jumps; was inclusive)\n` + + ' if (online) {\n' + + ' goto Charge\n' + + ' } else if (cash) {\n' + + ' goto Invoice\n' + + ' }\n', + ); + expectReports(warnings, ['degradedSplit', 'Fork']); + await printed(ir); + }); + + it('keeps a race off the block form when a wait routes on twice, and forks the wait itself', async () => { + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'eventBasedGateway', id: 'Race' }, + typedEvent( + 'intermediateCatchEvent', + 'C1', + timerDef('duration', 'PT1M'), + ), + typedEvent('intermediateCatchEvent', 'C2', messageDef('m')), + { kind: 'userTask', id: 'A' }, + { kind: 'userTask', id: 'B' }, + { kind: 'endEvent', id: 'E' }, + ], + [ + edge('S', 'Race'), + edge('Race', 'C1'), + edge('Race', 'C2'), + edge('C1', 'A'), + edge('C1', 'B'), + edge('C2', 'E'), + edge('A', 'E'), + edge('B', 'E'), + ], + ); + const { source, warnings } = printDsl(ir); + + expect(source).toContain( + `${UNSTRUCTURED_MARKER} (split Race degraded to jumps; was event-based)`, + ); + expect(source).toContain( + `${UNSTRUCTURED_MARKER} (split C1 degraded to jumps; was parallel)`, + ); + expectReports(warnings, ['degradedSplit', 'Race'], ['degradedSplit', 'C1']); + await printed(ir); + }); +}); + +describe('irToDsl: a split with no route out', () => { + // Revert symptom: the check in `emitNode` removed -> every row's list is []. + it.each([ + [ + 'a parallel split ends the run there, as the script does', + 'parallelGateway', + 'ends the run here', + ], + [ + 'an inclusive split stops the model with an error', + 'inclusiveGateway', + 'stops the run with an error', + ], + [ + 'a wait with nothing to wait for holds the model forever', + 'eventBasedGateway', + 'waits here forever', + ], + [ + 'an exclusive split keeps the model from deploying', + 'exclusiveGateway', + 'refuses to deploy', + ], + ] as const)('%s', async (_title, kind, says) => { + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + { kind, id: 'G' }, + ], + [edge('S', 'G')], + ); + const { source, warnings } = printDsl(ir); + + expect(source).toBe('process p {\n start S\n}\n'); + expectReports(warnings, ['emptySplit', 'G']); + expect(warnings[0]!.message).toContain(says); + await printed(ir); + }); +}); + +describe('irToDsl: a split that routes back into itself', () => { + // Revert symptom: the same-node filter in `tryWhile` removed -> the plain + // row prints `while (go) {`; in `tryDoWhileEntry` -> the weighed row prints + // `do {`. + it.each([ + [ + 'a plain route back beside a weighed way on', + gateway('G'), + [edge('G', 'G'), edge('G', 'A', { condition: '${go}' })], + ' if (go) {\n' + + ' } else {\n' + + ` ${UNSTRUCTURED_MARKER} (dropped edge into G)\n` + + ' }\n', + ], + [ + 'a weighed route back beside the fallback', + gateway('G', 'F_on'), + [ + edge('G', 'G', { condition: '${again}' }), + edge('G', 'A', { id: 'F_on' }), + ], + ' if (again) {\n' + + ` ${UNSTRUCTURED_MARKER} (dropped edge into G)\n` + + ' }\n', + ], + ] as const)( + '%s prints the marker rather than a loop', + async (_title, split, routes, chain) => { + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + split, + { kind: 'userTask', id: 'A' }, + { kind: 'endEvent', id: 'E' }, + ], + [edge('S', 'G'), ...routes, edge('A', 'E')], + ); + const { source, warnings } = printDsl(ir); + + expect(bodyOf(source)).toBe( + 'process p {\n start S\n' + chain + ' user A\n end E\n}\n', + ); + expectReports(warnings, ['droppedEdge', 'G']); + await printed(ir); + }, + ); +}); + +describe('irToDsl: a jump into a branch of a fork or a race', () => { + it('reports the jump the script refuses, from the sibling branch it was printed in', async () => { + // `B` is reached from the split inside the first branch and from the fork + // itself. The first branch walks it inline, so the second branch reaches + // it printed and jumps, across the border the validator guards. + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'parallelGateway', id: 'Fork' }, + gateway('X'), + { kind: 'userTask', id: 'A' }, + { kind: 'userTask', id: 'B' }, + { kind: 'parallelGateway', id: 'Join' }, + { kind: 'endEvent', id: 'E' }, + ], + [ + edge('S', 'Fork'), + edge('Fork', 'X'), + edge('X', 'A', { condition: '${a}' }), + edge('X', 'B', { condition: '${b}' }), + edge('A', 'Join'), + edge('Fork', 'B'), + edge('B', 'Join'), + edge('Join', 'E'), + ], + ); + const { source, warnings } = printDsl(ir); + + expect(bodyOf(source)).toBe( + 'process p {\n' + + ' start S\n' + + ' parallel {\n' + + ' {\n' + + ' if (a) {\n' + + ' user A\n' + + ' } else if (b) {\n' + + ' user B\n' + + ' }\n' + + ' }\n' + + ' {\n' + + ' goto B\n' + + ' }\n' + + ' }\n' + + ' end E\n' + + '}\n', + ); + expectReports( + warnings, + ['inventedFallback', 'X'], + ['crossBranchJump', 'B'], + ); + // The refusal the report names. + const errors = (await validate(source)).diagnostics + .filter((d) => d.severity === 1) + .map((d) => + typeof d.message === 'string' ? d.message : d.message.value, + ); + expect(errors).toEqual([ + expect.stringContaining("'goto B' jumps into a branch of a 'parallel'"), + ]); + }); + + it('says nothing about a jump written inside the branch it lands in', async () => { + // A loop headed by a merge, closed by a jump to the merge's successor, + // all of it inside the first branch. + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'parallelGateway', id: 'Fork' }, + gateway('Head'), + { kind: 'userTask', id: 'A' }, + gateway('Again', 'F_out'), + { kind: 'userTask', id: 'Retry' }, + { kind: 'userTask', id: 'B' }, + { kind: 'parallelGateway', id: 'Join' }, + { kind: 'endEvent', id: 'E' }, + ], + [ + edge('S', 'Fork'), + edge('Fork', 'Head'), + edge('Head', 'A'), + edge('A', 'Again'), + edge('Again', 'Retry', { condition: '${retry}' }), + edge('Retry', 'Head'), + edge('Again', 'Join', { id: 'F_out' }), + edge('Fork', 'B'), + edge('B', 'Join'), + edge('Join', 'E'), + ], + ); + const { source, warnings } = printDsl(ir); + + expect(source).toContain(' user Retry\n goto A\n'); + expect(warnings).toEqual([]); + await printed(ir); + }); +}); + +describe('irToDsl: a guard clause whose branch opens on a split', () => { + it('walks the branch inline instead of dropping the edge into the split', async () => { + // The outer split names a fallback that carries the main flow, and the + // branch it guards opens on a second split whose every route is weighed: + // one ends, one runs on into the continuation. + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + gateway('Outer', 'F_on'), + gateway('Inner'), + { kind: 'endEvent', id: 'Rejected' }, + { kind: 'userTask', id: 'Fix' }, + { kind: 'userTask', id: 'Ship' }, + { kind: 'endEvent', id: 'E' }, + ], + [ + edge('S', 'Outer'), + edge('Outer', 'Inner', { condition: '${flagged}' }), + edge('Outer', 'Ship', { id: 'F_on' }), + edge('Inner', 'Rejected', { condition: '${severe}' }), + edge('Inner', 'Fix', { condition: '${minor}' }), + edge('Fix', 'Ship'), + edge('Ship', 'E'), + ], + ); + const { source, warnings } = printDsl(ir); + + expect(bodyOf(source)).toBe( + 'process p {\n' + + ' start S\n' + + ' if (flagged) {\n' + + ' if (severe) {\n' + + ' end Rejected\n' + + ' } else if (minor) {\n' + + ' user Fix\n' + + ' }\n' + + ' }\n' + + ' user Ship\n' + + ' end E\n' + + '}\n', + ); + expectReports(warnings, ['inventedFallback', 'Inner']); + await printed(ir); + }); +}); + +describe('irToDsl: a branch walk stops where the block comes back together', () => { + // The post-dominator queries miss every one of these: a branch that can + // end takes the split's post-dominator to the exit, and a step that loops + // on itself leaves the whole graph with none. Each row is the shape as the + // compiler lowers it, so the print has to come back as written. + it.each([ + [ + 'a race with empty branches into a step that loops on itself prints the step after the block', + [ + 'process p {', + ' await {', + ' message("M") {', + ' }', + ' timer("PT1H") {', + ' }', + ' }', + ' user A', + ' goto A', + '}', + ], + ], + [ + 'a race whose branch holds a throw guard stops at the merge, and the end prints after the block', + [ + 'process p {', + ' await {', + ' timer("PT1H") {', + ' if (c) {', + ' throw message("PaymentDone")', + ' }', + ' }', + ' timer("PT2H") {', + ' }', + ' }', + ' end Done', + '}', + ], + ], + [ + 'an if chain with an ending branch, an empty branch and an else chain keeps the else, and the implicit end stays unwritten', + [ + 'process onboarding {', + ' subprocess Sub4 {', + ' if (true) {', + ' end Done7', + ' } else if (true) {', + ' } else {', + ' service Approve10(class: "org.acme.Audit")', + ' }', + ' }', + '}', + ], + ], + [ + 'a fork branch holding an empty if with an ending else stops at the join, not at the else', + [ + 'process p {', + ' parallel {', + ' {', + ' if (c) {', + ' } else {', + ' end X', + ' }', + ' }', + ' {', + ' user B', + ' }', + ' }', + ' end Done', + '}', + ], + ], + [ + 'a fork whose fallback runs straight into the join beside a nested fork and an ending branch takes that join', + [ + 'process p {', + ' parallel {', + ' if (a) {', + ' parallel {', + ' if (b) {', + ' user P', + ' }', + ' if (c) {', + ' user Q', + ' }', + ' }', + ' }', + ' if (d) {', + ' end X', + ' }', + ' }', + ' end Done', + '}', + ], + ], + ] as const)('%s', async (_title, lines) => { + const source = `${lines.join('\n')}\n`; + const ir = await reDesugar(source); + expect(bodyOf(await expectIdempotent(ir))).toEqual(source); + expect(printDsl(ir).warnings).toEqual([]); + }); +}); + +describe('irToDsl: an event sub-process a flow edge leads into', () => { + it('is refused as malformed, the import turning the flow down ahead of it', () => { + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + eventHandler('H', 'H_Start', messageDef('m')), + ], + [edge('S', 'H')], + ); + + expect(() => printDsl(ir)).toThrow(/flow edge/); + }); +}); + describe('warnings: a condition the script has nowhere to write', () => { /** `source` weighs its one route on, which the script writes as plain flow. */ const oneWeighedRouteIr = (source: FlowElement, target = 'A'): BpmnProcess => @@ -4817,7 +5919,7 @@ describe('warnings: a condition the script has nowhere to write', () => { const ir = oneWeighedRouteIr(node); const { source, warnings } = printDsl(ir); - expect(source).not.toContain('approved'); + expect(bodyOf(source)).not.toContain('approved'); expectReports(warnings, [report, node.id]); await printed(ir); }, @@ -4839,7 +5941,7 @@ describe('warnings: a condition the script has nowhere to write', () => { ); expect(source).toContain('while (again) {'); - expect(source).not.toContain('settled'); + expect(bodyOf(source)).not.toContain('settled'); expectReports(warnings, ['divertedRun', 'Split']); }, ); @@ -4859,7 +5961,6 @@ describe('warnings: a condition the script has nowhere to write', () => { expectReports( warnings, - ['droppedEdge', 'Split'], ['choiceFallbackCondition', 'Split'], ['droppedFlowCondition', 'Split'], ); @@ -4884,7 +5985,7 @@ describe('warnings: a condition the script has nowhere to write', () => { const { source, warnings } = printDsl(ir); expect(source).toContain('goto A'); - expect(source).not.toContain('approved'); + expect(bodyOf(source)).not.toContain('approved'); expectReports(warnings, ['droppedFlowCondition', 'Detached']); }); @@ -5002,8 +6103,9 @@ function inclusiveIr( describe('irToDsl: conditioned parallel branches', () => { /** One conditioned branch and one plain one, the fork and the merge elided. */ - const TWO_BRANCH_SOURCE = + const twoBranchSource = (declared = ' var amount: any\n'): string => 'process p {\n' + + declared + ' start S\n' + ' parallel {\n' + ' if (amount > 10000) {\n' + @@ -5027,6 +6129,7 @@ describe('irToDsl: conditioned parallel branches', () => { expect(source).toBe( 'process p {\n' + + ' var amount: any\n' + ' start S\n' + ' parallel {\n' + ' if (amount > 10000) {\n' + @@ -5052,11 +6155,38 @@ describe('irToDsl: conditioned parallel branches', () => { // The same dead fallback as the case above, beside the same unconditioned // branch, but it goes nowhere the merge does not, so it is left out and the // printed source holds no `else` to report. The report is read off the - // branches that print, not off the model's edges. + // branches that print, not off the model's edges. The compiler reserves no + // fallback beside an unconditioned branch either, so the source comes back + // as the model without that edge. const ir = inclusiveIr('join'); const { source, warnings } = printDsl(ir); - expect(source).toBe(TWO_BRANCH_SOURCE); + expect(source).toBe(twoBranchSource()); + expect(warnings).toEqual([]); + const ir2 = await reDesugar(source); + expect(elementMultiset(ir2)).toEqual(elementMultiset(inclusiveIr('none'))); + expect(edgeMultiset(ir2)).toEqual(edgeMultiset(inclusiveIr('none'))); + }); + + it('prints one weighed branch beside a bare fallback as the block with an else, and reports nothing', async () => { + const ir = inclusiveIr('second-branch'); + const { source, warnings } = printDsl(ir); + + expect(source).toBe( + 'process p {\n' + + ' var amount: any\n' + + ' start S\n' + + ' parallel {\n' + + ' if (amount > 10000) {\n' + + ' user Audit\n' + + ' }\n' + + ' else {\n' + + ' user Record\n' + + ' }\n' + + ' }\n' + + ' end E\n' + + '}\n', + ); expect(warnings).toEqual([]); await expectIdempotent(ir); }); @@ -5065,7 +6195,7 @@ describe('irToDsl: conditioned parallel branches', () => { const { source, warnings } = printDsl(inclusiveIr('none')); // The same two branches as the case above, reached without a default flow. - expect(source).toBe(TWO_BRANCH_SOURCE); + expect(source).toBe(twoBranchSource()); expect(warnings).toEqual([]); }); @@ -5086,6 +6216,8 @@ describe('irToDsl: conditioned parallel branches', () => { expect(source).toBe( 'process p {\n' + + ' var amount: any\n' + + ' var urgent: any\n' + ' start S\n' + ' parallel {\n' + ' if (amount > 10000) {\n' + @@ -5098,7 +6230,7 @@ describe('irToDsl: conditioned parallel branches', () => { ' end E\n' + '}\n', ); - expect(source).not.toContain('urgent'); + expect(bodyOf(source)).not.toContain('urgent'); expectReports(warnings, ['forkFallbackCondition', 'Gateway_p_1_fork']); // What the round trip has to hold on to: the branch is still the fork's @@ -5121,7 +6253,9 @@ describe('irToDsl: conditioned parallel branches', () => { const ir = inclusiveIr('join', '${late}'); const { source, warnings } = printDsl(ir); - expect(source).toBe(TWO_BRANCH_SOURCE); + expect(source).toBe( + twoBranchSource(' var amount: any\n var late: any\n'), + ); expectReports(warnings, ['forkFallbackCondition', 'Gateway_p_1_fork']); await reDesugar(source); }); @@ -5140,7 +6274,7 @@ describe('irToDsl: conditioned parallel branches', () => { ); expect(source).toContain('while (again) {'); - expect(source).not.toContain('settled'); + expect(bodyOf(source)).not.toContain('settled'); expectReports(warnings, ['forkFallbackCondition', 'Fork']); }); @@ -5150,7 +6284,7 @@ describe('irToDsl: conditioned parallel branches', () => { const { source, warnings } = printDsl(inclusiveIr('branch', '${late}')); expect(source).toContain(' else {\n user Triage\n'); - expect(source).not.toContain('late'); + expect(bodyOf(source)).not.toContain('late'); expectReports( warnings, ['forkFallbackCondition', 'Gateway_p_1_fork'], @@ -5186,6 +6320,7 @@ describe('irToDsl: conditioned parallel branches', () => { expect(await printed(ir)).toBe( 'process p {\n' + + ' var amount: any\n' + ' start S\n' + ' parallel {\n' + ' if (amount > 10000) {\n' + @@ -5269,6 +6404,8 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () // run on where the model had nothing left to take. expect(source).toBe( 'process p {\n' + + ' var amount: any\n' + + ' var urgent: any\n' + ' start S\n' + ' if (amount > 10000) {\n' + ' user Audit\n' + @@ -5293,44 +6430,11 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () expect(invented?.conditionExpression).toBeUndefined(); }); - it('reports the invented fallback at a step whose own routes are all weighed, beside the choice it degrades them to', () => { - // Two reports, each about a different change: the model takes every route - // whose condition holds and the script takes one, and the model stops where - // none holds and the script carries on. - const { warnings } = printDsl( - minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - { kind: 'userTask', id: 'Triage' }, - { kind: 'userTask', id: 'Audit' }, - { kind: 'userTask', id: 'Record' }, - gateway('Merge'), - { kind: 'endEvent', id: 'E' }, - ], - [ - edge('S', 'Triage'), - edge('Triage', 'Audit', { condition: '${amount > 10000}' }), - edge('Triage', 'Record', { condition: '${urgent}' }), - edge('Audit', 'Merge'), - edge('Record', 'Merge'), - edge('Merge', 'E'), - ], - ), - ); - - // A step's fallback is not carried into the IR, so the report says what - // the script does and leaves the model out of it. - expectReports( - warnings, - ['implicitSplit', 'Triage'], - ['inventedStepFallback', 'Triage'], - ); - }); - - it('leaves the drop reported on import and the fallback reported on print saying the same thing', async () => { - // The two hops speak about the same step in the same run of the CLI, so a - // print report claiming the model named no fallback would contradict the - // import report naming the one it dropped. + it('reads the fallback an imported step carries, and reports only the condition on it that is weighed nowhere', async () => { + // The two hops speak about the same step in the same run of the CLI: the + // import carries the fallback the model named and says nothing, and the + // print writes it as the `else`, which is the route the engine takes when + // the weighed one fails, whatever the condition on the fallback says. const condition = (body: string): string => `${body}`; const { ir, warnings: imported } = await xmlToIr(bpmnDoc` @@ -5346,26 +6450,32 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () ${condition('${urgent}')} `); - expect(imported.map((w) => [w.category, w.elementId])).toEqual([ - ['unmappedConstruct', 'Triage'], - ]); - expect(imported[0]?.message).toContain( - "The 'default' attribute on 'Triage' was not imported", - ); + expect(imported).toEqual([]); + expect(ir.flowElements.find((e) => e.id === 'Triage')).toEqual({ + kind: 'userTask', + id: 'Triage', + defaultFlowId: 'F2', + }); - expectReports( - printDsl(ir).warnings, - ['implicitSplit', 'Triage'], - ['inventedStepFallback', 'Triage'], + const { source, warnings } = printDsl(ir); + expect(bodyOf(source)).toBe( + 'process p {\n' + + ' start S\n' + + ' user Triage\n' + + ' if (paid) {\n' + + ' end E1\n' + + ' }\n' + + ' end E2\n' + + '}\n', ); + expectReports(warnings, ['forkFallbackCondition', 'Triage']); }); - it('says nothing about a step whose route back into the loop the loop already printed', () => { + it('says nothing about an invented fallback at a step whose route back into the loop the loop already printed', () => { // The route back carries no condition, so `Review` always has it and can // never be left with nowhere to go. The loop prints it as the closing - // brace, which leaves only the weighed escapes at the step's position: the - // question is asked of the routes the model gives the step, not of the ones - // still to print. + // brace, which leaves the weighed escapes at the step's position with no + // merge to close them, so they leave as jumps. const { source, warnings } = printDsl( minimalProcess( [ @@ -5392,7 +6502,7 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () ); expect(source).toContain('while (again) {'); - expectReports(warnings, ['implicitSplit', 'Review']); + expectReports(warnings, ['degradedSplit', 'Review']); }); it('keeps the guard-clause continuation when the route the split names carries a condition', async () => { @@ -5468,6 +6578,8 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () expect(source).toBe( 'process p {\n' + + ' var amount: any\n' + + ' var urgent: any\n' + ' start S\n' + ' if (amount > 10000) {\n' + ' user Audit\n' + @@ -5477,7 +6589,7 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () ' end E\n' + '}\n', ); - expect(source).not.toContain('urgent'); + expect(bodyOf(source)).not.toContain('urgent'); expectReports(warnings, ['choiceFallbackCondition', 'Pick']); // What the round trip has to hold on to: `Record` is still what the split @@ -5612,6 +6724,8 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () expect(await printed(ir)).toBe( 'process p {\n' + + ' var amount: any\n' + + ' var urgent: any\n' + ' start S\n' + ' if (amount > 10000) {\n' + ' user Audit\n' + @@ -5656,7 +6770,7 @@ describe('irToDsl: a condition on a branch of a fork that weighs none', () => { const dsl = await printed(CONDITIONED_AND_FORK); expect(dsl).toContain('parallel {'); - expect(dsl).not.toContain('urgent'); + expect(bodyOf(dsl)).not.toContain('urgent'); // Both forks print as `parallel`, so a head is all that tells them apart. expect(dsl).not.toContain('if ('); }); @@ -5742,8 +6856,8 @@ describe('irToDsl: race', () => { }; const { source, warnings } = printDsl(ir); - expect(source).toBe(irToDsl(RACE_IR)); - expect(source).not.toContain('overdue'); + expect(bodyOf(source)).toBe(bodyOf(irToDsl(RACE_IR))); + expect(bodyOf(source)).not.toContain('overdue'); expectReports(warnings, ['raceCondition', 'Gateway_p_1_race']); }); @@ -5763,8 +6877,8 @@ describe('irToDsl: race', () => { }; const { source, warnings } = printDsl(ir); - expect(source).toBe(irToDsl(RACE_IR)); - expect(source).not.toContain('ok'); + expect(bodyOf(source)).toBe(bodyOf(irToDsl(RACE_IR))); + expect(bodyOf(source)).not.toContain('ok'); expectReports(warnings, ['droppedFlowCondition', 'Catch_p_1_b0']); }); @@ -5862,6 +6976,52 @@ describe('irToDsl: race', () => { await expectIdempotent(ir); }); + // Revert: `raceWait` returning `undefined` for a wait with no route out + // degrades the race to jumps instead of the empty branch. + it('prints a wait with no route out as an empty branch, which ends the run where the model does when nothing follows the race', async () => { + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'eventBasedGateway', id: 'Gateway_p_1_race' }, + typedEvent( + 'intermediateCatchEvent', + 'Catch_p_1_b0', + messageDef('Paid'), + ), + typedEvent( + 'intermediateCatchEvent', + 'Catch_p_1_b1', + timerDef('duration', 'P3D'), + ), + { kind: 'userTask', id: 'Chase' }, + { kind: 'endEvent', id: 'E' }, + ], + [ + edge('S', 'Gateway_p_1_race'), + edge('Gateway_p_1_race', 'Catch_p_1_b0'), + edge('Gateway_p_1_race', 'Catch_p_1_b1'), + edge('Catch_p_1_b1', 'Chase'), + edge('Chase', 'E'), + ], + ); + const { source, warnings } = printDsl(ir); + expect(source).toBe( + 'process p {\n' + + ' start S\n' + + ' await {\n' + + ' message("Paid") {\n' + + ' }\n' + + ' timer("P3D") {\n' + + ' user Chase\n' + + ' end E\n' + + ' }\n' + + ' }\n' + + '}\n', + ); + expect(warnings).toEqual([]); + await printed(ir); + }); + it('degrades when a branch does not open on a wait, and loses no edge doing it', () => { const { source, warnings } = printDsl( minimalProcess( @@ -5953,8 +7113,8 @@ describe('irToDsl: a split with one way out is transparent', () => { const { source, warnings } = printDsl(passThroughIr(kind)); expect(source).toContain('goto R'); - expect(source).not.toContain(UNSTRUCTURED_MARKER); - expectReports(warnings, ['implicitSplit', 'A']); + expect(source).not.toContain('dropped edge'); + expectReports(warnings, ['degradedSplit', 'A']); }, ); }); @@ -6111,6 +7271,7 @@ describe('irToDsl: gateway settings', () => { }), [ 'process p {', + ' var amount: any', ' start S', ' user A(label: "A task")', ' if (amount > 1000) (asyncBefore: true, jobPriority: 10, joinAsyncBefore: true, joinRetryCycle: "R3/PT10M") {', @@ -6141,6 +7302,8 @@ describe('irToDsl: gateway settings', () => { }), [ 'process p {', + ' var a: any', + ' var b: any', ' start S', ' if (a) (exclusive: false, joinAsyncAfter: true) {', ' user A', @@ -6161,6 +7324,8 @@ describe('irToDsl: gateway settings', () => { }), [ 'process p {', + ' var c: any', + ' var a: any', ' start S', ' while (c) {', ' if (a) (joinAsyncBefore: true) {', @@ -6184,6 +7349,8 @@ describe('irToDsl: gateway settings', () => { }), [ 'process p {', + ' var a: any', + ' var x: any', ' start S', ' if (a) {', ' user A', @@ -6208,6 +7375,7 @@ describe('irToDsl: gateway settings', () => { withJobSettings(WHILE_IR, { Gateway_p_1_loop: { asyncAfter: true } }), [ 'process p {', + ' var count: any', ' start S', ' while (count < 10) (asyncAfter: true) {', ' user W(label: "Work")', @@ -6222,6 +7390,7 @@ describe('irToDsl: gateway settings', () => { withJobSettings(DO_WHILE_IR, { Gateway_p_1_loop: { exclusive: false } }), [ 'process p {', + ' var count: any', ' start S', ' do {', ' user W(label: "Work")', @@ -6261,6 +7430,7 @@ describe('irToDsl: gateway settings', () => { }), [ 'process p {', + ' var amount: any', ' start S', ' parallel (retryCycle: "R3/PT10M", joinExclusive: false) {', ' if (amount > 10000) {', @@ -6338,7 +7508,7 @@ describe('irToDsl: gateway settings', () => { PASS_THROUGH_IR, 'Gateway_p_9_join', [ - ['implicitSplit', 'A'], + ['degradedSplit', 'A'], ['droppedSetting', 'Gateway_p_9_join'], ], ], diff --git a/packages/transform/test/ir-to-xml.layout-guard.test.ts b/packages/transform/test/ir-to-xml.layout-guard.test.ts new file mode 100644 index 0000000..6e53951 --- /dev/null +++ b/packages/transform/test/ir-to-xml.layout-guard.test.ts @@ -0,0 +1,31 @@ +/** + * `bpmn-auto-layout` is mocked here rather than provoking its real crash + * (`packages/extension/test/conversion-core.test.ts` does that with a + * validator-clean `goto` graph): this file only needs + * `irToXml` to see some throw and wrap it, not the library's own defect. + */ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('bpmn-auto-layout', () => ({ + layoutProcess: vi.fn(() => { + throw new TypeError('Cannot set properties of undefined'); + }), +})); + +import { irToXml } from '../src/ir-to-xml.js'; +import { LayoutError } from '../src/errors.js'; +import { minimalProcess } from './helpers/ir-fixtures.js'; + +describe('irToXml: layoutProcess guard', () => { + it('wraps a layoutProcess throw in LayoutError carrying the DI-less document', async () => { + const err = await irToXml( + minimalProcess([{ kind: 'task', id: 'X' }]), + ).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(LayoutError); + const layoutError = err as LayoutError; + expect(layoutError.message).toContain('Cannot set properties of undefined'); + expect(layoutError.xml).toContain(' { }); }); +describe('irToXml: bpmn:Definitions id', () => { + it('names bpmn:Definitions after the process id', async () => { + const definitions = await parseDefinitionsWithOperaton( + await irToXml(minimalProcess([{ kind: 'task', id: 'X' }])), + ); + expect(definitions.id).toBe('Definitions_p'); + }); + + it('resolves a collision against an authored element id and keeps the process element', async () => { + const definitions = await parseDefinitionsWithOperaton( + await irToXml(minimalProcess([{ kind: 'task', id: 'Definitions_p' }])), + ); + expect(definitions.id).toBe('Definitions_p_2'); + expect(processOf(definitions).id).toBe('p'); + }); + + it('throws naming the moddle-xml warning when two elements share an id', async () => { + const dup = minimalProcess([ + { kind: 'task', id: 'X' }, + { kind: 'task', id: 'X' }, + ]); + + await expect(irToXml(dup)).rejects.toThrow(/duplicate ID/); + }); +}); + describe('irToXml: Operaton extension attributes', () => { it.each([ 'operaton:assignee="demo"', @@ -292,6 +318,31 @@ describe('irToXml: inclusive and event-based gateway serialization', () => { expect(degreeOf(gatewaysXml, 'Race').out).toBe(2); }); + it('writes the default a step carries, on the step', async () => { + const stepDefault = processIr( + 'step-default', + [ + { kind: 'startEvent', id: 'Start' }, + { kind: 'userTask', id: 'Triage', defaultFlowId: 'F_Triage_B' }, + { kind: 'userTask', id: 'A' }, + { kind: 'userTask', id: 'B' }, + ], + [ + { id: 'F_Start_Triage', sourceRef: 'Start', targetRef: 'Triage' }, + { + id: 'F_Triage_A', + sourceRef: 'Triage', + targetRef: 'A', + conditionExpression: '${a}', + }, + { id: 'F_Triage_B', sourceRef: 'Triage', targetRef: 'B' }, + ], + ); + const xml = await irToXml(stepDefault); + expect(extractNodeBlock(xml, 'Triage')).toContain('default="F_Triage_B"'); + await expectNoModdleWarnings(xml); + }); + it('names the offending gateway kind when a declared default flow is missing', async () => { const danglingDefault = processIr('dangling-default', [ { kind: 'inclusiveGateway', id: 'Fork', defaultFlowId: 'F_absent' }, @@ -2211,6 +2262,65 @@ beforeAll(async () => { nestedGroupsBlock = extensionBlock(nestedGroupsXml); }); +describe("irToXml: a timer job's lock is written where BpmnParse.parseTimer reads it", () => { + /** `S -> Host -> E` with one timer carrier of the given kind beside it. */ + const withTimerCarrier = (carrier: FlowElement): BpmnProcess => + processIr( + 'p', + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'userTask', id: 'Host' }, + { kind: 'endEvent', id: 'E' }, + carrier, + { kind: 'endEvent', id: 'Escaped' }, + ], + [ + edge('S', 'Host'), + edge('Host', 'E'), + edge(carrier.id, 'Escaped', { id: 'SF_escape' }), + ], + ); + + const timer = timerDef('duration', 'PT1H'); + + // The tag keeps the attribute for the async continuation job, which + // `parseAsynchronousContinuation` reads there; the definition gains it for + // the timer job. Revert: drop the definition attribute and the third column + // finds the tag alone. + it.each<[string, FlowElement, number]>([ + [ + 'a non-exclusive boundary timer writes it on the definition and the tag', + { + ...boundaryEvent('Boundary_Host_timer', 'Host', timer), + exclusive: false, + }, + 2, + ], + [ + 'a boundary timer with no lock written writes it nowhere', + boundaryEvent('Boundary_Host_timer', 'Host', timer), + 0, + ], + [ + 'a non-exclusive await writes it on the definition and the tag', + { + ...typedEvent('intermediateCatchEvent', 'Wait', timer), + exclusive: false, + }, + 2, + ], + ])('%s', async (_title, carrier, occurrences) => { + const xml = await irToXml(withTimerCarrier(carrier)); + const block = extractNodeBlock(xml, carrier.id); + expect(block.match(/operaton:exclusive="false"/g) ?? []).toHaveLength( + occurrences, + ); + expect( + block.includes(''), + ).toBe(occurrences > 0); + }); +}); + describe('irToXml: input/output parameters', () => { it('emits one operaton:inputOutput holding every value form, in IR order', () => { expect(nestedGroupsBlock).toMatch( @@ -2274,7 +2384,7 @@ describe('irToXml: listeners', () => { //, ); expect(nestedGroupsBlock).toMatch( - //, + //, ); expect(nestedGroupsBlock).toMatch( /\s*\s*log\(1\);\s*<\/operaton:script>\s*<\/operaton:executionListener>/, @@ -2282,9 +2392,44 @@ describe('irToXml: listeners', () => { // A timeout task listener also carries its timer as a bpmn child. expect(nestedGroupsBlock).toMatch( - /]*>\s*\s*]*>\s*PT2H\s*<\/bpmn:timeDuration>\s*<\/bpmn:timerEventDefinition>\s*<\/operaton:taskListener>/, + /]*>\s*\s*]*>\s*PT2H\s*<\/bpmn:timeDuration>\s*<\/bpmn:timerEventDefinition>\s*<\/operaton:taskListener>/, ); }); + + it('every timeout listener gets its own id, stepping around an id the document already holds', async () => { + // `BpmnParse.parseTimeoutTaskListener` refuses a timeout listener with no + // id, and `TaskDefinition.addTimeoutTaskListener` keys the listeners by + // it, so two on one task need two. The second base is taken by a task. + const timeout = (className: string, duration: string) => ({ + event: 'timeout' as const, + binding: classBinding(className), + timer: timerDef('duration', duration), + }); + const xmlStr = await irToXml( + minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + { + kind: 'userTask', + id: 'Review', + taskListeners: [ + timeout('x.A', 'PT1H'), + { event: 'create', binding: classBinding('x.C') }, + timeout('x.B', 'PT2H'), + ], + }, + { kind: 'userTask', id: 'Review_timeout_2' }, + { kind: 'endEvent', id: 'E' }, + ], + flowChain('S', 'Review', 'Review_timeout_2', 'E'), + ), + ); + expect(listenerTags(extensionBlock(xmlStr))).toEqual([ + '', + '', + '', + ]); + }); }); /** One class-bound `operaton:field`, in the two XML value forms a field takes. */ @@ -2292,6 +2437,8 @@ const STRING_FIELD_TAG = //; const EXPRESSION_FIELD_TAG = /\s*\$\{x\}<\/operaton:expression>\s*<\/operaton:field>/; +const DEFERRED_EXPRESSION_FIELD_TAG = + /\s*#\{x\}<\/operaton:expression>\s*<\/operaton:field>/; describe('irToXml: field injection', () => { type Carrier = 'service task' | 'execution listener' | 'task listener'; @@ -2330,6 +2477,12 @@ describe('irToXml: field injection', () => { '${x}', EXPRESSION_FIELD_TAG, ], + [ + 'a #{...} value on a class-bound service task writes an operaton:expression child too', + 'service task', + '#{x}', + DEFERRED_EXPRESSION_FIELD_TAG, + ], [ 'a literal value on a class-bound execution listener writes the stringValue attribute', 'execution listener', @@ -2624,6 +2777,23 @@ describe('irToXml: task kinds', () => { expect(rate).toContain('operaton:resultVariable="risk"'); }); + it('a decision binding with no version or result-mapping modifier writes decisionRef alone', async () => { + const xml = await irToXml( + around({ + kind: 'serviceTask', + id: 'Rate', + element: 'businessRule', + binding: { kind: 'decision', decisionRef: 'riskRating' }, + }), + ); + expect(extractNodeBlock(xml, 'Rate')).toBe( + '\n' + + ' F1\n' + + ' F2\n' + + ' ', + ); + }); + it('re-reads through the Operaton descriptor with no moddle warnings', async () => { await expectNoModdleWarnings(taskKindsXml); }); diff --git a/packages/transform/test/juel.test.ts b/packages/transform/test/juel.test.ts index a2e3413..b354c53 100644 --- a/packages/transform/test/juel.test.ts +++ b/packages/transform/test/juel.test.ts @@ -11,7 +11,8 @@ * bare unquoted expression (`amount > 1000`). * - A body outside the subset (method/bean calls, JUEL functions, or anything * unparseable) -> a `{ kind: 'raw' }` result, rendered as the quoted - * `"${...}"` fallback. `parseJuel` never throws. + * `"${...}"` or `"#{...}"` fallback with the opener it came with and the + * string escapes the grammar reads back. `parseJuel` never throws. * * The subset boundary is fixed by the grammar; the final two suites * (`idempotence with the grammar renderer` and `subset parity with the real @@ -25,6 +26,8 @@ import { EmptyFileSystem } from 'langium'; import { parseHelper } from 'langium/test'; import { createBpmnScriptServices, + isEquality, + isLiteralString, renderExpression, } from '@bpmn-script/language'; import type { Expr, Model } from '@bpmn-script/language'; @@ -61,6 +64,8 @@ describe('parseJuel: structured classification', () => { ['${ready ? a : b}', 'ready ? a : b'], ['${order.items[0].price}', 'order.items[0].price'], ['${flag-name}', 'flag-name'], + ['${region == "a\\\\b"}', 'region == "a\\\\b"'], + ['${map["a\\\\b"]}', 'map["a\\\\b"]'], ])('classifies %s as structured, rendered bare as %s', (body, surface) => { const r = parseJuel(body); expect(r.kind).toBe('structured'); @@ -95,22 +100,67 @@ describe('parseJuel: raw fallback classification', () => { }); // --------------------------------------------------------------------------- -// The `#{...}` spelling: Operaton accepts either delimiter, the DSL writes `${` +// The `#{...}` spelling: Operaton accepts either delimiter. A structured body +// prints bare and rebuilds inside `${`; a raw one keeps the opener it came with. // --------------------------------------------------------------------------- describe('parseJuel: the #{...} delimiter', () => { - it('classifies an in-subset #{...} body as structured', () => { - expect(parseJuel('#{lineCount}').kind).toBe('structured'); + it('classifies an in-subset #{...} body as structured and renders it bare', () => { + const result = parseJuel('#{lineCount}'); + expect(result.kind).toBe('structured'); + expect(renderRawFallback(result)).toBe('lineCount'); }); - it('renders an in-subset #{...} body as the same bare expression', () => { - expect(renderRawFallback(parseJuel('#{lineCount}'))).toBe('lineCount'); + it('tags a wrapperless body as $ even when its text starts with #', () => { + expect(parseJuel('#foo')).toEqual({ kind: 'raw', text: '#foo', open: '$' }); }); +}); - it('renders an out-of-subset #{...} body as the ${...} fallback', () => { - expect(renderRawFallback(parseJuel('#{myBean.check()}'))).toBe( +describe('renderRawFallback: a raw body prints as a quoted template the grammar reads back to the same text', () => { + it.each([ + [ + 'an out-of-subset ${...} body', + '${myBean.check()}', '"${myBean.check()}"', - ); + ], + [ + 'an out-of-subset #{...} body keeps its opener', + '#{myBean.check()}', + '"#{myBean.check()}"', + ], + [ + 'a quote inside the body is escaped once', + '${execution.getVariable("x")}', + '"${execution.getVariable(\\"x\\")}"', + ], + [ + 'a composite body keeps the opener it starts with', + '${a} #{b}', + '"${a} #{b}"', + ], + ])('%s: %s prints as %s', async (_title, body, printed) => { + expect(renderRawFallback(parseJuel(body))).toBe(printed); + const doc = await parse(`process P { if (${printed}) { } }`); + expect(doc.parseResult.parserErrors).toEqual([]); + const stmt = doc.parseResult.value.processes[0].body[0]; + const cond = (stmt as { condition: Expr }).condition; + expect(cond.$type === 'RawExpr' && cond.raw).toBe(body); + }); +}); + +describe('a string literal prints with the escapes the grammar reads back to the same characters', () => { + it.each([ + ['a backslash', '${region == "a\\\\b"}', 'a\\b'], + ['an embedded quote', '${region == "say \\"hi\\""}', 'say "hi"'], + ])('%s', async (_title, body, chars) => { + const surface = renderRawFallback(parseJuel(body)); + const doc = await parse(`process P { if (${surface}) { } }`); + expect(doc.parseResult.parserErrors).toEqual([]); + const stmt = doc.parseResult.value.processes[0].body[0]; + const cond = (stmt as { condition: Expr }).condition; + expect( + isEquality(cond) && isLiteralString(cond.right) && cond.right.value, + ).toBe(chars); }); }); @@ -141,6 +191,9 @@ describe('parseJuel: totality (never throws)', () => { 'no-wrapper-at-all', '${a == }', '${* 5}', + '${"a\\', + '${(a}', + '${a[0}', ]; it.each(malformed)('never throws on %j', (body) => { @@ -215,10 +268,11 @@ describe('idempotence with the grammar renderExpression', () => { 'order.items[0].price', // String literals: verifies the grammar renderer (expression-render.ts) and // the transform renderer (juel.ts) agree on the canonical quoted form, - // including re-escaping an embedded double quote. + // including re-escaping an embedded double quote and an embedded backslash. // `greeting` avoids the `label` keyword. 'x == "hello"', 'greeting == "say \\"hi\\""', + 'x == "a\\\\b"', ]; it.each(structuredInputs)( diff --git a/packages/transform/test/operaton-moddle.test.ts b/packages/transform/test/operaton-moddle.test.ts index 19ee810..727ef9d 100644 --- a/packages/transform/test/operaton-moddle.test.ts +++ b/packages/transform/test/operaton-moddle.test.ts @@ -372,7 +372,7 @@ describe('per-element attribution of a dropped extension child', () => { R3/PT10M - + @@ -394,7 +394,55 @@ describe('per-element attribution of a dropped extension child', () => { // Never infer a per-element drop from a document-level boolean: assert // the exact warning count and that its message names the dropped type. expect(warnings).toHaveLength(1); - expect(warnings[0].message).toContain('operaton:potentialStarter'); + expect(warnings[0].message).toContain('operaton:formProperty'); + }); +}); + +describe('a potential starter declares the formal expression BpmnParse.parseStartAuthorization reads off it', () => { + it('parses the resourceAssignmentExpression as a typed bpmn child and writes it back', async () => { + const fixture = `${XML_HEADER} + + + + + user(a), group(g) + + + + +`; + + const moddle = operatonModdle(); + const { definitions, process, warnings } = await parseProcess( + moddle, + fixture, + ); + expect(warnings).toHaveLength(0); + + // Revert: drop the PotentialStarter type, and the element is dropped + // from `values` with one document-level warning naming it. + const [starter] = extensionValues(process); + const rae = starter.get('resourceAssignmentExpression') as ModdleElement; + expect([ + starter.$type, + rae.$type, + rae.get('expression').get('body'), + ]).toEqual([ + 'operaton:PotentialStarter', + 'bpmn:ResourceAssignmentExpression', + 'user(a), group(g)', + ]); + + // moddle writes the subtype through `xsi:type`, which + // `Element.element("formalExpression")` does not find by tag, which is + // why the import respells the starter into the two attributes rather + // than writing it back. + const { xml } = await moddle.toXML(definitions, { format: false }); + expect(xml).toContain( + '' + + 'user(a), group(g)' + + '', + ); }); }); @@ -642,3 +690,45 @@ describe('TaskListener timer event definition', () => { expect(xml).toContain('PT1H'); }); }); + +describe('a timer definition declares the lock BpmnParse.parseTimer reads off it', () => { + it('parses operaton:exclusive on bpmn:timerEventDefinition as a typed boolean and writes it back off its default only', async () => { + const fixture = `${XML_HEADER} + + + + R/PT1H + + + + + PT1H + + + +`; + + const moddle = operatonModdle(); + const { definitions, process, warnings } = await parseProcess( + moddle, + fixture, + ); + expect(warnings).toHaveLength(0); + + const [start, wait] = process.get('flowElements') as ModdleElement[]; + const definitionOf = (el: ModdleElement): ModdleElement => + (el.get('eventDefinitions') as ModdleElement[])[0]; + // Revert: drop the type extending the definition and `get` answers + // `undefined` for both, the raw attribute sitting in `$attrs` instead. + expect([ + definitionOf(start).get('exclusive'), + definitionOf(wait).get('exclusive'), + ]).toEqual([false, true]); + + const { xml } = await moddle.toXML(definitions, { format: false }); + expect(xml).toContain( + '', + ); + expect(xml).toContain('PT1H'); + }); +}); diff --git a/packages/transform/test/synthesize-ids.test.ts b/packages/transform/test/synthesize-ids.test.ts index 60663c8..083a71a 100644 --- a/packages/transform/test/synthesize-ids.test.ts +++ b/packages/transform/test/synthesize-ids.test.ts @@ -10,8 +10,8 @@ * Gateway__loop XOR loop-head gateway for a `while` statement X * Flow__default Default (else-branch) flow out of a gateway * Flow__ Sequence flow (plain); duplicate pairs get _2, _3, ... - * StartEvent_ Implicit start event - * EndEvent_ Implicit end event; duplicates get _2, _3, ... + * StartEvent_ Implicit start event of a container + * EndEvent_ Implicit end event of a container, or of a boundary escape * Throw_ Unnamed `throw`/`emit` event at coordinate X * EventSubProcess_ `on` handler (event sub-process) at coordinate X * Boundary__ Hosted `on : ` handler; duplicates get _2, _3, ... @@ -20,6 +20,9 @@ import { describe, expect, it } from 'vitest'; import { isReservedName } from '@bpmn-script/language'; import { + isMintedEndId, + isMintedStartId, + isWritableName, makeGatewaySplitId, makeGatewayJoinId, makeGatewayForkId, @@ -33,6 +36,7 @@ import { makeEventSubProcessId, makeBoundaryEventId, makeIntermediateCatchEventId, + mintPrintableName, resolveCollision, } from '../src/synthesize-ids.js'; @@ -61,11 +65,12 @@ describe('determinism', () => { // --------------------------------------------------------------------------- // Structural stability: templates match the exact documented forms, and every -// form is one the validator reserves +// form is one the validator reserves; a start or end only inside the container +// that seeds it // --------------------------------------------------------------------------- describe('structural stability', () => { - it.each([ + it.each<[string, () => string, string?]>([ ['Gateway_AmountCheck_split', () => makeGatewaySplitId('AmountCheck')], ['Gateway_Step1_split', () => makeGatewaySplitId('Step1')], ['Gateway_AmountCheck_join', () => makeGatewayJoinId('AmountCheck')], @@ -84,27 +89,17 @@ describe('structural stability', () => { [ 'StartEvent_invoice-approval', () => makeStartEventId('invoice-approval', new Set()), + 'invoice-approval', ], - ['StartEvent_my-process', () => makeStartEventId('my-process', new Set())], [ - 'EndEvent_invoice-approval', - () => makeEndEventId('invoice-approval', new Set()), - ], - [ - 'EndEvent_invoice-approval_2', - () => - makeEndEventId( - 'invoice-approval', - new Set(['EndEvent_invoice-approval']), - ), + 'StartEvent_my-process', + () => makeStartEventId('my-process', new Set()), + 'my-process', ], [ - 'EndEvent_invoice-approval_3', - () => - makeEndEventId( - 'invoice-approval', - new Set(['EndEvent_invoice-approval', 'EndEvent_invoice-approval_2']), - ), + 'EndEvent_invoice-approval', + () => makeEndEventId('invoice-approval', new Set()), + 'invoice-approval', ], ['Throw_p_1', () => makeThrowEventId('p_1')], [ @@ -143,13 +138,56 @@ describe('structural stability', () => { new Set(['Boundary_Pack_timer', 'Boundary_Pack_timer_2']), ), ], - ] as const)('%s', (expected, make) => { + // A boundary escape's end and a handler body's own start: minted off a + // coordinate no container name reaches, so the validator reserves the + // prefix rather than the exact form (no `container` argument here). + [ + 'EndEvent_Boundary_Pack_timer', + () => makeEndEventId('Boundary_Pack_timer', new Set()), + ], + [ + 'StartEvent_EventSubProcess_p_1', + () => makeStartEventId('EventSubProcess_p_1', new Set()), + ], + ])('%s', (expected, make, container) => { const id = make(); expect(id).toBe(expected); // An authored name matching a synthesized id is refused, from a pattern // list the validator spells itself. Its package cannot import these // templates, so this row is what keeps the two lists the same set. - expect(isReservedName(id)).toBe(true); + expect(isReservedName(id, container)).toBe(true); + if (container !== undefined) expect(isReservedName(id)).toBe(false); + }); + + // The suffixed id minted past a taken name is an ordinary name: the + // validator does not reserve it, and the printer does not elide it. + it('the minted start and end are recognized exactly, per container', () => { + const boundaries = ['Boundary_Pack_error']; + expect( + [ + 'StartEvent_p', + 'StartEvent_1', + 'StartEvent_p_2', + 'StartEvent_q', + 'EndEvent_p', + 'EndEvent_Boundary_Pack_error', + 'EndEvent_p_2', + 'EndEvent_q', + ].map((id) => [ + id, + isMintedStartId(id, 'p'), + isMintedEndId(id, 'p', boundaries), + ]), + ).toEqual([ + ['StartEvent_p', true, false], + ['StartEvent_1', false, false], + ['StartEvent_p_2', false, false], + ['StartEvent_q', false, false], + ['EndEvent_p', false, true], + ['EndEvent_Boundary_Pack_error', false, true], + ['EndEvent_p_2', false, false], + ['EndEvent_q', false, false], + ]); }); it('the positional throw/handler/catch templates take no taken set (no collision resolution)', () => { @@ -167,6 +205,32 @@ describe('structural stability', () => { }); }); +// --------------------------------------------------------------------------- +// Names for what the script cannot spell +// --------------------------------------------------------------------------- + +describe('mintPrintableName', () => { + // Revert: drop the leading underscore for a keyword and `user` mints itself. + it.each([ + ['a dot becomes an underscore', 'Task.1', false, 'Task_1'], + ['a keyword takes a leading underscore', 'user', false, '_user'], + ['a trailing hyphen becomes an underscore', 'Review-', false, 'Review_'], + ['every non-word character becomes one', 'WFP-6-', false, 'WFP_6_'], + ['a leading digit takes a leading underscore', '1st', false, '_1st'], + [ + 'an inner hyphen is a name already', + 'invoice-approval', + true, + 'invoice-approval', + ], + ])('%s', (_title, id, writable, expected) => { + expect([isWritableName(id), mintPrintableName(id)]).toEqual([ + writable, + expected, + ]); + }); +}); + // --------------------------------------------------------------------------- // Collision resolver // --------------------------------------------------------------------------- diff --git a/packages/transform/test/xml-to-ir.test.ts b/packages/transform/test/xml-to-ir.test.ts index 2a02928..155973d 100644 --- a/packages/transform/test/xml-to-ir.test.ts +++ b/packages/transform/test/xml-to-ir.test.ts @@ -21,11 +21,12 @@ import { fileURLToPath } from 'node:url'; import { xmlToIr } from '../src/xml-to-ir.js'; import type { ImportWarning } from '../src/xml-to-ir.js'; import { HISTORY_TIME_TO_LIVE, irToXml } from '../src/ir-to-xml.js'; +import { irToDsl } from '../src/ir-to-dsl.js'; import { UnsupportedCallActivityError, UnsupportedCollaborationError, UnsupportedConditionExpressionError, - UnsupportedConstructError, + UnsupportedDocumentError, UnsupportedElementError, UnsupportedEventDefinitionError, UnsupportedAssignmentError, @@ -34,6 +35,7 @@ import { UnsupportedExtensionFormError, UnsupportedFormFieldConstraintError, UnsupportedFormReferenceError, + UnsupportedGatewayShapeError, UnsupportedLoopCharacteristicsError, UnsupportedServiceTaskFormError, } from '../src/errors.js'; @@ -102,8 +104,8 @@ const HANDWRITTEN_XML = readFileSync( 'utf-8', ); -// Only the compensation-rewrite-preview tests below re-parse printed DSL; every -// other test in this file asserts against the IR alone. +// Only the tests that print DSL (the compensation-rewrite preview, the +// none-throw rewrite) re-parse it; every other test asserts against the IR alone. let parse: ReturnType>; beforeAll(() => { @@ -130,6 +132,54 @@ const warning = ( message: expect.stringMatching(message), }); +/** + * The report every setting no IR node reads draws: the tag it sat on, and + * the engine method that does read it there, when one does. + */ +const unread = ( + subject: string, + id: string, + tag: string, + engine?: string, +): ImportWarning => ({ + elementId: id, + category: 'extensionAttribute', + message: + `The ${subject} on '${id}' was not imported: this tool reads no such ` + + `setting on ${tag}, ` + + (engine === undefined + ? 'and the document written back carries none.' + : `though Operaton ${engine}, so the document written back runs without it.`), +}); + +/** + * The one warning `xmlToIr` pushes once a document's camunda: text is read as + * operaton:. Every fixture below built with `camundaDoc`/`dualDoc`/ + * `camundaDefs`/`dualDefs` draws it, since the swap runs on the raw namespace + * declaration regardless of which prefix the body actually spells. + */ +const CAMUNDA_ALIAS_MESSAGE = + 'The file declares the camunda namespace; it was read as the operaton ' + + 'namespace, since `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` falls back to ' + + 'the camunda URI wherever the operaton spelling is absent, and the ' + + 'document written back carries `operaton:` alone.'; +const CAMUNDA_ALIAS_WARNING = { + elementId: 'p', + category: 'unmappedConstruct', + message: CAMUNDA_ALIAS_MESSAGE, +}; + +/** The one warning the older `operaton:async` spelling draws on `subject` (`'T'`, `the repetition of 'T'`). */ +const asyncRespelling = (subject: string): string => + `The operaton:async="true" on ${subject} imports as asyncBefore: true: ` + + 'BpmnParse.isAsyncBefore reads the two spellings as one, and this tool ' + + 'writes it back as operaton:asyncBefore, which the engine reads the same.'; + +/** The refusal `BpmnParse.parsePriority` draws for a constant that is not an integer. */ +const priorityRefusal = (attr: string, value: string): string => + `operaton:${attr}="${value}", which BpmnParse.parsePriority refuses to ` + + `deploy ("Value '${value}' for attribute '${attr}' is not a valid number")`; + /** The whole refusal an activity excluded from normal flow draws. */ const IS_FOR_COMPENSATION_DETAIL = 'isForCompensation="true" marks this activity as excluded from normal ' + @@ -201,15 +251,132 @@ describe('xmlToIr: camunda: prefix alias', () => { expect(ir).toEqual(HANDWRITTEN_IMPORT_IR); }); - it('camunda:assignee is read as UserTask.assignee', async () => { - const { node } = await importOnly( - oneNodeDoc('userTask', { - attrs: 'name="My Task" camunda:assignee="alice"', - doc: camundaDoc, - }), - 'userTask', + /** + * One userTask carrying an assignee, a task listener, an input/output block + * and a retry cycle, plus one callActivity carrying an in mapping: every + * element-form construct finding 13 dropped, in one document. + */ + const constructsBody = ( + prefix: 'operaton' | 'camunda', + ): string => ` + + + <${prefix}:taskListener event="create" class="com.example.C" /> + <${prefix}:inputOutput> + <${prefix}:inputParameter name="amount">42 + + <${prefix}:failedJobRetryTimeCycle>R3/PT10M + + + + + <${prefix}:in source="a" target="b" /> + + + + + + `; + + const expectConstructsImported = (ir: BpmnProcess): void => { + expect(byId(ir, 'Review')).toEqual({ + kind: 'userTask', + id: 'Review', + assignee: 'alice', + taskListeners: [ + { event: 'create', binding: classBinding('com.example.C') }, + ], + inputParameters: [ioParam('amount', textValue('42'))], + retryCycle: 'R3/PT10M', + }); + expect(byId(ir, 'Call')).toEqual({ + kind: 'callActivity', + id: 'Call', + calledElement: 'other', + inMappings: [{ kind: 'variable', source: 'a', target: 'b' }], + }); + }; + + it('a camunda: file carrying an assignee, a task listener, an inputOutput, a call activity in mapping and a retry cycle imports every one of them, with exactly the one namespace warning', async () => { + const { ir, warnings } = await xmlToIr( + camundaDoc`${constructsBody('camunda')}`, + ); + expectConstructsImported(ir); + expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); + }); + + it('a dual-prefix document imports both spellings with the same one warning', async () => { + const mixedBody = ` + + + + + 42 + + R3/PT10M + + + + + + + + + + + `; + + const { ir, warnings } = await xmlToIr(dualDoc`${mixedBody}`); + expectConstructsImported(ir); + expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); + }); + + it('a document declaring xmlns:camunda on the task element rather than the root imports the same way', async () => { + const xml = bpmnDoc` + + + + + + + + `; + + const { ir, warnings } = await xmlToIr(xml); + const task = byId(ir, 'T'); + expect(task.kind === 'userTask' && task.assignee).toBe('alice'); + expect(task.kind === 'userTask' && task.taskListeners).toEqual([ + { event: 'create', binding: classBinding('com.example.C') }, + ]); + expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); + }); + + it('an operaton: document draws no namespace warning', async () => { + const { ir, warnings } = await xmlToIr( + operatonDoc`${constructsBody('operaton')}`, + ); + expectConstructsImported(ir); + expect(warnings).toEqual([]); + }); + + // Revert: swapping the URI throughout the text rewrites the documentation + // and draws the alias warning for a document declaring only operaton:. + it('the camunda URI inside a documentation body is text, not a declaration: kept verbatim and no warning', async () => { + const { ir, warnings } = await xmlToIr( + operatonDoc` + + See http://camunda.org/schema/1.0/bpmn for the schema. + + + + `, ); - expect(node.assignee).toBe('alice'); + expect(byId(ir, 'T')).toEqual({ + kind: 'task', + id: 'T', + documentation: 'See http://camunda.org/schema/1.0/bpmn for the schema.', + }); + expect(warnings).toEqual([]); }); }); @@ -509,7 +676,8 @@ describe('xmlToIr: script task support', () => { ), UnsupportedExtensionFormError, `the script on 'ST' names an external resource ("${resource}"); ` + - 'only an inline script body can be written here', + 'ScriptUtil.getScript runs the resource in place of the body, and ' + + 'only an inline body can be written here', ); expect(e.elementId).toBe('ST'); @@ -528,11 +696,162 @@ describe('xmlToIr: script task support', () => { ), UnsupportedExtensionFormError, 'the operaton:script in an operaton:executionListener names an ' + - `external resource ("${resource}"); only an inline script body ` + - 'can be written here', + `external resource ("${resource}"); ScriptUtil.getScript runs the ` + + 'resource in place of the body, and only an inline body can be ' + + 'written here', ); expect(listenerErr.elementId).toBe('T'); }); + + it('a missing scriptFormat imports as juel with a warning naming the engine default', async () => { + const { node, warnings } = await importOnly( + oneNodeDoc('scriptTask', { + id: 'ST', + children: 'x = 1;', + doc: bpmnDoc, + }), + 'scriptTask', + ); + expect(node.format).toBe('juel'); + expect(warnings).toEqual([ + { + elementId: 'ST', + category: 'extensionAttribute', + message: + "The script on 'ST' has no scriptFormat; " + + '`BpmnParse.parseScriptTaskElement` substitutes ' + + '`ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE` (juel), and it was ' + + 'imported as such.', + }, + ]); + }); + + it('an empty scriptFormat refuses instead of importing an unparseable fence', async () => { + await expectRefusal( + xmlToIr( + oneNodeDoc('scriptTask', { + id: 'ST', + attrs: 'scriptFormat=""', + children: 'x = 1;', + doc: bpmnDoc, + }), + ), + UnsupportedExtensionFormError, + "the script on 'ST' has an empty scriptFormat; " + + '`ScriptUtil.getScript` refuses to deploy it', + ); + }); + + it('a script task with no script body and no resource refuses instead of importing an empty fence', async () => { + await expectRefusal( + xmlToIr( + oneNodeDoc('scriptTask', { + id: 'ST', + attrs: 'scriptFormat="javascript"', + doc: bpmnDoc, + }), + ), + UnsupportedExtensionFormError, + "the script on 'ST' has neither a script body nor a resource; " + + '`ScriptUtil.getScript` refuses to deploy it with neither', + ); + }); + + // Revert: `checkScriptBody` warning on nothing leaves every row's + // warning list empty. + it.each([ + { + title: 'a self-closing bpmn:script', + children: '', + where: "the script on 'ST'", + carried: { code: '', executionListeners: undefined }, + }, + { + title: 'a whitespace-only bpmn:script', + children: '\n ', + where: "the script on 'ST'", + carried: { code: '', executionListeners: undefined }, + }, + { + title: 'a self-closing operaton:script on a listener', + children: ` + + + + + x = 1;`, + where: 'the operaton:script in an operaton:executionListener', + carried: { + code: 'x = 1;', + executionListeners: [ + { + event: 'start', + binding: { kind: 'script', format: 'groovy', code: '' }, + }, + ], + }, + }, + ])( + '$title imports as an empty body with a warning, since ScriptUtil.getScript deploys it and the printed fence draws an error', + async ({ children, where, carried }) => { + const { node, warnings } = await importOnly( + oneNodeDoc('scriptTask', { + id: 'ST', + attrs: 'scriptFormat="javascript"', + children, + doc: bpmnDoc, + }), + 'scriptTask', + ); + expect({ + code: node.code, + executionListeners: node.executionListeners, + }).toEqual(carried); + expect(warnings).toEqual([ + { + elementId: 'ST', + category: 'extensionAttribute', + message: + `The body of ${where} is empty: ScriptUtil.getScript deploys ` + + 'it, since it checks the source for null and not for ' + + 'emptiness, and the printed script draws an empty-body error ' + + 'there.', + }, + ]); + }, + ); + + it('a script task body holding three backticks refuses, since no fence can enclose it', async () => { + await expectRefusal( + xmlToIr(scriptTaskDoc('a = "```";')), + UnsupportedExtensionFormError, + "the script on 'ST' contains three consecutive backticks, which no " + + 'script fence this language has can enclose', + ); + }); + + it('a scriptFormat outside the alias table is carried as written with a warning', async () => { + const { node, warnings } = await importOnly( + oneNodeDoc('scriptTask', { + id: 'ST', + attrs: 'scriptFormat="cobol"', + children: '1', + doc: bpmnDoc, + }), + 'scriptTask', + ); + expect(node.format).toBe('cobol'); + expect(warnings).toEqual([ + { + elementId: 'ST', + category: 'extensionAttribute', + message: + "The script on 'ST' names the language 'cobol', which the DSL " + + 'has no fence alias for; it was imported as written, and the ' + + 'printed script draws an error there.', + }, + ]); + }); }); describe('xmlToIr: unsupported element (still refused kinds)', () => { @@ -557,20 +876,263 @@ describe('xmlToIr: unsupported element (still refused kinds)', () => { }); }); -describe('xmlToIr: multi-process error', () => { - it('XML with two bpmn:process elements raises a clear multi-process error', async () => { - const xml = bpmnDefs` - - - - - - - - - `; +describe('xmlToIr: the document level', () => { + const STEPS = ` + + + + `; + const process = (attrs: string, id = 'p', content = STEPS): string => + ` \n${content}\n `; + const EXECUTABLE = process(' isExecutable="true"'); + const collaboration = (children: string): string => + ` \n${children}\n `; + const unmapped = (elementId: string, message: RegExp) => + warning(elementId, message, 'unmappedConstruct'); + /** The two processes of a Modeler file with one drawn pool and one empty one. */ + const twoPools = + collaboration(` + `); + const approval = process(' isExecutable="true"', 'approval'); + const sketch = process( + ' isExecutable="false"', + 'sketch', + ' ', + ); + const twoPoolsWarnings = [ + unmapped( + 'sketch', + /^The process 'sketch' is not marked executable and was not imported; `BpmnParse.parseProcessDefinitions` does not deploy it either\.$/, + ), + unmapped( + 'P1', + /^The pool 'Approval' \(P1\) names the imported process 'approval'; `BpmnParse.parseCollaboration` records it for the diagram alone, and the document written back has no pool\.$/, + ), + unmapped( + 'P2', + /^The pool 'Sketch' \(P2\) names process 'sketch', which was not imported; `BpmnParse\.parseCollaboration`/, + ), + ]; + const VALIDATING_PARSE = + /Operaton's validating parse \(`Parse\.execute` against `BPMN20\.xsd`/; - await expect(xmlToIr(xml)).rejects.toThrow(/multi.process|not supported/i); + it.each([ + [ + 'one participant naming the process, and a lane, import with the pool and the lane warned', + bpmnDefs`${collaboration(' ')} +${process( + ' isExecutable="true"', + 'p', + ` \n${STEPS}`, +)}`, + { + imports: 'p', + warnings: [ + warning( + 'Lane_1', + /^Lane 'Clerk' \(Lane_1\) was not imported/, + 'lane', + ), + unmapped( + 'P1', + /^The pool 'Approval' \(P1\) names the imported process 'p'; `BpmnParse\.parseCollaboration` records it for the diagram alone, and the document written back has no pool\.$/, + ), + ], + }, + ], + [ + 'one participant plus a message flow import with both warned', + bpmnDefs`${collaboration(` + `)} +${EXECUTABLE}`, + { + imports: 'p', + warnings: [ + unmapped( + 'P1', + /^The pool 'Approval' \(P1\) names the imported process 'p'/, + ), + unmapped( + 'MF', + /^The message flow 'MF' from 'A' to 'P1' was not imported; `BpmnParse` reads no message flow, so the process runs identically without it\.$/, + ), + ], + }, + ], + [ + 'two pools over one executable and one non-executable process import the executable one, written first', + bpmnDefs`${twoPools}\n${approval}\n${sketch}`, + { imports: 'approval', warnings: twoPoolsWarnings }, + ], + [ + 'two pools over one executable and one non-executable process import the executable one, written last', + bpmnDefs`${twoPools}\n${sketch}\n${approval}`, + { imports: 'approval', warnings: twoPoolsWarnings }, + ], + [ + 'two executable processes refuse, naming both', + bpmnDefs`${process(' isExecutable="true"', 'p1')}\n${process(' isExecutable="true"', 'p2', ' ')}`, + { + refusal: UnsupportedCollaborationError, + detail: /^two executable processes \('p1', 'p2'\)$/, + }, + ], + [ + 'two processes neither marked executable refuse: the engine deploys none of them', + bpmnDefs`${process('', 'p1')}\n${process('', 'p2', ' ')}`, + { + refusal: UnsupportedDocumentError, + detail: + /^none of its 2 processes is marked isExecutable="true"; `BpmnParse\.parseProcessDefinitions` deploys none of them/, + }, + ], + [ + 'one process without isExecutable imports as executable and says so', + bpmnDefs`${process('')}`, + { + imports: 'p', + warnings: [ + unmapped( + 'p', + /^The process 'p' is not marked isExecutable="true", which `BpmnParse\.parseProcessDefinitions` skips in a new deployment; it was imported as an executable process and is written back as one\.$/, + ), + ], + }, + ], + [ + 'a DOCTYPE with an internal entity refuses', + bpmnDefs`${EXECUTABLE}`.replace( + ']>\n declaration.*disallow-doctype-decl/, + }, + ], + [ + 'an entity XML does not predefine refuses', + bpmnDefs`${process(' isExecutable="true"', 'p', STEPS.replace('id="A"', 'id="A" name="A B"'))}`, + { + refusal: UnsupportedDocumentError, + detail: + /^it references the entity ' ', which XML does not predefine and no DOCTYPE declares/, + }, + ], + [ + 'a predefined entity, and anything inside a comment or a CDATA section, import', + bpmnDefs`${process( + ' isExecutable="true"', + 'p', + ` + + + + + + + `, + )}`, + { imports: 'p', warnings: [] }, + ], + [ + 'isSequential outside true/false refuses, naming the validating parse', + bpmnDefs`${process(' isExecutable="true"', 'p', STEPS.replace('', ''))}`, + { + refusal: UnsupportedDocumentError, + detail: new RegExp( + `^isSequential="yes" is outside true and false.*${VALIDATING_PARSE.source}`, + ), + }, + ], + [ + 'triggeredByEvent outside true/false refuses the same way', + bpmnDefs`${process(' isExecutable="true"', 'p', `${STEPS}\n `)}`, + { + refusal: UnsupportedDocumentError, + detail: new RegExp( + `^triggeredByEvent="on" is outside true and false.*${VALIDATING_PARSE.source}`, + ), + }, + ], + [ + 'isSequential text inside documentation does not refuse: only an attribute in a tag does', + bpmnDefs`${process( + ' isExecutable="true"', + 'p', + ` Do not write isSequential="yes" here\n${STEPS}`, + )}`, + { imports: 'p', warnings: [] }, + ], + [ + 'an id written on two elements refuses, naming the id', + bpmnDefs`${process(' isExecutable="true"', 'p', STEPS.replace('', '\n '))}`, + { + refusal: UnsupportedDocumentError, + detail: + /^the id 'A' is written on two elements; Operaton validates every file against BPMN20\.xsd/, + }, + ], + [ + 'an id outside the ASCII alphabet this tool reads refuses, naming the id', + bpmnDefs`${process(' isExecutable="true"', 'p', STEPS.replaceAll('"A"', '"Auftrag_prüfen"'))}`, + { + refusal: UnsupportedDocumentError, + detail: + "the id 'Auftrag_prüfen' is outside what this tool reads: ASCII " + + "letters, digits, '_', '-' and '.', starting with a letter or '_', " + + 'where the schema admits any letter; rename it', + }, + ], + [ + 'a bpmn:import refuses, naming its type', + bpmnDefs` +${EXECUTABLE}`, + { + refusal: UnsupportedDocumentError, + detail: + /^it declares a bpmn:import of type 'http:\/\/www\.w3\.org\/2001\/XMLSchema'; `BpmnParse\.parseImports` fails the deployment/, + }, + ], + [ + 'two signal roots sharing a name refuse, naming both', + bpmnDefs` + +${EXECUTABLE}`, + { + refusal: UnsupportedEventFeatureError, + detail: + /^signal roots 'Signal_1' and 'Signal_2' both declare the name "Go"; `BpmnParse\.parseSignals` fails the deployment/, + }, + ], + // Revert: `checkStartEventCount` returning before the no-start check on + // a process imports both rows, the second with no warning at all. + [ + 'an empty process refuses, since parseStartEvents fails the deployment without a start', + bpmnDefs`${process(' isExecutable="true"', 'p', '')}`, + { + refusal: UnsupportedEventFeatureError, + detail: + /^it has no start event, which BpmnParse\.parseStartEvents fails the deployment on \("process must define a startEvent element"\)/, + }, + ], + [ + 'a process with steps but no start refuses the same way', + bpmnDefs`${process(' isExecutable="true"', 'p', ' \n \n ')}`, + { + refusal: UnsupportedEventFeatureError, + detail: + /^it has no start event, which BpmnParse\.parseStartEvents fails the deployment on \("process must define a startEvent element"\)/, + }, + ], + ] as const)('%s', async (_title, xml, expected) => { + if ('refusal' in expected) { + await expectRefusal(xmlToIr(xml), expected.refusal, expected.detail); + return; + } + const { ir, warnings } = await xmlToIr(xml); + expect(ir.id).toBe(expected.imports); + expect(warnings).toEqual(expected.warnings); }); }); @@ -790,13 +1352,20 @@ describe('xmlToIr: start, end, and emit triggers', () => { }); describe('a process takes several starts, a subprocess or transaction takes one', () => { - it('a process with two start events imports both, each keeping its outgoing flow', async () => { - const { ir, warnings } = - await xmlToIr(bpmnDoc` - - + const TIMER_START = `R/PT1H`; + const MESSAGE_START = ''; + const twoStarts = (second: string): string => + bpmnDefs` + + + ${second} + - `); + + `; + + it('a process with a plain start beside a message start imports both, each keeping its outgoing flow', async () => { + const { ir, warnings } = await xmlToIr(twoStarts(MESSAGE_START)); expect(warnings).toEqual([]); expect(ir.flowElements.map((fe) => [fe.kind, fe.id])).toEqual([ ['startEvent', 'S1'], @@ -809,6 +1378,64 @@ describe('xmlToIr: start, end, and emit triggers', () => { ]); }); + it.each([ + ['a second plain start', ''], + ['a timer start', TIMER_START], + ])( + 'a process with a plain start beside %s refuses with the sentence selectInitial fails the deployment with', + async (_title, second) => { + const e = await expectRefusal( + xmlToIr(twoStarts(second)), + UnsupportedEventFeatureError, + "the process 'p' has 2 plain or timer starts, which " + + 'BpmnParse.selectInitial fails the deployment on ("multiple ' + + 'none start events or timer start events not supported on ' + + 'process definition")', + ); + expect(e.elementId).toBe('S2'); + }, + ); + + const noStart = (tag: string): string => + bpmnDoc` + <${tag} id="Sub"> + + + + + + + `; + + it('a bpmn:subProcess with no start event refuses with the sentence parseStartEvents fails the deployment with', async () => { + const e = await expectRefusal( + xmlToIr(noStart('bpmn:subProcess')), + UnsupportedEventFeatureError, + 'it has no start event, which BpmnParse.parseStartEvents fails the ' + + 'deployment on ("subProcess must define a startEvent element")', + ); + expect(e.elementId).toBe('Sub'); + }); + + it('a bpmn:transaction with no start event imports and warns that the engine fails it on entry', async () => { + const { ir, warnings } = await xmlToIr(noStart('bpmn:transaction')); + expect(subProcess(ir, 'Sub').flowElements.map((fe) => fe.id)).toEqual([ + 'In', + 'SubEnd', + ]); + expect(warnings).toEqual([ + { + elementId: 'Sub', + category: 'unmappedConstruct', + message: + "The bpmn:transaction 'Sub' has no start event: Operaton " + + 'deploys it and SubProcessActivityBehavior.execute fails on ' + + 'entering it ("No initial activity found"); the script adds a ' + + 'start, so the imported block runs.', + }, + ]); + }); + it.each(['bpmn:subProcess', 'bpmn:transaction'])( 'a %s with two start events refuses, attributed to it', async (tag) => { @@ -945,7 +1572,7 @@ describe('xmlToIr: start, end, and emit triggers', () => { 'endEvent', ); expect(node.binding).toEqual(classBinding('com.example.Send')); - expect(warnings).toEqual([]); + expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); }); it('a message emit carries the implementation too', async () => { @@ -1085,128 +1712,366 @@ describe('xmlToIr: start, end, and emit triggers', () => { { roots: SIGNAL_ROOT, defs: operatonDefs }, ), ); - expectOneWarning(extensionWarnings(warnings), { - elementId: 'Typed', - message: 'operaton:Connector', - }); + expect(extensionWarnings(warnings)).toEqual([ + unread('operaton:connector', 'Typed', 'a '), + ]); + }); + + /** The definition with an extension block, for the fields and mappings the engine reads off it. */ + const messageDefWith = (attrs: string, children: string): string => + ` + +${children} + + `; + + /** A property list on the event element itself, where `parseServiceTaskLike` is handed it. */ + const EVENT_PROPERTIES = ` + + + + + `; + + const resultVariableRefusal = ( + binding: string, + attr: string, + elementName: string, + ): string => + `its message definition binds operaton:${binding} with operaton:${attr}, ` + + `which Operaton refuses to deploy: "'resultVariableName' not supported ` + + `for ${elementName} elements using '${binding}'" ` + + '(BpmnParse.parseServiceTaskLike)'; + + // Revert: drop the `refuseResultVariableBeside` call from + // `readThrownMessageBinding`, and the first two rows import with a + // warning; drop the expression arm, and the third does. + it.each([ + [ + 'operaton:class with operaton:resultVariable on an emit', + emitXml( + implementedMessageDef( + 'operaton:class="com.example.Send" operaton:resultVariable="r"', + ), + { roots: MESSAGE_ROOT, defs: operatonDefs }, + ), + 'Emit1', + resultVariableRefusal( + 'class', + 'resultVariable', + 'intermediateMessageThrowEvent', + ), + ], + [ + 'operaton:delegateExpression with the older operaton:resultVariableName on a message end', + endTriggerXml( + implementedMessageDef( + 'operaton:delegateExpression="${bean}" operaton:resultVariableName="r"', + ), + { roots: MESSAGE_ROOT, defs: operatonDefs }, + ), + 'Typed', + resultVariableRefusal( + 'delegateExpression', + 'resultVariableName', + 'messageEndEvent', + ), + ], + [ + 'operaton:expression with operaton:resultVariable on a message end, which the engine deploys and stores the value under', + endTriggerXml( + implementedMessageDef( + 'operaton:expression="${sender.send()}" operaton:resultVariable="r"', + ), + { roots: MESSAGE_ROOT, defs: operatonDefs }, + ), + 'Typed', + 'its message definition binds operaton:expression with ' + + 'operaton:resultVariable="r", under which ' + + "BpmnParse.parseServiceTaskLike stores the expression's value " + + '(ServiceTaskExpressionActivityBehavior); a thrown message in ' + + 'this script takes no result variable, so dropping it would ' + + 'change what runs', + ], + ])( + '%s refuses with UnsupportedEventFeatureError quoting the engine', + async (_title, xml, elementId, detail) => { + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedEventFeatureError, + detail, + ); + expect(e.elementId).toBe(elementId); + }, + ); + + const neverWritten = (attr: string, binding: string): string => + `The '${attr}' setting on 'Typed' was not imported: ` + + 'BpmnParse.parseServiceTaskLike hands it to an expression binding ' + + `alone, so ${binding} never writes it.`; + + const readOffThrow = (what: string, carrier: string): string => + `The ${what} on 'Typed' was not imported: ` + + `BpmnParse.parseExternalServiceTask reads it off ${carrier} of a ` + + 'thrown message bound with operaton:type="external", and this ' + + "surface's throw has no position for it, so the document written " + + 'back runs without it.'; + + const neverReadOnThrow = (what: string): string => + `The ${what} on 'Typed' was not imported: Operaton reads it in ` + + 'parseExternalServiceTask alone, which only a thrown message bound ' + + 'with operaton:type="external" reaches, so the event runs as written ' + + 'without it.'; + + const FIELD_HOME = + 'this tool carries an injected field on the step or the listener ' + + 'whose class or delegate binding receives it, on the step whose ' + + 'built-in mail or shell behaviour does, and on no other position'; + + // Revert: take the extras off `CONSUMED_EXTENSION_ATTRS`/`_ELEMENTS` + // for the definition and the event, and every row gains a generic + // unread-setting warning beside its own. + it.each([ + { + case: 'a result variable beside an external type is dropped: the type branch never writes it', + definition: implementedMessageDef( + 'operaton:type="external" operaton:topic="send-ack" operaton:resultVariable="r"', + ), + binding: externalBinding('send-ack'), + warnings: [ + neverWritten('resultVariable', 'an operaton:type="external" binding'), + ], + }, + { + case: 'a result variable on a definition naming no implementation is dropped: nothing runs to write it', + definition: implementedMessageDef('operaton:resultVariableName="r"'), + binding: undefined, + warnings: [ + neverWritten( + 'resultVariableName', + 'a definition naming no implementation', + ), + ], + }, + { + case: 'a field beside a class is dropped, naming the definition the engine reads it off', + definition: messageDefWith( + 'operaton:class="com.example.Send"', + ' ', + ), + binding: classBinding('com.example.Send'), + warnings: [ + "The injected field 'to' on the message definition of 'Typed' " + + `was not imported: ${FIELD_HOME}; BpmnParse.parseServiceTaskLike ` + + 'reads it off the definition into the class it names, so the ' + + 'document written back runs that without it.', + ], + }, + { + case: 'a field beside an expression is dropped: the engine injects into a class or a delegate alone', + definition: messageDefWith( + 'operaton:expression="${sender.send()}"', + ' ', + ), + binding: exprBinding('${sender.send()}'), + warnings: [ + "The injected field 'to' on the message definition of 'Typed' " + + `was not imported: ${FIELD_HOME}, and Operaton injects a field ` + + 'into a class or a delegate binding and into no other.', + ], + }, + { + case: 'a task priority on the definition and a property list on the event beside an external type: two engine-read drops and nothing else', + definition: + EVENT_PROPERTIES + + implementedMessageDef( + 'operaton:type="external" operaton:topic="send-ack" operaton:taskPriority="7"', + ), + binding: externalBinding('send-ack'), + warnings: [ + readOffThrow("'taskPriority' setting", 'the message definition'), + readOffThrow('operaton:properties block', 'the event element'), + ], + }, + { + case: 'the three extras beside a class are each a setting the engine never reads there', + roots: MESSAGE_ROOT + ' \n', + definition: + EVENT_PROPERTIES + + messageDefWith( + 'operaton:class="com.example.Send" operaton:taskPriority="7"', + ' ', + ), + binding: classBinding('com.example.Send'), + warnings: [ + neverReadOnThrow("'taskPriority' setting"), + neverReadOnThrow("operaton:errorEventDefinition 'Map_1'"), + neverReadOnThrow('operaton:properties block'), + ], + }, + { + case: 'a property list on a signal end is never read either', + roots: SIGNAL_ROOT, + definition: + EVENT_PROPERTIES + + '', + binding: undefined, + warnings: [neverReadOnThrow('operaton:properties block')], + }, + ])( + '$case', + async ({ + roots = MESSAGE_ROOT, + definition, + binding, + warnings: expected, + }) => { + const { node, warnings } = await importById( + endTriggerXml(definition, { roots, defs: operatonDefs }), + 'Typed', + 'endEvent', + ); + expect(node.binding).toEqual(binding); + expect(warnings.map((w) => w.message)).toEqual(expected); + }, + ); + + it('a task priority the engine refuses beside an external type refuses here too, quoting parsePriority', async () => { + const e = await expectRefusal( + xmlToIr( + endTriggerXml( + implementedMessageDef( + 'operaton:type="external" operaton:topic="send-ack" operaton:taskPriority="abc"', + ), + { roots: MESSAGE_ROOT, defs: operatonDefs }, + ), + ), + UnsupportedExtensionFormError, + priorityRefusal('taskPriority', 'abc'), + ); + expect(e.elementId).toBe('Typed'); + }); + + it('the extras on a caught message warn as throw-only settings, one each', async () => { + const { warnings } = await xmlToIr( + startTriggerXml( + messageDefWith( + 'operaton:taskPriority="7" operaton:resultVariable="r"', + ' \n' + + ' ', + ), + { + roots: + MESSAGE_ROOT + ' \n', + defs: operatonDefs, + }, + ), + ); + const throwOnly = (what: string): string => + `The ${what} on 'TStart' only takes effect on a throw ('throw ` + + "message' or 'emit message'); it has no effect on a catch and was " + + 'not imported.'; + expect(warnings.map((w) => w.message)).toEqual([ + throwOnly("'taskPriority' setting"), + throwOnly("'resultVariable' setting"), + throwOnly("operaton:field 'to'"), + throwOnly("operaton:errorEventDefinition 'Map_1'"), + ]); }); }); - describe('a name that starts with an expression refuses everywhere', () => { + describe('a name opening with an expression imports as written and prints as a raw template', () => { const EXPR_MESSAGE_ROOT = ' \n'; const EXPR_SIGNAL_ROOT = ' \n'; const SIGNAL_DEF = ''; + const EXPR_SIGNAL: EventDefinition = { + kind: 'signal', + signalName: '${topic}', + }; - /** The whole refusal an expression-leading message name draws. */ - const MESSAGE_EXPR_DETAIL = - 'a message name that starts with an expression ("${orderType}") ' + - 'cannot be written back: this tool writes the name in quotes, and a ' + - 'quoted name opening with "${" reads as an expression rather than as ' + - "a name; give the message a fixed name, which the process's own start " + - 'needs in any case; anywhere else the same expression reads back ' + - 'written as "#{...}"'; - - /** The same for a signal, whose remedy has no process-start exception. */ - const SIGNAL_EXPR_DETAIL = - 'a signal name that starts with an expression ("${topic}") cannot be ' + - 'written back: this tool writes the name in quotes, and a quoted ' + - 'name opening with "${" reads as an expression rather than as a name; ' + - 'give the signal a fixed name, or write the same expression as ' + - '"#{...}"'; - + // `BpmnParse.parseMessages` evaluates every message name through + // `createExpression`, so the shape deploys; only a process start refuses + // it, since nothing has started when the engine would evaluate it. it.each([ [ 'a message end', endTriggerXml(MESSAGE_DEF, { roots: EXPR_MESSAGE_ROOT }), 'Typed', - MESSAGE_EXPR_DETAIL, + messageDef('${orderType}'), ], [ 'a message emit', emitXml(MESSAGE_DEF, { roots: EXPR_MESSAGE_ROOT }), 'Emit1', - MESSAGE_EXPR_DETAIL, + messageDef('${orderType}'), ], [ 'a signal end', endTriggerXml(SIGNAL_DEF, { roots: EXPR_SIGNAL_ROOT }), 'Typed', - SIGNAL_EXPR_DETAIL, - ], - [ - 'a message start', - startTriggerXml(MESSAGE_DEF, { roots: EXPR_MESSAGE_ROOT }), - 'TStart', - MESSAGE_EXPR_DETAIL, + EXPR_SIGNAL, ], [ 'a message handler start', handlerDoc(MESSAGE_DEF, { roots: EXPR_MESSAGE_ROOT, body: '' }), 'HStart', - MESSAGE_EXPR_DETAIL, + messageDef('${orderType}'), ], - // The signal remedy is offered unqualified even at the start position: - // a signal start does take an expression name, a message start does not. [ 'a signal start', startTriggerXml(SIGNAL_DEF, { roots: EXPR_SIGNAL_ROOT }), 'TStart', - SIGNAL_EXPR_DETAIL, + EXPR_SIGNAL, ], - ])( - '%s refuses: the name would not read back as a name', - async (_case, xml, elementId, detail) => { - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedEventFeatureError, - detail, - ); - expect(e.elementId).toBe(elementId); - }, - ); - - it('a message handler start named with the "#{...}" spelling imports: the remedy applies there', async () => { - const { ir, warnings } = await xmlToIr( - handlerDoc(MESSAGE_DEF, { + [ + 'a message end named with the "#{...}" spelling', + endTriggerXml(MESSAGE_DEF, { roots: ' \n', }), - ); - expect(warnings).toEqual([]); - const start = byId(subProcess(ir, 'Handler'), 'HStart'); - expect(start.kind === 'startEvent' && start.eventDefinition).toEqual( + 'Typed', messageDef('#{orderType}'), - ); - }); - - it('a signal start named with the "#{...}" spelling imports', async () => { - const { node } = await importById( - startTriggerXml(SIGNAL_DEF, { - roots: ' \n', + ], + [ + 'a message end named with an expression after a fixed prefix', + endTriggerXml(MESSAGE_DEF, { + roots: + ' \n', }), - 'TStart', - 'startEvent', - ); - expect(node.eventDefinition).toEqual({ - kind: 'signal', - signalName: '#{topic}', - }); - }); + 'Typed', + messageDef('Order-${orderType}'), + ], + ] as const)( + '%s imports the name as written, with no warning', + async (_case, xml, elementId, definition) => { + const { ir, warnings } = await xmlToIr(xml); + expect(warnings).toEqual([]); + const container = + elementId === 'HStart' ? subProcess(ir, 'Handler') : ir; + const node = byId(container, elementId); + expect('eventDefinition' in node && node.eventDefinition).toEqual( + definition, + ); + }, + ); - it.each(['Order-${orderType}', '#{orderType}'])( - 'a message end named %s imports: it reads back as the name it is', + it.each(['${orderType}', '#{orderType}'])( + 'a message start named %s keeps its refusal: the engine rejects an expression there', async (name) => { - const { node, warnings } = await importById( - endTriggerXml(MESSAGE_DEF, { - roots: ` \n`, - }), - 'Typed', - 'endEvent', + const e = await expectRefusal( + xmlToIr( + startTriggerXml(MESSAGE_DEF, { + roots: ` \n`, + }), + ), + UnsupportedEventFeatureError, + `a message start event's message name "${name}" is an expression; ` + + 'Operaton rejects an expression there, because a process that ' + + 'has not started yet has no variables to evaluate it against', ); - expect(node.eventDefinition).toEqual(messageDef(name)); - expect(warnings).toEqual([]); + expect(e.elementId).toBe('TStart'); }, ); }); @@ -1263,9 +2128,14 @@ describe('xmlToIr: imports a repetition', () => { const { node, warnings } = await importOnly( oneNodeDoc(tag, { attrs, - children: repeat( - 'operaton:collection="lines" operaton:elementVariable="line"', - ), + // A script task needs a body of its own and a block its own start + // to import at all; every other repeatable tag ignores the extra child. + children: + (tag === 'scriptTask' ? 'x = 1;' : '') + + repeat( + 'operaton:collection="lines" operaton:elementVariable="line"', + ) + + (tag === 'subProcess' ? '' : ''), }), kind, ); @@ -1363,23 +2233,112 @@ describe('xmlToIr: imports a repetition', () => { expect(node.loop).toEqual({ collection: 'lines' }); }); - it('a bpmn:loopDataInputRef shadows operaton:collection, and the drop is reported', async () => { - const { node, warnings } = await importById( - dataDoc( - 'operaton:collection="items"', - 'lines', - ), - 'T', - 'userTask', - ); - expect(node.loop).toEqual({ collection: 'lines' }); - expect(warnings.map((w) => w.message)).toContainEqual( - expect.stringContaining( - "Both bpmn:loopDataInputRef and operaton:collection name the collection on 'T'; " + - "Operaton reads bpmn:loopDataInputRef second, so 'lines' was imported and 'items' was dropped.", - ), - ); - }); + const SECOND_WINS = + 'parseMultiInstanceLoopCharacteristics writes both into the same field ' + + 'and bpmn:loopDataInputRef second'; + const EXPRESSION_WINS = + 'parseMultiInstanceLoopCharacteristics stores an expression (a value ' + + 'containing "{") and a variable name in two fields, and ' + + 'MultiInstanceActivityBehavior.resolveNrOfInstances reads the expression ' + + 'field first'; + + it.each([ + ['two variable names', 'items', 'lines', 'lines', 'items', SECOND_WINS], + [ + 'two expressions', + '${items}', + '${lines}', + '${lines}', + '${items}', + SECOND_WINS, + ], + [ + 'an expression beside a variable name', + '${items}', + 'lines', + '${items}', + 'lines', + EXPRESSION_WINS, + ], + [ + 'a variable name beside an expression', + 'items', + '${lines}', + '${lines}', + 'items', + EXPRESSION_WINS, + ], + ])( + 'operaton:collection and bpmn:loopDataInputRef spelling %s import the one the engine iterates, and the drop is reported with the rule', + async (_title, setting, referenced, kept, dropped, rule) => { + const { node, warnings } = await importOnly( + repeatedTaskDoc( + `operaton:collection="${setting}"`, + `${referenced}`, + ), + 'userTask', + ); + expect(node.loop).toEqual({ collection: kept }); + expect(warnings).toEqual([ + { + elementId: 'T', + category: 'extensionAttribute', + message: + 'Both operaton:collection and bpmn:loopDataInputRef name the ' + + `collection on 'T'; ${rule}, so '${kept}' was imported and ` + + `'${dropped}' was dropped.`, + }, + ]); + }, + ); + + const BARE_COMPLETION_WARNING = { + elementId: 'T', + category: 'unmappedConstruct', + message: + "The bpmn:completionCondition on 'T' is the bare text " + + '"nrOfCompletedInstances > 1" with no "${...}" or "#{...}" opener: ' + + 'parseMultiInstanceLoopCharacteristics hands it to createExpression as ' + + 'a literal and MultiInstanceActivityBehavior.completionConditionSatisfied ' + + 'throws expressionNotBooleanException when the first run completes; ' + + 'the script writes it inside "${...}", which evaluates it.', + }; + + it.each([ + ['a bare bpmn:completionCondition', '', [BARE_COMPLETION_WARNING]], + [ + 'a bare bpmn:completionCondition carrying a language', + 'language="groovy"', + [ + BARE_COMPLETION_WARNING, + { + elementId: 'T', + category: 'unmappedConstruct', + message: + 'The language="groovy" on the bpmn:completionCondition of \'T\' ' + + 'was not imported: parseMultiInstanceLoopCharacteristics hands ' + + 'the text alone to createExpression and reads no language, so ' + + 'the imported step runs the same.', + }, + ], + ], + ])( + '%s imports the text and warns about each thing the engine does with it', + async (_title, attrs, expected) => { + const { node, warnings } = await importOnly( + repeatedTaskDoc( + 'operaton:collection="lines"', + `nrOfCompletedInstances > 1`, + ), + 'userTask', + ); + expect(node.loop).toEqual({ + collection: 'lines', + completionCondition: 'nrOfCompletedInstances > 1', + }); + expect(warnings).toEqual(expected); + }, + ); // Every shape the grammar's ID terminal takes, the hyphen form included: the // refusal beside it must not narrow what the clause can already write. @@ -1426,7 +2385,8 @@ describe('xmlToIr: imports a repetition', () => { category: 'extensionAttribute', message: "Both bpmn:inputDataItem and operaton:elementVariable name what each run sees on 'T'; " + - "Operaton reads bpmn:inputDataItem second, so 'line' was imported and 'item' was dropped.", + 'parseMultiInstanceLoopCharacteristics writes both into the same field and bpmn:inputDataItem second, ' + + "so 'line' was imported and 'item' was dropped.", }); }); @@ -1506,7 +2466,7 @@ describe('xmlToIr: imports a repetition', () => { oneNodeDoc('userTask', { children: `${IO_BLOCK}${repeat('operaton:collection="lines"')}`, }), - "it maps an 'operaton:outputParameter', which Operaton refuses to deploy on a repeated step", + 'it maps an \'operaton:outputParameter\', which BpmnParse.checkActivityOutputParameterSupported fails the deployment on ("operaton:outputParameter not allowed for multi-instance constructs")', ], ])('%s is refused', async (_title, xml, detail) => { const e = await expectRefusal( @@ -1578,10 +2538,26 @@ describe('xmlToIr: imports a repetition', () => { }, { title: - 'a job priority on the loop element draws the one ignored-content warning and reaches no field', + 'the older async spelling on the loop element carries asyncBefore and names the respelling', prefix: 'operaton' as const, - loopFirst: true, - loopAttrs: 'operaton:collection="lines" operaton:jobPriority="#{high}"', + loopFirst: false, + loopAttrs: 'operaton:collection="lines" operaton:async="true"', + loopChildren: '', + loop: { collection: 'lines', asyncBefore: true }, + warnings: [ + { + elementId: 'T', + category: 'unmappedConstruct', + message: asyncRespelling("the repetition of 'T'"), + }, + ], + }, + { + title: + 'a job priority on the loop element draws the one ignored-content warning and reaches no field', + prefix: 'operaton' as const, + loopFirst: true, + loopAttrs: 'operaton:collection="lines" operaton:jobPriority="#{high}"', loopChildren: '', loop: { collection: 'lines' }, warnings: [ @@ -1615,7 +2591,10 @@ describe('xmlToIr: imports a repetition', () => { ...STEP_SETTINGS, loop, }); - expect(imported.warnings).toEqual(warnings); + // `doc: dualDoc` above declares the camunda namespace regardless of + // which prefix the row itself writes, so every row draws the alias + // warning too. + expect(imported.warnings).toEqual([...warnings, CAMUNDA_ALIAS_WARNING]); }, ); @@ -1754,16 +2733,29 @@ describe('xmlToIr: imports a repetition', () => { ); }); -describe('xmlToIr: a #{...} expression body is rewrapped, and says so', () => { +describe('xmlToIr: a #{...} body the printer spells bare is rebuilt inside ${...}, and says so', () => { const loopDoc = (children: string): string => oneNodeDoc('userTask', { children: `${children}`, }); const rewrapped = (slot: string, id: string): string => - `The ${slot} on '${id}' is written with "#{...}", which this surface has ` + - 'no form for: its text is written back inside "${...}", which Operaton ' + - 'evaluates identically.'; + `The ${slot} on '${id}' is written with "#{...}"; the script prints its ` + + 'body as bare DSL and the rebuilt document writes it inside "${...}", ' + + 'which Operaton evaluates identically.'; + + const externalTaskErrorDoc = operatonDefs` + + + + + + + + + + + `; it.each([ [ @@ -1800,22 +2792,49 @@ describe('xmlToIr: a #{...} expression body is rewrapped, and says so', () => { }), rewrapped('bpmn:condition', 'T'), ], + [ + 'an external task error mapping', + externalTaskErrorDoc, + rewrapped('operaton:errorEventDefinition expression', 'Charge'), + ], + ] as const)('%s reports the rewrap', async (_title, xml, message) => { + const { warnings } = await xmlToIr(xml); + expect(warnings.map((w) => w.message)).toEqual([message]); + expect(warnings.map((w) => w.category)).toEqual(['unmappedConstruct']); + }); + + // An out-of-subset body prints as the quoted raw template with its opener, + // and a `#{` later in the body leaves the parse outside the subset, so the + // raw path returns both character for character. + it.each(['${lineCount}', '${a} #{b}', '#{bean.count()}'])( + 'a cardinality the printer keeps as written imports verbatim and reports nothing: %s', + async (body) => { + const { node, warnings } = await importOnly( + loopDoc(`${body}`), + 'userTask', + ); + expect(node.loop).toEqual({ collection: 'lines', cardinality: body }); + expect(warnings).toEqual([]); + }, + ); + + it.each([ [ 'a job priority', oneNodeDoc('userTask', { attrs: 'operaton:jobPriority="#{high}"' }), - rewrapped("'jobPriority' setting", 'T'), + { jobPriority: '#{high}' }, ], [ 'a job priority on a gateway', oneNodeDoc('exclusiveGateway', { attrs: 'operaton:jobPriority="#{high}"', }), - rewrapped("'jobPriority' setting", 'T'), + { jobPriority: '#{high}' }, ], [ 'a user task priority', oneNodeDoc('userTask', { attrs: 'operaton:priority="#{high}"' }), - rewrapped("'priority' setting", 'T'), + { priority: '#{high}' }, ], [ 'a call activity version binding', @@ -1824,26 +2843,14 @@ describe('xmlToIr: a #{...} expression body is rewrapped, and says so', () => { 'calledElement="sub" operaton:calledElementBinding="version" ' + 'operaton:calledElementVersion="#{v}"', }), - rewrapped('calledElementVersion', 'T'), + { binding: { kind: 'version', version: '#{v}' } }, ], - ] as const)('%s reports the rewrap', async (_title, xml, message) => { - const { warnings } = await xmlToIr(xml); - expect(warnings.map((w) => w.message)).toEqual([message]); - expect(warnings.map((w) => w.category)).toEqual(['unmappedConstruct']); - }); - - // Only a leading `#{` is rewritten: a `#{` later in the body leaves the - // parse outside the subset, and the raw path returns it character for - // character. Widening the test to `contains` reports a body nothing changed. - it.each(['${lineCount}', '${a} #{b}'])( - 'a body opening with ${ imports verbatim and reports nothing: %s', - async (body) => { - const { node, warnings } = await importOnly( - loopDoc(`${body}`), - 'userTask', - ); - expect(node.loop).toEqual({ collection: 'lines', cardinality: body }); + ] as const)( + '%s opening with #{ is carried as written and reports nothing', + async (_title, xml, carried) => { + const { ir, warnings } = await xmlToIr(xml); expect(warnings).toEqual([]); + expect(byId(ir, 'T')).toMatchObject(carried); }, ); }); @@ -1853,18 +2860,22 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even const flowDoc = ( conditionAttrs: string, doc: XmlTag = operatonDoc, - body = 'amount > 1000', + body = '${amount > 1000}', + flowAttrs = '', + plainFlowAttrs = '', ): string => doc` - + ${body} - + `; + const conditionOf = (ir: BpmnProcess): string | undefined => + ir.sequenceFlows.find((f) => f.id === 'F2')?.conditionExpression; it.each([ [ @@ -1890,6 +2901,15 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even 'operaton:resource="deployment://check.groovy", which Operaton ' + 'runs as that deployed script rather than the body written here', ], + [ + 'an unprefixed xsi:type dropped by moddle still reads language and resource, since parseConditionExpression resolves it against BPMN20_NS regardless', + 'xsi:type="tFormalExpression" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ' + + 'language="groovy" operaton:resource="deployment://check.groovy"', + operatonDoc, + 'it declares language="groovy" with ' + + 'operaton:resource="deployment://check.groovy", which Operaton ' + + 'runs as that deployed script rather than the body written here', + ], ] as const)('%s', async (_title, attrs, doc, detail) => { await expectRefusal( xmlToIr(flowDoc(attrs, doc)), @@ -1913,8 +2933,7 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even 'a lone %s imports the inline expression and reports the dropped resource', async (_prefix, attrs, doc) => { const { ir, warnings } = await xmlToIr(flowDoc(attrs, doc)); - const flow = ir.sequenceFlows.find((f) => f.id === 'F2'); - expect(flow?.conditionExpression).toBe('amount > 1000'); + expect(conditionOf(ir)).toBe('${amount > 1000}'); expect(warnings).toEqual([ { elementId: 'F2', @@ -1925,10 +2944,188 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even 'runs as the expression written in the body, and the ' + 'attribute was not imported.', }, + ...(doc === camundaDoc ? [CAMUNDA_ALIAS_WARNING] : []), + ]); + }, + ); + + // Revert: drop the raw-text read of an unprefixed type, and the second row + // imports the flow with no condition under a residual drop attributed to + // the process. + it.each([ + ['xsi:type="bpmn:tExpression"', 'bpmn:tExpression'], + [ + 'an unprefixed xsi:type="tExpression" in a bpmn:-prefixed document, which moddle drops before any reader sees it', + 'tExpression', + ], + ])( + '%s refuses with the sentence parseConditionExpression fails the deployment with', + async (_title, xsiType) => { + const e = await expectRefusal( + xmlToIr( + flowDoc( + `xsi:type="${xsiType}" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`, + ), + ), + UnsupportedConditionExpressionError, + `it is typed xsi:type="${xsiType}", which ` + + 'BpmnParse.parseConditionExpression fails the deployment on ' + + '("Invalid type, only tFormalExpression is currently supported")', + ); + expect(e.elementId).toBe('F2'); + }, + ); + + it.each([ + ['no xsi:type at all', flowDoc('')], + [ + 'an unprefixed xsi:type="tFormalExpression" in a bpmn:-prefixed document, which moddle drops and parseConditionExpression resolves against BPMN20_NS', + flowDoc( + 'xsi:type="tFormalExpression" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"', + ), + ], + [ + 'an unprefixed xsi:type="tFormalExpression" in a default-namespace document', + ` + + + + + + + + + \${amount > 1000} + + + + +`, + ], + ])( + 'a condition with %s imports as the formal expression the engine reads it as', + async (_title, xml) => { + const { ir, warnings } = await xmlToIr(xml); + expect(conditionOf(ir)).toBe('${amount > 1000}'); + expect(warnings).toEqual([]); + }, + ); + + // Revert: drop the `.trim()` on the recovered body, and this imports + // ' ' as the condition instead of matching moddle's own empty-body path. + it('a whitespace-only recovered body imports as no condition, as moddle imports one', async () => { + const { ir, warnings } = await xmlToIr( + flowDoc( + 'xsi:type="tFormalExpression" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"', + operatonDoc, + ' ', + ), + ); + expect(conditionOf(ir)).toBeUndefined(); + expect(warnings).toEqual([ + { + elementId: 'F2', + category: 'unmappedConstruct', + message: + "The condition on 'F2' has an empty body: " + + 'UelExpressionCondition.evaluate reads it as a string and fails ' + + 'the flow on every run ("condition expression returns ' + + 'non-Boolean"); the flow was imported with no condition.', + }, + ]); + }); + + it.each([ + [ + 'a body with no "${" or "#{" opener', + 'amount > 1000', + 'amount > 1000', + 'The condition on \'F2\' is the bare text "amount > 1000" with no ' + + '"${...}" or "#{...}" opener: UelExpressionCondition.evaluate reads ' + + 'it as a string and fails the flow on every run ("condition ' + + 'expression returns non-Boolean"); the script writes it inside ' + + '"${...}", which evaluates it.', + ], + [ + 'an empty body', + '', + undefined, + "The condition on 'F2' has an empty body: " + + 'UelExpressionCondition.evaluate reads it as a string and fails the ' + + 'flow on every run ("condition expression returns non-Boolean"); ' + + 'the flow was imported with no condition.', + ], + ])( + 'a condition with %s warns that the engine fails the flow on every run', + async (_title, body, imported, message) => { + const { ir, warnings } = await xmlToIr( + flowDoc( + 'xsi:type="bpmn:tFormalExpression" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"', + operatonDoc, + body, + ), + ); + expect(conditionOf(ir)).toBe(imported); + expect(warnings).toEqual([ + { elementId: 'F2', category: 'unmappedConstruct', message }, ]); }, ); + it.each([ + [ + 'a conditioned flow named other than its condition text', + 'name="yes"', + '', + [ + { + elementId: 'F2', + category: 'label', + message: + 'The name "yes" on the flow \'F2\' was not imported: the script ' + + 'has no label for a flow, and the rebuilt document names this ' + + 'one by its condition ("amount > 1000").', + }, + ], + ], + [ + 'an unconditioned flow named at all', + '', + 'name="no"', + [ + { + elementId: 'F3', + category: 'label', + message: + 'The name "no" on the flow \'F3\' was not imported: the script ' + + 'has no label for a flow, and the rebuilt document leaves this ' + + 'one unnamed.', + }, + ], + ], + [ + 'a conditioned flow named exactly its condition text', + 'name="amount > 1000"', + '', + [], + ], + ['an unconditioned flow named the empty string', '', 'name=""', []], + ])( + '%s reports the label only when the rebuilt document would lose it', + async (_title, flowAttrs, plainFlowAttrs, expected) => { + const { warnings } = await xmlToIr( + flowDoc( + '', + operatonDoc, + '${amount > 1000}', + flowAttrs, + plainFlowAttrs, + ), + ); + expect(warnings).toEqual(expected); + }, + ); + // Operaton's parseConditionExpression is the shared helper behind both a // sequence flow's conditionExpression and a conditional event definition's // condition, so the same language/resource handling applies at every @@ -2004,27 +3201,6 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even ); }); -describe('xmlToIr: refuses collaborations (pools / message flows)', () => { - const collaborationXml = bpmnDefs` - - - - - - - - `; - - it('a document containing a bpmn:Collaboration refuses, as an UnsupportedConstructError', async () => { - const err = await expectRefusal( - xmlToIr(collaborationXml), - UnsupportedCollaborationError, - ); - expect(err).toBeInstanceOf(UnsupportedConstructError); - expect(err.message.length).toBeGreaterThan(0); - }); -}); - describe('xmlToIr: warns for dropped extension attributes', () => { it.each([ ['operaton', operatonDoc], @@ -2043,11 +3219,14 @@ describe('xmlToIr: warns for dropped extension attributes', () => { 'userTask', ); expect(node.assignee).toBe('alice'); - expectOneWarning(warnings, { + expectOneWarning(extensionWarnings(warnings), { elementId: 'FormHandlerTask', category: 'extensionAttribute', message: 'formHandlerClass', }); + expect(unmappedWarnings(warnings)).toEqual( + prefix === 'camunda' ? [CAMUNDA_ALIAS_WARNING] : [], + ); }, ); @@ -2119,94 +3298,289 @@ describe('xmlToIr: warns for dropped extension attributes', () => { expect([read(ir), warnings]).toEqual([expected, []]); }); - // `CONSUMED_EXTENSION_ATTRS` is keyed by `$type`, so declaring `initiator` - // read on `bpmn:StartEvent` silences the unread-attribute sweep for a - // handler's start as much as for the process's own. Only this test stands - // between a handler start's value and being dropped without a word. - it('an event handler start keeps its initiator too', async () => { - const { ir, warnings } = await xmlToIr( - handlerDoc('', { - startAttrs: 'operaton:initiator="claimant"', - roots: ' \n', - defs: operatonDefs, - }), - ); - const handlerStart = only(subProcess(ir, 'Handler'), 'startEvent'); - expect([handlerStart.initiator, warnings]).toEqual(['claimant', []]); - }); - - // A start the modeler left unnamed carries the id this tool mints for one it - // synthesizes, which a script cannot repeat, so the whole statement is left - // out and the initiator with it. The unread-attribute sweep no longer covers - // it, so this report is all that stands between the value and a silent drop. - it.each([ - [ - "a process's own start", - headerDoc('', 'operaton:initiator="claimant"', 'StartEvent_1'), - ], - [ - 'an event handler start, whose trigger prints in the header instead', - operatonDefs` - + /** `S -> E` under a process carrying the given attributes and extension children. */ + const starterDoc = (processAttrs: string, children: string): string => + operatonDefs` + +${children} + - - - - - - `, - ], + `; + const potentialStarter = (expression?: string): string => + ` ${ + expression === undefined + ? '' + : ` + + ${expression} + ` + } + `; + + // Revert: drop the potential-starter read from mapProcess, and the first + // row keeps the attributes' entries alone under a residual-drop warning. + it.each([ + { + title: + 'an operaton:potentialStarter lands on the two header settings ahead of the attributes, as parseStartAuthorization builds its lists', + processAttrs: + 'operaton:candidateStarterUsers="demo" operaton:candidateStarterGroups="ops"', + children: potentialStarter('user(a), user(b), group(g), h'), + header: { + candidateStarterUsers: 'a,b,demo', + candidateStarterGroups: 'g,h,ops', + }, + warnings: [ + [ + 'unmappedConstruct', + 'p', + "The operaton:potentialStarter on 'p' imports as " + + 'candidateStarterUsers: "a,b" and candidateStarterGroups: "g,h": ' + + 'Operaton reads its formal expression that way ' + + '(BpmnParse.parsePotentialStarterResourceAssignment), and this ' + + 'tool writes it back as operaton:candidateStarterUsers and ' + + 'operaton:candidateStarterGroups, which the engine reads the same.', + ], + ], + }, + { + title: + 'an operaton:potentialStarter with no formal expression is dropped, saying the engine reads nothing else', + processAttrs: '', + children: potentialStarter(), + header: {}, + warnings: [ + [ + 'unmappedConstruct', + 'p', + "The operaton:potentialStarter on 'p' was not imported: it carries " + + 'no formal expression, and Operaton reads nothing else off it ' + + '(BpmnParse.parsePotentialStarterResourceAssignment).', + ], + ], + }, ])( - '%s printing no statement says its initiator went with it', - async (_title, xml) => { - const { warnings } = await xmlToIr(xml); - expect(warnings).toEqual([ - { - elementId: 'StartEvent_1', - category: 'extensionAttribute', - message: - "The 'operaton:initiator' setting on 'StartEvent_1' was not " + - "written to the script: 'StartEvent_1' is the kind of name this " + - 'tool generates for itself, which a script cannot repeat, so this ' + - 'start is left out entirely and its initiator with it. Rename it ' + - 'in the diagram to keep the initiator.', - }, + '$title', + async ({ processAttrs, children, header, warnings: expected }) => { + const { ir, warnings } = await xmlToIr( + starterDoc(processAttrs, children), + ); + expect([ir.candidateStarterUsers, ir.candidateStarterGroups]).toEqual([ + header.candidateStarterUsers, + header.candidateStarterGroups, ]); + expect(warnings.map((w) => [w.category, w.elementId, w.message])).toEqual( + expected, + ); }, ); -}); -describe('xmlToIr: warns for dropped lanes', () => { - const lanesXml = bpmnDoc` - - S - - - E - - - + // Revert: drop a row from ENGINE_READS_ELSEWHERE, and its sentence loses + // the clause naming the engine's reader; drop the retry-attribute arm from + // warnUnreadPrefixedAttrs, and that row reads as a setting nothing reads. + it.each([ + { + title: + "async and an operaton:in on a thrown signal's definition name parseSignalEventDefinition", + xml: rootedDoc( + ' \n', + ` + + + + + + - `; - - it('surfaces one lane warning per lane, naming the lane, and still imports the body', async () => { - const { ir, warnings } = await xmlToIr(lanesXml); - expect(ir.flowElements.map((fe) => fe.id)).toEqual(['S', 'E']); - expect(warnings.map((w) => [w.category, w.elementId])).toEqual([ - ['lane', 'Lane_Sales'], - ['lane', 'Lane_Support'], - ]); - expect(warnings[0].message).toContain('Sales'); - }); - - it('names a lane nested in a childLaneSet as well as the lane holding it', async () => { - const xml = bpmnDoc` - - - - + + `, + operatonDefs, + ), + warnings: [ + unread( + "'operaton:async' setting", + 'Throw', + 'a ', + 'reads it on a thrown signal as its async delivery ' + + '(BpmnParse.parseSignalEventDefinition)', + ), + unread( + "operaton:in 'a'", + 'Throw', + 'a ', + 'reads it on a thrown signal as its payload ' + + '(BpmnParse.parseSignalEventDefinition through parseInputParameter)', + ), + ], + }, + { + title: + "a form key on the process's own start names parseStartFormHandlers", + xml: headerDoc('', 'operaton:formKey="embedded:app:forms/start.html"'), + warnings: [ + unread( + "'operaton:formKey' setting", + 'S', + 'a ', + "reads it on the process's own start (BpmnParse.parseStartFormHandlers)", + ), + ], + }, + { + title: + "an operaton:in on a caught signal's definition reads nowhere, since parseSignalEventDefinition only reads it on a throw", + xml: rootedDoc( + ' \n', + ` + + + + + + + + + `, + operatonDefs, + ), + warnings: [ + unread("operaton:in 'a'", 'Wait', 'a '), + ], + }, + { + title: + "a form key on a nested start reads nowhere, since parseStartFormHandlers runs for the process's own start alone", + xml: operatonDoc` + + + + + + + + `, + warnings: [ + unread("'operaton:formKey' setting", 'Inner', 'a '), + ], + }, + { + title: + 'the job priority, task priority and tasklist flag on the process name parseProcess', + xml: headerDoc( + 'operaton:jobPriority="3" operaton:taskPriority="4" operaton:isStartableInTasklist="false"', + ), + warnings: [ + unread( + "'operaton:jobPriority' setting", + 'p', + 'a ', + 'reads it (BpmnParse.parseProcess through parsePriority)', + ), + unread( + "'operaton:taskPriority' setting", + 'p', + 'a ', + 'reads it (BpmnParse.parseProcess through parsePriority)', + ), + unread( + "'operaton:isStartableInTasklist' setting", + 'p', + 'a ', + 'reads it (BpmnParse.parseProcess through isStartable)', + ), + ], + }, + { + title: + 'a retry cycle written as an attribute says the engine reads the element form alone', + xml: oneNodeDoc('serviceTask', { + id: 'Work', + attrs: + 'operaton:class="com.example.W" operaton:failedJobRetryTimeCycle="R3/PT1M"', + }), + warnings: [ + { + elementId: 'Work', + category: 'extensionAttribute', + message: + "The 'operaton:failedJobRetryTimeCycle' setting on 'Work' was " + + 'not imported: Operaton reads a retry cycle as an ' + + ' element and never as an ' + + 'attribute (DefaultFailedJobParseListener.' + + 'setFailedJobRetryTimeCycleValue through ' + + 'BpmnParseUtil.findOperatonExtensionElement), so the document ' + + 'written back runs the same.', + }, + ], + }, + { + title: + 'a setting no engine method reads says only that nothing carries it', + xml: headerDoc('', 'operaton:mystery="1"'), + warnings: [ + unread("'operaton:mystery' setting", 'S', 'a '), + ], + }, + ])('$title', async ({ xml, warnings: expected }) => { + const { warnings } = await xmlToIr(xml); + expect(warnings).toEqual(expected); + }); + + // A start under the exact id this tool mints for its container is one a + // script cannot repeat, so the whole statement is left out and the + // initiator with it. The unread-attribute sweep no longer covers it, so this + // report is all that stands between the value and a silent drop. (A + // handler's start drops the initiator before ever reaching this point.) + it("a process's own start printing no statement says its initiator went with it", async () => { + const { warnings } = await xmlToIr( + headerDoc('', 'operaton:initiator="claimant"', 'StartEvent_p'), + ); + expect(warnings).toEqual([ + { + elementId: 'StartEvent_p', + category: 'extensionAttribute', + message: + "The 'operaton:initiator' setting on 'StartEvent_p' was not " + + "written to the script: 'StartEvent_p' is the kind of name this " + + 'tool generates for itself, which a script cannot repeat, so this ' + + 'start is left out entirely and its initiator with it. Rename it ' + + 'in the diagram to keep the initiator.', + }, + ]); + }); +}); + +describe('xmlToIr: warns for dropped lanes', () => { + const lanesXml = bpmnDoc` + + S + + + E + + + + + `; + + it('surfaces one lane warning per lane, naming the lane, and still imports the body', async () => { + const { ir, warnings } = await xmlToIr(lanesXml); + expect(ir.flowElements.map((fe) => fe.id)).toEqual(['S', 'E']); + expect(warnings.map((w) => [w.category, w.elementId])).toEqual([ + ['lane', 'Lane_Sales'], + ['lane', 'Lane_Support'], + ]); + expect(warnings[0].message).toContain('Sales'); + }); + + it('names a lane nested in a childLaneSet as well as the lane holding it', async () => { + const xml = bpmnDoc` + + + + @@ -2260,25 +3634,22 @@ describe('xmlToIr: an empty extensionElements is not flagged beside a real drop' }); }); -describe('xmlToIr: foreign-namespace extension elements are per-element', () => { - it('names a camunda: extension element against its owning task', async () => { +describe('xmlToIr: camunda: extension elements read exactly as operaton: ones', () => { + it('a camunda:field on a class-bound service task is injected, the same as operaton:field', async () => { const xml = oneNodeDoc('serviceTask', { id: 'CamSvc', - // The class keeps a supported form, so mapping does not refuse first. attrs: 'name="Cam Svc" camunda:class="com.example.Svc"', - // camunda:field, not camunda:connector: the element alias covers - // attributes only, and a connector now refuses rather than warns. children: extensionElements( ` `, ), doc: camundaDoc, }); - const { warnings } = await xmlToIr(xml); - expectOneWarning(warnings, { - elementId: 'CamSvc', - category: 'extensionAttribute', - message: 'Extra configuration (camunda:field)', + const { node, warnings } = await importOnly(xml, 'serviceTask'); + expect(node.binding).toEqual({ + ...classBinding('com.example.Svc'), + fields: [{ name: 'greeting', value: 'hello' }], }); + expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); }); }); @@ -2289,7 +3660,7 @@ describe('xmlToIr: undeclared operaton extension element residual', () => { - + @@ -2303,7 +3674,7 @@ describe('xmlToIr: undeclared operaton extension element residual', () => { // cannot tie to a specific step. expectOneWarning(extensionWarnings(warnings), { elementId: 'p', - message: /potentialStarter/i, + message: /formProperty/i, }); expect(warnings.some((w) => w.elementId === 'CleanTask')).toBe(false); }); @@ -2325,10 +3696,9 @@ describe('xmlToIr: undeclared operaton extension element residual', () => { `, ); - expectOneWarning(extensionWarnings(warnings), { - elementId: 'Message_1', - message: 'operaton:InputOutput', - }); + expect(extensionWarnings(warnings)).toEqual([ + unread('operaton:inputOutput', 'Message_1', 'a '), + ]); }); }); @@ -2469,22 +3839,22 @@ describe('xmlToIr: bpmn:documentation', () => { ], [ 'a start and an end whose ids this tool writes for itself carry it and report that no script can spell it back', - bpmnDoc` ${doc('Where it begins.')} - ${doc('Where it stops.')} - `, + bpmnDoc` ${doc('Where it begins.')} + ${doc('Where it stops.')} + `, [ - ['StartEvent_1', 'Where it begins.'], - ['EndEvent_1', 'Where it stops.'], + ['StartEvent_p', 'Where it begins.'], + ['EndEvent_p', 'Where it stops.'], ], [ [ 'documentation', - 'StartEvent_1', + 'StartEvent_p', 'this start is left out entirely and its documentation with it', ], [ 'documentation', - 'EndEvent_1', + 'EndEvent_p', 'Where the script can do without this end, it is left out and its documentation with it', ], ], @@ -2770,8 +4140,8 @@ describe('xmlToIr: warns for unmapped BPMN content', () => { ], [ 'reports content on bpmn:definitions itself against the process', - ' ', - { elementId: 'p', message: 'bpmn:import' }, + ' ', + { elementId: 'p', message: 'bpmn:extension' }, ], ])('%s', async (_title, root, expected) => { const xml = bpmnDefs`${root} @@ -2802,16 +4172,6 @@ describe('xmlToIr: warns for unmapped BPMN content', () => { expect(reported[0].message).toMatch(/deploy/i); }); - it('says nothing when the process omits isExecutable, which an engine reads as executable', async () => { - const xml = bpmnDefs` - - - - `; - - expect((await xmlToIr(xml)).warnings).toEqual([]); - }); - it('reports nothing for a process built only from mapped constructs', async () => { const xml = oneNodeDoc('userTask', { id: 'Review', @@ -2905,14 +4265,6 @@ describe("xmlToIr: a root of a kind this tool does not model reports its own chi '(this tool imports the executable flow and the engine settings on ' + 'its steps, and nothing declared or drawn beside it).'; - const KEPT_SETTINGS_NOTE = - '(this tool keeps the assignee, form, form reference, script, ' + - 'service-task binding, injected fields, result variable, version tag, ' + - "input/output mappings and listeners, an external task's priority, " + - 'properties and error mappings, and the async, retry, job-priority and ' + - 'task-assignment settings; a gateway carries the async, retry and ' + - 'job-priority settings and nothing else).'; - it.each([ [ 'a globalScriptTask root is reported once, and its own script child is not blamed on the process', @@ -2981,7 +4333,7 @@ ${GLOBAL_SCRIPT_TASK} { elementId: 'p', category: 'extensionAttribute', - message: `Extra engine-specific configuration (bpmn:name at line 5) was not imported; it could not be attributed to a specific step ${KEPT_SETTINGS_NOTE}`, + message: `Extra engine-specific configuration (bpmn:name at line 5) was not imported; it could not be attributed to a specific step.`, }, ], ], @@ -3010,7 +4362,7 @@ ${GLOBAL_SCRIPT_TASK} { elementId: 'p', category: 'extensionAttribute', - message: `Extra engine-specific configuration (bpmn:name at line 9) was not imported; it could not be attributed to a specific step ${KEPT_SETTINGS_NOTE}`, + message: `Extra engine-specific configuration (bpmn:name at line 9) was not imported; it could not be attributed to a specific step.`, }, ], ], @@ -3245,17 +4597,78 @@ describe('xmlToIr: callActivity import', () => { [ 'calledElementBinding="version" WITHOUT a version is refused', 'operaton:calledElementBinding="version"', + 'calledElementBinding="version" is set without a calledElementVersion, ' + + 'so the engine cannot resolve which version to use', ], [ - 'an unrecognized calledElementBinding value (e.g. versionTag) is refused', + 'calledElementBinding="versionTag" is refused as this surface\'s limit', 'operaton:calledElementBinding="versionTag"', + 'calledElementBinding="versionTag" pins a version tag, which this ' + + 'surface has no setting for', ], - ])('%s', async (_title, attrs) => { - await expect( + ])('%s', async (_title, attrs, detail) => { + await expectRefusal( xmlToIr(callXmlWithBindingAttrs(attrs)), - ).rejects.toBeInstanceOf(UnsupportedCallActivityError); + UnsupportedCallActivityError, + detail, + ); + }); + + // Revert: refuse the default branch of readVersionBinding again, and the + // row throws instead of importing. + it('a binding word parseBinding does not know imports as latest, naming the rewrite', async () => { + const { node, warnings } = await importCall( + 'operaton:calledElementBinding="bogus"', + ); + expect(node.binding).toEqual({ kind: 'latest' }); + expect(warnings).toEqual([ + { + elementId: 'CallSub', + category: 'unmappedConstruct', + message: + 'The calledElementBinding="bogus" on \'CallSub\' imports as ' + + 'binding: latest: BpmnParse.parseBinding sets no binding for that ' + + 'word and BaseCallableElement.isLatestBinding reads none as ' + + 'latest, and this tool writes it back as ' + + 'calledElementBinding="latest", which the engine reads the same.', + }, + CAMUNDA_ALIAS_WARNING, + ]); }); + it.each([ + [ + 'a caseRef and no calledElement', + 'operaton:caseRef="claims"', + 'it names operaton:caseRef="claims" and no calledElement, so ' + + 'BpmnParse.parseCallActivity runs a case through ' + + 'CaseCallActivityBehavior, which this surface has no form for', + ], + [ + 'neither a calledElement nor a caseRef', + '', + 'it names neither a calledElement nor an operaton:caseRef, which ' + + 'BpmnParse.parseCallActivity refuses to deploy ("Missing attribute ' + + "'calledElement' or 'caseRef'\")", + ], + [ + 'a calledElement beside a caseRef', + 'calledElement="sub-process" operaton:caseRef="claims"', + 'it names a calledElement beside operaton:caseRef="claims", which ' + + 'BpmnParse.parseCallActivity refuses to deploy ("The attributes ' + + "'calledElement' or 'caseRef' cannot be used together\")", + ], + ])( + 'a call activity with %s is refused, saying what it names', + async (_title, attrs, detail) => { + await expectRefusal( + xmlToIr(oneNodeDoc('callActivity', { id: 'CallSub', attrs })), + UnsupportedCallActivityError, + detail, + ); + }, + ); + it('a dangling calledElementVersion (binding absent) imports with NO binding and exactly one warning', async () => { const { node, warnings } = await importCall( 'operaton:calledElementVersion="#{v}"', @@ -3269,6 +4682,7 @@ describe('xmlToIr: callActivity import', () => { "The 'calledElementVersion' setting on 'CallSub' has no effect " + 'without calledElementBinding="version" and was not imported.', }, + CAMUNDA_ALIAS_WARNING, ]); }); @@ -3331,7 +4745,9 @@ describe('xmlToIr: callActivity import', () => { async (_title, attributes, expectedMapper, expectedWarnings) => { const { node, warnings } = await importCall(attributes); expect(node.mapper).toEqual(expectedMapper); - expect(warnings).toEqual(expectedWarnings); + // `importCall` always declares the camunda namespace (`dualDoc`), so + // every row draws the alias warning alongside its own. + expect(warnings).toEqual([...expectedWarnings, CAMUNDA_ALIAS_WARNING]); }, ); @@ -3339,11 +4755,6 @@ describe('xmlToIr: callActivity import', () => { callDoc('', extensionElements(extension)); it.each([ - [ - 'an operaton:in with both source and sourceExpression is refused, naming the shape', - '', - 'an operaton:in carries both source and sourceExpression', - ], [ 'an operaton:in with source but no target is refused, naming the shape', '', @@ -3354,36 +4765,21 @@ describe('xmlToIr: callActivity import', () => { '', 'an operaton:in carries variables="foo", which this tool cannot import (only variables="all" is supported)', ], - [ - 'a businessKey In combined with a target is refused, naming the shape', - '', - 'an operaton:in businessKey is combined with source/sourceExpression/target/variables/local', - ], - [ - 'two businessKey Ins are refused, naming the shape', - '', - 'more than one operaton:in businessKey is set', - ], [ 'an empty operaton:in with no recognized attribute is refused', '', 'an operaton:in carries none of the recognized shapes (source+target, sourceExpression+target, variables="all", or businessKey)', ], + [ + 'an operaton:in with an empty source is refused, quoting the strict validation', + '', + 'an operaton:in carries source="", which BpmnParse.parseCallableElementProvider refuses to deploy ("Empty attribute \'source\' when passing variables")', + ], [ 'an operaton:in with sourceExpression but no target is refused, naming the shape', '', 'an operaton:in carries sourceExpression without a target', ], - [ - 'an operaton:in with variables="all" combined with source/target is refused, naming the shape', - '', - 'an operaton:in carries variables="all" combined with source/sourceExpression/target', - ], - [ - 'a businessKey In combined with variables is refused, naming the shape', - '', - 'an operaton:in businessKey is combined with source/sourceExpression/target/variables/local', - ], [ 'an operaton:out with source but no target is refused, naming the out tag', '', @@ -3397,18 +4793,103 @@ describe('xmlToIr: callActivity import', () => { ); }); - it('a camunda:in on a call activity produces a drop warning (foreign-namespace element)', async () => { + /** The warning one ignored attribute draws on an `operaton:in` of `CallSub`. */ + const inIgnored = (attr: string, winner: string, why: string) => ({ + elementId: 'CallSub', + category: 'extensionAttribute', + message: + `The '${attr}' on an operaton:in of 'CallSub' has no effect alongside ` + + `${winner} and was not imported: ${why}.`, + }); + const PROVIDER_READS_ALL = + 'BpmnParse.parseCallableElementProvider passes every variable and reads nothing else'; + const KEY_ALONE = + 'BpmnParse.parseInputParameter reads the business key alone off that element'; + + // Revert: refuse each combination in readCallMappings again, and the row + // throws instead of importing the engine's pick. + it.each([ + { + title: 'source beside sourceExpression keeps source', + extension: + '', + node: { inMappings: [{ kind: 'variable', source: 'a', target: 'c' }] }, + warnings: [ + inIgnored( + 'sourceExpression', + 'source', + 'BpmnParse.parseCallableElementProvider reads source first', + ), + ], + }, + { + title: 'variables="all" beside source and target keeps all', + extension: '', + node: { inMappings: [{ kind: 'all' }] }, + warnings: [ + inIgnored('source', 'variables="all"', PROVIDER_READS_ALL), + inIgnored('target', 'variables="all"', PROVIDER_READS_ALL), + ], + }, + { + title: + 'a businessKey beside source, target, variables and local keeps the key', + extension: + '', + node: { businessKey: '${k}' }, + warnings: [ + inIgnored('source', 'businessKey', KEY_ALONE), + inIgnored('target', 'businessKey', KEY_ALONE), + inIgnored('variables', 'businessKey', KEY_ALONE), + inIgnored('local', 'businessKey', KEY_ALONE), + ], + }, + { + title: 'a second businessKey replaces the first', + extension: + '', + node: { businessKey: '${b}' }, + warnings: [ + { + elementId: 'CallSub', + category: 'extensionAttribute', + message: + 'The operaton:in businessKey="${a}" on \'CallSub\' has no effect ' + + 'alongside a later one and was not imported: ' + + 'BpmnParse.parseInputParameter hands each to ' + + 'setBusinessKeyValueProvider, and the last stands.', + }, + ], + }, + ])( + 'a mapping shape the engine deploys imports its pick and reports the rest: $title', + async ({ extension, node, warnings }) => { + const imported = await importOnly( + callXmlWithExtension(extension), + 'callActivity', + ); + expect(imported.node).toEqual({ + kind: 'callActivity', + id: 'CallSub', + calledElement: 'sub-process', + ...node, + }); + expect(imported.warnings).toEqual(warnings); + }, + ); + + it('a camunda:in on a call activity imports the mapping, exactly as operaton:in would', async () => { const xml = callDoc( '', extensionElements(' '), camundaDoc, ); - const { warnings } = await xmlToIr(xml); - const w = extensionWarnings(warnings).find( - (w) => w.elementId === 'CallSub', - ); - expect(w).toBeDefined(); + const { node, warnings } = await importOnly(xml, 'callActivity'); + expect(node.inMappings).toEqual([ + { kind: 'variable', source: 'a', target: 'b' }, + ]); + expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); }); it('a call activity inside a sub-process imports into the nested container', async () => { @@ -3442,6 +4923,8 @@ describe('xmlToIr: callActivity import', () => { expect(warnings).toEqual([]); }); + // Revert: read the name through readString, and the empty label reads as + // absent, so the rebuilt document derives one the author erased. it.each([ ['equals humanize(id) is dropped', 'Fulfil Order', undefined], [ @@ -3449,6 +4932,7 @@ describe('xmlToIr: callActivity import', () => { 'Send the order to fulfilment', 'Send the order to fulfilment', ], + ['is the empty string is kept as written', '', ''], ])('a call-activity label that %s', async (_title, written, kept) => { const { node } = await importOnly( oneNodeDoc('callActivity', { @@ -3531,7 +5015,7 @@ describe('xmlToIr: event layer import', () => { it('imports an interrupting error handler, an alongside escalation handler (camunda: binding alias), a typed end, and an emit, sharing their roots, into the exact expected IR (deep equality)', async () => { const { ir, warnings } = await xmlToIr(fullEventXml); expect(ir).toEqual(EXPECTED_EVENT_IR); - expect(warnings).toEqual([]); + expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); }); /** The trigger definition on the handler start of a `handlerDoc` fixture. */ @@ -3656,16 +5140,6 @@ describe('xmlToIr: event layer import', () => { `, ), ], - [ - 'a "none" intermediate throw (no event definition)', - rootedDoc( - '', - ` - - - `, - ), - ], [ 'two bpmn:Error roots sharing a code but disagreeing on the message', rootedDoc( @@ -3690,30 +5164,245 @@ describe('xmlToIr: event layer import', () => { UnsupportedEventFeatureError, ); }); - }); - - describe('warn-drops', () => { - const ERROR_X_ROOT = ' \n'; - it('a genuine label on an event handler start is kept, not dropped', async () => { - const { ir, warnings } = await xmlToIr( - bpmnDoc` + /** `S -> E` beside a handler, with one extra flow wired by the row and no incoming/outgoing children. */ + const wiredHandler = (flow: string): string => + bpmnDoc` - + + + - `, + + ${flow}`; + + it.each([ + [ + 'into', + '', + "the flow 'F2' enters the event handler 'Handler', which " + + 'BpmnParse.parseSequenceFlow fails the deployment on ("Invalid ' + + 'incoming sequence flow of event subprocess"); a handler is ' + + 'entered by its trigger', + ], + [ + 'out of', + '', + "the flow 'F2' leaves the event handler 'Handler', which " + + 'BpmnParse.parseSequenceFlow fails the deployment on ("Invalid ' + + 'outgoing sequence flow of event subprocess"); a handler ends ' + + 'where its body ends', + ], + ])( + 'a flow %s an event handler written without incoming/outgoing children refuses on the flow itself', + async (_direction, flow, detail) => { + const e = await expectRefusal( + xmlToIr(wiredHandler(flow)), + UnsupportedEventFeatureError, + detail, + ); + expect(e.elementId).toBe('Handler'); + expect(e.message).toContain("Take the flow 'F2' off"); + }, + ); + + /** `S -> Work -> E`, with the given boundary block on the subprocess `Work`. */ + const workBoundaryDoc = (boundary: string, roots = ''): string => + rootedDoc( + roots, + ` + + + + + + ${boundary} + + + + + + `, ); - const start = byId(subProcess(ir, 'Handler'), 'HandlerStart'); - expect(start.kind === 'startEvent' && start.name).toBe('Cancelled'); - expect(warnings.filter((w) => w.category === 'label')).toEqual([]); - }); - it('operaton:errorCodeVariable on an error end event (throw side) warns: it has no effect there', async () => { - const { ir, warnings } = await xmlToIr( - rootedDoc( + /** `S -> Throw`, an end carrying the given definition beside the given roots. */ + const throwDoc = (definition: string, roots = ''): string => + rootedDoc( + roots, + ` + ${definition} + + `, + ); + + const danglingEscalation = (method: string): string => + "its escalationRef 'Esc_Missing' names no bpmn:escalation root, " + + 'which Operaton refuses to deploy ("could not find escalation with ' + + `id 'Esc_Missing'", BpmnParse.${method})`; + + // Revert: read a dangling or absent reference as a catch-all or a + // code-less throw again, and every row imports. + it.each([ + [ + 'a boundary catch whose escalationRef names no root', + workBoundaryDoc( + '', + ), + 'B', + danglingEscalation( + 'createEscalationEventDefinitionForEscalationHandler', + ), + ], + [ + 'a handler start whose escalationRef names no root', + handlerDoc( + '', + ), + 'HStart', + danglingEscalation( + 'createEscalationEventDefinitionForEscalationHandler', + ), + ], + [ + 'an escalation end whose escalationRef names no root', + throwDoc( + '', + ), + 'Throw', + danglingEscalation('findEscalationForEscalationEventDefinition'), + ], + [ + 'an escalation end with no escalationRef', + throwDoc(''), + 'Throw', + 'its escalation definition carries no escalationRef, which Operaton ' + + 'refuses to deploy ("escalationEventDefinition does not have ' + + "required attribute 'escalationRef'\", " + + 'BpmnParse.findEscalationForEscalationEventDefinition)', + ], + [ + 'an escalation end naming a code-less root', + throwDoc( + '', + ' \n', + ), + 'Throw', + "its escalationRef names the bpmn:escalation root 'Esc_NoCode', " + + 'which carries no code; Operaton refuses to deploy a throw of one ' + + '("throwing escalation event must have an \'escalationCode\'", ' + + 'BpmnParse.parseIntermediateThrowEvent; "escalation end event ' + + "must have an 'escalationCode'\", parseEndEvents)", + ], + [ + 'an error end with no errorRef', + throwDoc(''), + 'Throw', + 'its error definition carries no errorRef, which Operaton refuses ' + + "to deploy (\"'errorRef' attribute is mandatory on error end " + + 'event", BpmnParse.parseEndEvents)', + ], + [ + 'an error end naming a code-less root', + throwDoc( + '', + ' \n', + ), + 'Throw', + "its errorRef names the bpmn:error root 'Error_NoCode', which " + + 'carries no code; Operaton refuses to deploy the throw ' + + "(\"'errorCode' is mandatory on errors referenced by throwing " + + 'error event definitions", BpmnParse.parseEndEvents)', + ], + ])( + '%s refuses with UnsupportedEventFeatureError quoting the engine', + async (_title, xml, elementId, detail) => { + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedEventFeatureError, + detail, + ); + expect(e.elementId).toBe(elementId); + }, + ); + + const danglingErrorRef = (ownerId: string): ImportWarning => ({ + elementId: ownerId, + category: 'unmappedConstruct', + message: + `The errorRef 'Error_Missing' on '${ownerId}' names no bpmn:error ` + + "root and imports as the code 'Error_Missing': Operaton takes a " + + "dangling reference's text as the code " + + '(BpmnParse.parseBoundaryErrorEventDefinition, ' + + 'parseErrorStartEventDefinition, parseEndEvents, ' + + 'parseOperatonErrorEventDefinitions), and the document written ' + + 'back declares an error root carrying it.', + }); + + // Revert: drop the `unresolvedRef` read from either reader, and the + // catch rows import a catch-all with no warning while the throw refuses. + it.each([ + [ + 'a boundary catch', + workBoundaryDoc( + '', + ), + 'B', + ], + [ + 'a handler start', + handlerDoc(''), + 'HStart', + ], + [ + 'an error end', + throwDoc(''), + 'Throw', + ], + ])( + "%s whose errorRef names no root imports the reference text as the code, the engine's reading, with one warning", + async (_title, xml, ownerId) => { + const { ir, warnings } = await xmlToIr(xml); + const owner = + ownerId === 'HStart' + ? byId(subProcess(ir, 'Handler'), ownerId) + : byId(ir, ownerId); + expect('eventDefinition' in owner && owner.eventDefinition).toEqual( + errorDef('Error_Missing'), + ); + expect(warnings).toEqual([danglingErrorRef(ownerId)]); + expect(ir.errorDecls).toBeUndefined(); + expect(await irToXml(ir)).toContain( + '', + ); + }, + ); + }); + + describe('warn-drops', () => { + const ERROR_X_ROOT = ' \n'; + + it('a genuine label on an event handler start is kept, not dropped', async () => { + const { ir, warnings } = await xmlToIr( + bpmnDoc` + + + + + + + `, + ); + const start = byId(subProcess(ir, 'Handler'), 'HandlerStart'); + expect(start.kind === 'startEvent' && start.name).toBe('Cancelled'); + expect(warnings.filter((w) => w.category === 'label')).toEqual([]); + }); + + it('operaton:errorCodeVariable on an error end event (throw side) warns: it has no effect there', async () => { + const { ir, warnings } = await xmlToIr( + rootedDoc( ERROR_X_ROOT, ` @@ -4002,6 +5691,22 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { 'a signal definition must reference a bpmn:Signal root with a ' + 'non-empty name', ], + [ + // Revert: drop the `unresolvedRef` read from `resolveNamedRootRef`, + // and the detail no longer names the reference text. + 'a prefixed messageRef, which the engine resolves and this tool does not', + handlerDoc( + '', + { + roots: ' \n', + body: '', + }, + ), + "its messageRef 'tns:Message_1' names no bpmn:Message root: this " + + 'tool matches the reference to a root id as written, where ' + + 'Operaton resolves a prefixed reference through the xmlns table ' + + '(BpmnParse.resolveName) and refuses an unresolved one', + ], [ 'a timer definition with zero time children', handlerDoc('', { body: '' }), @@ -4055,18 +5760,18 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { `, { body: '', defs: camundaDefs }, ), - "a conditional definition's camunda:variableEvents narrows when the " + + "a conditional definition's operaton:variableEvents narrows when the " + 'condition is (re-)evaluated, which this tool cannot represent', ], [ - 'camunda:variableName on a conditional definition, the deprecated spelling of the same narrowing', + 'camunda:variableName on a conditional definition, read as operaton: like the rest of the namespace', handlerDoc( ` \${amount > 100} `, { body: '', defs: camundaDefs }, ), - "a conditional definition's camunda:variableName narrows when the " + + "a conditional definition's operaton:variableName narrows when the " + 'condition is (re-)evaluated, which this tool cannot represent', ], ] as const)( @@ -4120,36 +5825,39 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { }); describe('root honesty', () => { - it('two bpmn:Signal roots sharing one name, each referenced, collapse to one IR name with no warning', async () => { - const xml = bpmnDefs` - + // Two signal roots of one name refuse instead (`parseSignals` fails the + // deployment); `parseMessages` has no such check, so two message roots + // deploy and merge here. + it('two bpmn:Message roots sharing one name, each referenced, collapse to one IR name with no warning', async () => { + const xml = bpmnDefs` + - + - - - + + + - - + + `; const { ir, warnings } = await xmlToIr(xml); expect(warnings).toEqual([]); const start = byId(subProcess(ir, 'Handler'), 'HStart'); expect(start.kind === 'startEvent' && start.eventDefinition).toEqual( - PING, + messageDef('Ping'), ); - const emit = byId(ir, 'Emit'); + const wait = byId(ir, 'Wait'); expect( - emit.kind === 'intermediateThrowEvent' && emit.eventDefinition, - ).toEqual(PING); + wait.kind === 'intermediateCatchEvent' && wait.eventDefinition, + ).toEqual(messageDef('Ping')); }); it('an unreferenced bpmn:Message root warns once', async () => { @@ -4869,25 +6577,6 @@ ${extraFlows} - - - bar - - - ${TIMER_1H} - `, - '', - operatonDoc, - ), - 'a boundary event carries an operaton:inputOutput mapping; ' + - 'Operaton forbids input/output variable mappings on a boundary ' + - 'event', - 'Boundary_Review_timer', - ], [ 'cancelActivity="false" on an error boundary', reviewBoundaryDoc(` @@ -5360,7 +7049,13 @@ ${extraFlows} `; 'name="Retry" operaton:asyncBefore="true" operaton:jobPriority="5"', throwChildren: extensionElements( ` R3/PT5M - `, + + + + + + 1 + `, ), catchAttrs: 'name="Retry" operaton:asyncBefore="true"', defs: operatonDefs, @@ -5387,6 +7082,8 @@ ${extraFlows} `; dropped("'jobPriority' setting", 'reads a setting on'), dropped("'retryCycle' setting", 'reads a setting on'), dropped("'end' execution listener", 'runs a listener on'), + dropped('operaton:properties block', 'reads a property list on'), + dropped('operaton:inputOutput block', 'reads a mapping on'), ]); }); @@ -5541,6 +7238,28 @@ describe('xmlToIr: a label on an event the surface gives no label to', () => { ]); }, ); + + it('an end named for a boundary escape reports its label even when the boundary is written after it', async () => { + const { warnings } = await xmlToIr(bpmnDoc` + + + + ${TIMER} + + + + + `); + expect(warnings).toEqual([ + { + elementId: 'EndEvent_Boundary', + category: 'label', + message: expect.stringContaining( + "The label 'Escaped' on 'EndEvent_Boundary' cannot be kept as written", + ), + }, + ]); + }); }); describe('xmlToIr: flat engine settings on a user task', () => { @@ -5594,6 +7313,33 @@ describe('xmlToIr: flat engine settings on a user task', () => { expect(node).toEqual({ kind: 'userTask', id: 'T' }); expect(warnings).toEqual([]); }); + + // Revert: drop the `operaton:async` read from readRunSettings, and the + // first row loses asyncBefore and gains the unread-setting warning. + it.each([ + [ + 'operaton:async="true" imports as asyncBefore and names the respelling', + 'operaton:async="true"', + { asyncBefore: true }, + [ + { + elementId: 'T', + category: 'unmappedConstruct', + message: asyncRespelling("'T'"), + }, + ], + ], + [ + 'operaton:async="false" carries nothing, as isAsyncBefore reads only "true"', + 'operaton:async="false"', + {}, + [], + ], + ])('%s', async (_title, attrs, settings, expected) => { + const { node, warnings } = await importUserTask(attrs); + expect(node).toEqual({ kind: 'userTask', id: 'T', ...settings }); + expect(warnings).toEqual(expected); + }); }); describe('xmlToIr: BPMN-native assignment and the quantity attributes', () => { const role = ( @@ -5658,6 +7404,14 @@ describe('xmlToIr: BPMN-native assignment and the quantity attributes', () => { ]); }); + it('an empty operaton:assignee is kept as written, since parseUserTaskCustomExtensions reads it as one', async () => { + const { node, warnings } = await importReview('operaton:assignee=""', ''); + expect([node, warnings]).toEqual([ + { kind: 'userTask', id: 'Review', assignee: '' }, + [], + ]); + }); + it.each([ [ 'a humanPerformer beside operaton:assignee', @@ -5665,6 +7419,12 @@ describe('xmlToIr: BPMN-native assignment and the quantity attributes', () => { role('humanPerformer', 'Lead', 'demo'), /'Lead'.*"demo".*operaton:assignee="bob".*parseUserTaskCustomExtensions/, ], + [ + 'a humanPerformer beside an empty operaton:assignee', + 'operaton:assignee=""', + role('humanPerformer', 'Lead', 'demo'), + /'Lead'.*"demo".*operaton:assignee="".*parseUserTaskCustomExtensions/, + ], [ 'two humanPerformers', '', @@ -5832,15 +7592,7 @@ describe('xmlToIr: flat engine settings honor the camunda: alias', () => { exclusive: false, candidateGroups: 'managers', }); - expect(warnings).toEqual([]); - }); - - it('operaton: wins over camunda: when both spell the same setting', async () => { - const { node: task, warnings } = await importUserTask( - 'operaton:exclusive="true" camunda:exclusive="false"', - ); - expect(task).toEqual({ kind: 'userTask', id: 'T' }); - expect(warnings).toEqual([]); + expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); }); }); @@ -5916,6 +7668,79 @@ describe('xmlToIr: flat engine settings on every carrying node kind', () => { expect(ir.versionTag).toBe('1.4'); }); + const RESPELLED_RESULT_VARIABLE = { + elementId: 'T', + category: 'unmappedConstruct', + message: + 'The operaton:resultVariableName="out" on \'T\' imports as ' + + 'resultVariable: "out": BpmnParse.parseResultVariable reads the two ' + + 'spellings as one, and this tool writes it back as ' + + 'operaton:resultVariable, which the engine reads the same.', + }; + + // Revert: read resultVariable alone in readResultVariable, and the first + // three rows lose the field and gain the unread-setting warning. + it.each([ + { + title: 'an expression-bound service task', + tag: 'serviceTask', + attrs: + 'operaton:expression="${svc.run()}" operaton:resultVariableName="out"', + children: '', + kind: 'serviceTask' as const, + resultVariable: 'out', + warnings: [RESPELLED_RESULT_VARIABLE], + }, + { + title: 'a script task', + tag: 'scriptTask', + attrs: 'scriptFormat="groovy" operaton:resultVariableName="out"', + children: '1', + kind: 'scriptTask' as const, + resultVariable: 'out', + warnings: [RESPELLED_RESULT_VARIABLE], + }, + { + title: 'a decision-bound business rule task', + tag: 'businessRuleTask', + attrs: 'operaton:decisionRef="d" operaton:resultVariableName="out"', + children: '', + kind: 'serviceTask' as const, + resultVariable: 'out', + warnings: [RESPELLED_RESULT_VARIABLE], + }, + { + title: + 'both spellings written, which keeps resultVariable and reports the older one', + tag: 'serviceTask', + attrs: + 'operaton:expression="${svc.run()}" operaton:resultVariable="kept" ' + + 'operaton:resultVariableName="out"', + children: '', + kind: 'serviceTask' as const, + resultVariable: 'kept', + warnings: [ + { + elementId: 'T', + category: 'extensionAttribute', + message: + "The 'resultVariableName' setting on 'T' has no effect alongside " + + 'operaton:resultVariable and was not imported.', + }, + ], + }, + ])( + 'the older resultVariableName spelling imports as resultVariable on $title', + async ({ tag, attrs, children, kind, resultVariable, warnings }) => { + const imported = await importOnly( + oneNodeDoc(tag, { attrs, children }), + kind, + ); + expect(imported.node.resultVariable).toBe(resultVariable); + expect(imported.warnings).toEqual(warnings); + }, + ); + it('an event handler and its trigger start each carry their own settings', async () => { const xml = operatonDefs` @@ -5940,40 +7765,142 @@ describe('xmlToIr: flat engine settings on every carrying node kind', () => { }); }); -describe('xmlToIr: the job settings on a gateway', () => { - const TIMER_CATCH = ` +describe("xmlToIr: a timer job's lock is read where BpmnParse.parseTimer reads it", () => { + /** `S -> Host -> E` with a boundary timer on the host, its tag and definition carrying the given attributes. */ + const boundaryTimerDoc = (tagAttrs: string, defAttrs: string): string => + operatonDefs` + + + + + + PT1H + + + + + + + `; + + /** `S -> E` with a timer-started event sub-process `Handler` alongside; `subAttrs` go on the sub-process tag. */ + const handlerTimerDoc = ( + tagAttrs: string, + defAttrs: string, + subAttrs = '', + ): string => + operatonDefs` + + + + R/PT1H + + + + + + + `; + + const BOTH_JOBS_WARNING = warning( + 'B', + /operaton:exclusive="true" on the event.*continuation job.*operaton:exclusive="false" on its timer definition.*timer job.*took the timer definition's/, + ); + + const CONTINUATION_COPY_WARNING = warning( + 'Handler', + /'jobPriority' setting on 'Handler' was not imported: the 'on timer' head.*timer job.*continuation job/, + ); + + // Revert: read the tag alone and the first two rows lose their `false`; + // drop the definition from the consumed table and they warn "not imported"; + // keep the sub-process's own timer keys and the last row's IR carries + // `jobPriority` with no warning. + it.each< + [string, string, { tag?: string; def?: string; sub?: string }, unknown] + >([ + [ + 'the definition alone on a boundary', + 'B', + { def: 'operaton:exclusive="false"' }, + { exclusive: false, warnings: [] }, + ], + [ + 'the definition alone on an event sub-process start', + 'HStart', + { def: 'operaton:exclusive="false"' }, + { exclusive: false, warnings: [] }, + ], + [ + 'the tag alone, which the definition falls back to', + 'B', + { tag: 'operaton:exclusive="false"' }, + { exclusive: false, warnings: [] }, + ], + [ + 'the tag true beside the definition false takes the definition and says which job each governs', + 'B', + { tag: 'operaton:exclusive="true"', def: 'operaton:exclusive="false"' }, + { exclusive: false, warnings: [BOTH_JOBS_WARNING] }, + ], + [ + "a timer job key on the event sub-process itself is dropped under one warning, since the head's copy is the start's", + 'Handler', + { sub: 'operaton:asyncBefore="true" operaton:jobPriority="7"' }, + { + asyncBefore: true, + jobPriority: undefined, + warnings: [CONTINUATION_COPY_WARNING], + }, + ], + ])('%s', async (_title, id, { tag = '', def = '', sub = '' }, expected) => { + const xml = + id === 'B' ? boundaryTimerDoc(tag, def) : handlerTimerDoc(tag, def, sub); + const { ir, warnings } = await xmlToIr(xml); + const node = + id === 'Handler' + ? subProcess(ir, id) + : id === 'B' + ? byId(ir, id) + : byId(subProcess(ir, 'Handler'), id); + const { asyncBefore, exclusive, jobPriority } = node; + expect({ asyncBefore, exclusive, jobPriority, warnings }).toEqual(expected); + }); +}); + +const TIMER_CATCH = ` P1D `; - /** - * `S -> G -> C -> E`, `C` a timer so the one shape also holds for a wait. - * The gateway is written first or last among the nodes, so a sweep keyed - * on document position meets it at either end. - */ - const gatewayDoc = ( - tag: string, - attrs: string, - { - children = '', - placement = 'first', - doc: wrapper = operatonDoc, - }: { children?: string; placement?: 'first' | 'last'; doc?: XmlTag } = {}, - ): string => { - const node = `${children}`; - const others = ` +/** + * `S -> G -> C -> E`, `C` a timer so the one shape also holds for a wait. + * The gateway is written first or last among the nodes, so a sweep keyed + * on document position meets it at either end. + */ +const gatewayDoc = ( + tag: string, + attrs: string, + { + children = '', + placement = 'first', + doc: wrapper = operatonDoc, + }: { children?: string; placement?: 'first' | 'last'; doc?: XmlTag } = {}, +): string => { + const node = `${children}`; + const others = ` ${TIMER_CATCH} `; - const [head, tail] = - placement === 'first' ? [node, others] : [others, node]; - return wrapper` ${head} + const [head, tail] = placement === 'first' ? [node, others] : [others, node]; + return wrapper` ${head} ${tail} `; - }; +}; +describe('xmlToIr: the job settings on a gateway', () => { const settingAttrs = (prefix: 'operaton' | 'camunda', asyncAfter = true) => `${prefix}:asyncBefore="true" ` + (asyncAfter ? `${prefix}:asyncAfter="true" ` : '') + @@ -5988,14 +7915,6 @@ describe('xmlToIr: the job settings on a gateway', () => { jobPriority: '7', retryCycle: 'R3/PT10M', }; - const stillReported = (type: string) => [ - [ - 'G', - 'extensionAttribute', - `Extra configuration (${type}) on 'G' was not imported.`, - ], - ]; - // Revert: drop GATEWAY_TAGS from JOB_SETTING_OWNERS, and every settings row // loses its node fields and gains one 'was not imported' warning per setting. it.each([ @@ -6017,7 +7936,7 @@ describe('xmlToIr: the job settings on a gateway', () => { placement: 'last' as const, doc: dualDoc, settings: ALL_FIVE, - warnings: [], + warnings: [['p', 'unmappedConstruct', CAMUNDA_ALIAS_MESSAGE]], }, { title: 'a fork written last carries all five', @@ -6043,18 +7962,17 @@ describe('xmlToIr: the job settings on a gateway', () => { warnings: [], }, { - title: 'an input/output block on a fork is still reported', - tag: 'parallelGateway', - attrs: '', - children: extensionElements(` - bar - `), + title: + 'a split written with the older async spelling carries asyncBefore and names the respelling', + tag: 'exclusiveGateway', + attrs: 'operaton:async="true"', placement: 'first' as const, - settings: {}, - warnings: stillReported('operaton:InputOutput'), + settings: { asyncBefore: true }, + warnings: [['G', 'unmappedConstruct', asyncRespelling("'G'")]], }, { - title: 'an execution listener on a split is still reported', + title: + 'an execution listener on a split is dropped naming the parser that runs it', tag: 'exclusiveGateway', attrs: '', children: extensionElements( @@ -6062,7 +7980,18 @@ describe('xmlToIr: the job settings on a gateway', () => { ), placement: 'last' as const, settings: {}, - warnings: stillReported('operaton:ExecutionListener'), + warnings: [ + [ + 'G', + 'extensionAttribute', + unread( + "operaton:executionListener 'start'", + 'G', + 'a ', + 'runs it (BpmnParse.parseExecutionListenersOnScope)', + ).message, + ], + ], }, ])('$title', async (row) => { const { ir, warnings } = await xmlToIr(gatewayDoc(row.tag, row.attrs, row)); @@ -6071,9 +8000,28 @@ describe('xmlToIr: the job settings on a gateway', () => { row.warnings, ); }); -}); -describe('xmlToIr: content is consumed only on the owner kind that reads it', () => { + // Revert: drop requireIntegerOrExpression from readJobSettings, and both + // rows import the priority as written with no warning. + it.each([ + ['a decimal', '1.5'], + ['a word', 'abc'], + ])( + '%s job priority refuses, quoting parsePriority', + async (_title, value) => { + const err = await expectRefusal( + xmlToIr( + gatewayDoc('exclusiveGateway', `operaton:jobPriority="${value}"`), + ), + UnsupportedExtensionFormError, + priorityRefusal('jobPriority', value), + ); + expect(err.elementId).toBe('G'); + }, + ); +}); + +describe('xmlToIr: content is consumed only on the owner kind that reads it', () => { const serviceTaskXml = (attrs: string, children = ''): string => oneNodeDoc('serviceTask', { id: 'Svc', @@ -6262,37 +8210,47 @@ describe('xmlToIr: input/output parameters', () => { ]); }); - it('every activity kind reads its own io block, and an event does not', async () => { - const io = ` - - 1 - - `; - const xml = operatonDoc` -${io} - - -${io} - - -${io} + /** One `` holding one io block, optionally with an output parameter beside the input. */ + const IO = ( + params = '1', + ): string => + extensionElements(` + ${params} + `); + + // Revert: take bpmn:IntermediateThrowEvent off the operaton:InputOutput + // row and the hand report out of the throw's definition arm, and the + // throw's block reports twice or not at all; drop an event's row from + // ENGINE_READS_ELSEWHERE and its sentence loses the reader. + it('every activity kind reads its own io block, and an event that runs one reports it naming parseActivityInputOutput', async () => { + const xml = operatonDefs` + + + ${IO()} + ${IO()} 1 - -${io} + ${IO()} - -${io} - - + ${IO()} + ${IO()} + + + ${IO()} + PT1H + + ${IO()} - `; + + + + `; const { ir, warnings } = await xmlToIr(xml); const carried = ir.flowElements @@ -6301,14 +8259,141 @@ ${io} ) .map((fe) => fe.id); expect(carried).toEqual(['Review', 'Calc', 'Booking', 'Sub']); + expect(warnings).toEqual([ + unread( + 'operaton:inputOutput', + 'Throw', + 'a ', + 'reads it on every throw but a link (BpmnParse.parseActivityInputOutput)', + ), + unread( + 'operaton:inputOutput', + 'Wait', + 'a ', + 'reads it (BpmnParse.parseActivityInputOutput)', + ), + unread( + 'operaton:inputOutput', + 'End', + 'a ', + 'reads its input parameters (BpmnParse.parseActivityInputOutput)', + ), + ]); + }); - // The start event's IR node carries no io block, so its own stays a - // reported drop rather than a silent one. - expectOneWarning(warnings, { - elementId: 'Start', - category: 'extensionAttribute', - message: /InputOutput/i, - }); + /** The refusal an io mapping draws where `method` fails the deployment on the tag. */ + const ioRefusal = (tag: string, method: string, suffix = ''): string => + `an operaton:inputOutput mapping on a , which ` + + `BpmnParse.${method} fails the deployment on ("operaton:inputOutput ` + + `mapping unsupported for element type '${tag}'${suffix}")`; + + const TRIGGERED = " with attribute 'triggeredByEvent = true'"; + + // Revert: drop the refuseIoMapping call from a mapper, and that row imports + // the block as a warned drop. + it.each([ + [ + "the process's own start", + operatonDoc` ${IO()} + + `, + 'S', + ioRefusal('startEvent', 'ensureNoIoMappingDefined'), + ], + [ + "an event handler's start", + handlerDoc(`${IO()}`, { + defs: operatonDefs, + }), + 'HStart', + ioRefusal('startEvent', 'ensureNoIoMappingDefined'), + ], + [ + 'a boundary event', + operatonDoc` + + ${IO()} + PT1H + + + + + `, + 'B', + ioRefusal('boundaryEvent', 'ensureNoIoMappingDefined'), + ], + ...( + [ + 'exclusiveGateway', + 'inclusiveGateway', + 'parallelGateway', + 'eventBasedGateway', + ] as const + ).map((tag): [string, string, string, string] => [ + `a ${tag}`, + gatewayDoc(tag, '', { children: IO() }), + 'G', + ioRefusal(tag, 'checkActivityInputOutputSupported'), + ]), + [ + 'an event sub-process', + handlerDoc('', { + defs: operatonDefs, + body: `${IO()} + +`, + }), + 'Handler', + ioRefusal('subProcess', 'checkActivityInputOutputSupported', TRIGGERED), + ], + [ + 'an output parameter on an end event', + operatonDoc` + ${IO('1')} + `, + 'E', + 'an operaton:outputParameter on a , which ' + + 'BpmnParse.checkActivityOutputParameterSupported fails the ' + + 'deployment on ("operaton:outputParameter not allowed for element ' + + "type 'endEvent'\")", + ], + ])( + 'an io mapping on %s refuses, quoting the parser that fails the deployment', + async (_title, xml, elementId, detail) => { + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedExtensionFormError, + detail, + ); + expect(e.elementId).toBe(elementId); + }, + ); + + // `BpmnParseUtil.parseNestedParamValueProvider` reads `element.elements()` + // alone, so body text beside the one nested child never reaches the engine; + // revert: dropping the `text !== undefined` warning branch turns this back + // into the old "carries both body text and a nested value" refusal. + it('a parameter carrying body text beside one nested child keeps the child and warns on the text', async () => { + const { node: task, warnings } = await importServiceTask( + ioBlock( + ` texta`, + ), + ); + expect(task.inputParameters).toEqual([ + ioParam('x', listValue([textValue('a')])), + ]); + expect(warnings).toEqual([ + { + elementId: 'Svc', + category: 'extensionAttribute', + message: + "operaton:inputParameter 'x' carries both body text and a " + + 'nested value: BpmnParseUtil' + + '.parseNestedParamValueProvider reads the nested value and ' + + 'never the text, and the document written back carries the ' + + 'nested value alone.', + }, + ]); }); }); @@ -6382,20 +8467,125 @@ ${listener} expect(carrying).toEqual(['Start', 'Review', 'Booking', 'Timeout', 'End']); expect(warnings).toEqual([]); }); + + // Revert: drop the process, flow or gateway row from ENGINE_READS_ELSEWHERE, + // and its sentence loses the parser that runs the listener. + it('a listener on the process, a flow or a gateway is dropped naming the parser that runs it', async () => { + const listener = (event: string): string => + extensionElements( + ` `, + ); + const { warnings } = await xmlToIr( + operatonDefs` ${listener('start')} + + ${listener('end')} + + + ${listener('take')} + `, + ); + expect(warnings).toEqual([ + unread( + "operaton:executionListener 'start'", + 'p', + 'a ', + 'runs it on the process instance (BpmnParse.parseExecutionListenersOnScope)', + ), + unread( + "operaton:executionListener 'end'", + 'G', + 'a ', + 'runs it (BpmnParse.parseExecutionListenersOnScope)', + ), + unread( + "operaton:executionListener 'take'", + 'F2', + 'a ', + 'runs it when the flow is taken, whatever its event says ' + + '(BpmnParse.parseExecutionListenersOnTransition)', + ), + ]); + }); + + // Each row's revert: `readListeners`/`resolveListenerBinding` refusing what + // Operaton actually runs (a repeated event, a second attribute, a shadowed + // script, an empty expression) turns the row into a refusal instead of the + // listener list plus warning it pins. + it.each([ + { + case: 'two listeners on one event import in document order, since Operaton appends rather than replaces', + children: ` + `, + listeners: [ + { event: 'start', binding: classBinding('com.example.A') }, + { event: 'start', binding: classBinding('com.example.B') }, + ], + warnings: [], + }, + { + case: 'a second attribute binding is shadowed by the one BpmnParse.parseExecutionListener resolves first', + children: ` `, + listeners: [{ event: 'start', binding: classBinding('com.example.A') }], + warnings: [ + { + elementId: 'Svc', + category: 'extensionAttribute', + message: + "The 'expression' setting on an operaton:executionListener on " + + "'Svc' has no effect alongside class and was not imported.", + }, + ], + }, + { + case: 'a script child is shadowed the same way by an attribute binding', + children: ` + 1 + `, + listeners: [{ event: 'start', binding: classBinding('com.example.A') }], + warnings: [ + { + elementId: 'Svc', + category: 'extensionAttribute', + message: + "The 'script' setting on an operaton:executionListener on 'Svc' " + + 'has no effect alongside class and was not imported.', + }, + ], + }, + { + case: 'an empty expression carries as written, since ExpressionExecutionListener evaluates it rather than refusing it', + children: ` `, + listeners: [{ event: 'start', binding: exprBinding('') }], + warnings: [ + { + elementId: 'Svc', + category: 'extensionAttribute', + message: + 'An operaton:executionListener on \'Svc\' has expression="": ' + + 'ExpressionExecutionListener evaluates the empty text rather ' + + 'than refusing it, and the document written back carries it.', + }, + ], + }, + ] as const)('$case', async ({ children, listeners, warnings: expected }) => { + const { node: task, warnings } = await importServiceTask(children); + expect(task.executionListeners).toEqual(listeners); + expect(warnings).toEqual(expected); + }); }); describe('xmlToIr: task listeners', () => { it('the five non-timeout events import in emission order', async () => { const { node: task, warnings } = await importUserTaskWith( ` - + `, ); expect(task.taskListeners?.map((l) => l.event)).toEqual([ 'create', - 'assign', + 'assignment', 'complete', 'update', 'delete', @@ -6431,34 +8621,27 @@ describe('xmlToIr: task listeners', () => { expectOneWarning(warnings, { elementId: 'Svc', message: /TaskListener/i }); }); - it('a listener id is reported, not swallowed with the listener that runs', async () => { + // The export mints a timeout listener's id, the key + // `BpmnParse.parseTimeoutTaskListener` requires, so the one read is not + // carried; on any other event `parseTaskListener` never reads it. + it('a listener id is consumed without a warning, on a timeout and elsewhere', async () => { const { node: task, warnings } = await importUserTaskWith( - ` `, - ); - expect(task.taskListeners).toEqual([ - { event: 'create', binding: classBinding('com.example.L') }, - ]); - expect(warnings).toHaveLength(1); - expect(warnings[0].elementId).toBe('Review'); - expect(warnings[0].category).toBe('extensionAttribute'); - expect(warnings[0].message).toMatch( - /'id' on an operaton:taskListener 'create'/, - ); - }); - - it('a timeout listener id is reported, the id Operaton addresses its job by', async () => { - const { node: task, warnings } = await importUserTaskWith( - ` + ` + PT8H `, ); - expect(task.taskListeners?.[0].timer).toEqual(timerDef('duration', 'PT8H')); - expect(warnings).toHaveLength(1); - expect(warnings[0].message).toMatch( - /'id' on an operaton:taskListener 'timeout'/, - ); + expect(task.taskListeners).toEqual([ + { event: 'create', binding: classBinding('com.example.L') }, + { + event: 'timeout', + binding: classBinding('com.example.T'), + timer: timerDef('duration', 'PT8H'), + }, + ]); + expect(warnings).toEqual([]); }); it('an undeclared operaton attribute and a foreign one on a listener both report', async () => { @@ -6474,6 +8657,141 @@ describe('xmlToIr: task listeners', () => { expect.stringMatching(/'foo:bar' on an operaton:taskListener/), ]); }); + + // Each row's revert: `readListeners`/`readListenerTimer`/ + // `resolveListenerBinding` refusing what Operaton actually runs (a repeated + // timeout, an empty binding attribute, a timer off a non-timeout event) + // turns the row into a refusal instead of the listener list plus warning it + // pins. + it.each([ + { + case: 'two timeout listeners import in document order, each carrying its own timer, since TaskDefinition.addTimeoutTaskListener keys by id rather than replacing', + children: ` + + PT1H + + + + + 2026-08-01T09:00:00 + + `, + listeners: [ + { + event: 'timeout', + binding: classBinding('com.example.T'), + timer: timerDef('duration', 'PT1H'), + }, + { + event: 'timeout', + binding: classBinding('com.example.D'), + timer: timerDef('date', '2026-08-01T09:00:00'), + }, + ], + warnings: [], + }, + { + case: 'an empty class carries as written, since parseTaskListener checks no attribute for emptiness', + children: ` `, + listeners: [{ event: 'create', binding: classBinding('') }], + warnings: [ + { + elementId: 'Review', + category: 'extensionAttribute', + message: + 'An operaton:taskListener on \'Review\' has class="": ' + + 'BpmnParse.parseTaskListener checks no listener attribute for ' + + 'emptiness, so the task deploys and the listener fails when ' + + 'its event fires, and the document written back carries the ' + + 'empty text.', + }, + ], + }, + { + case: 'an empty expression on a task listener carries as written, since ExpressionTaskListener evaluates it rather than failing', + children: ` `, + listeners: [{ event: 'complete', binding: exprBinding('') }], + warnings: [ + { + elementId: 'Review', + category: 'extensionAttribute', + message: + 'An operaton:taskListener on \'Review\' has expression="": ' + + 'ExpressionTaskListener evaluates the empty text rather than ' + + 'refusing it, and the document written back carries it.', + }, + ], + }, + { + case: 'an empty delegateExpression on a task listener warns the same way as an empty class', + children: ` `, + listeners: [{ event: 'update', binding: delegateBinding('') }], + warnings: [ + { + elementId: 'Review', + category: 'extensionAttribute', + message: + "An operaton:taskListener on 'Review' has " + + 'delegateExpression="": BpmnParse.parseTaskListener checks no ' + + 'listener attribute for emptiness, so the task deploys and the ' + + 'listener fails when its event fires, and the document written ' + + 'back carries the empty text.', + }, + ], + }, + { + case: 'operaton:exclusive on a timeout timer imports the timer and reports the lock flag the surface cannot carry', + children: ` + + PT1H + + `, + listeners: [ + { + event: 'timeout', + binding: classBinding('com.example.T'), + timer: timerDef('duration', 'PT1H'), + }, + ], + warnings: [ + { + elementId: 'Review', + category: 'extensionAttribute', + message: + 'The operaton:exclusive="false" on the timer of an ' + + 'operaton:taskListener with event="timeout" on \'Review\' was ' + + 'not imported: this tool has no setting for it there, though ' + + 'Operaton locks the timeout job by it ' + + '(BpmnParse.parseTimeoutTaskListener through parseTimer), so the ' + + 'document written back runs without it.', + }, + ], + }, + { + case: 'a timer under a non-timeout listener warns and drops, since parseTaskListener reads no event definition there', + children: ` + + PT1H + + `, + listeners: [{ event: 'create', binding: classBinding('com.example.C') }], + warnings: [ + { + elementId: 'Review', + category: 'extensionAttribute', + message: + 'The bpmn:TimerEventDefinition on an operaton:taskListener ' + + 'with event="create" was not imported: BpmnParse.parseTaskListener ' + + 'reads no event definition off a listener that is not a ' + + 'timeout, and the document written back carries none.', + }, + ], + }, + ] as const)('$case', async ({ children, listeners, warnings: expected }) => { + const { node: task, warnings } = await importUserTaskWith(children); + expect(task.taskListeners).toEqual(listeners); + expect(warnings).toEqual(expected); + }); }); describe('xmlToIr: a consumed extension child reports its own unread attributes', () => { @@ -6661,6 +8979,21 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => ), ], ], + [ + 'a delegate binding carries a #{...} operaton:expression child as written', + 'sendTask', + 'operaton:delegateExpression="${svcBean}"', + field( + 'name="greeting"', + '#{who}', + ), + { + kind: 'delegateExpression', + expression: '${svcBean}', + fields: [{ name: 'greeting', value: '#{who}' }], + }, + [], + ], [ 'a stringValue that reads as an expression drops, because export would write it back as an operaton:expression the engine evaluates', 'serviceTask', @@ -6675,6 +9008,20 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => ), ], ], + [ + 'a stringValue that reads as a #{...} expression drops the same way', + 'serviceTask', + CLASS, + field('name="greeting" stringValue="#{who}"'), + SVC, + [ + dropped( + "a stringValue attribute holding '#{who}' would be written back " + + 'as an operaton:expression child, and the engine would evaluate ' + + 'it rather than inject the text', + ), + ], + ], [ 'an operaton:expression child that does not read as an expression drops, because export would write it back as the literal stringValue, and its text is quoted on one line', 'serviceTask', @@ -6694,7 +9041,7 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => ], ], [ - 'a pretty-printed operaton:expression child drops, because the engine evaluates the body it is handed untrimmed and the indentation is part of the expression', + 'a pretty-printed operaton:expression child drops, because the engine evaluates the body it is handed untrimmed and the script has no template opening after whitespace', 'serviceTask', CLASS, field( @@ -6706,8 +9053,8 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => dropped( 'an operaton:expression child holding ' + "'\\n ${who}\\n ' would be written back as a " + - 'stringValue attribute, and the engine would inject that text ' + - 'rather than evaluate it', + 'quoted literal the compiler refuses, since a raw template opens ' + + 'directly after its quote', ), ], ], @@ -6882,14 +9229,24 @@ describe('xmlToIr: a mail or shell task imports with its fields on the three tag ]), { title: - 'operaton:type="Shell" imports lower-case, and a resultVariable beside it stays on the node', + 'operaton:type="Shell" imports lower-case, and a resultVariable beside it stays on the node with the never-read warning', tag: 'serviceTask', attrs: 'operaton:type="Shell" operaton:resultVariable="out"', fields: SHELL_FIELDS, node: { binding: SHELL, resultVariable: 'out' }, - warnings: [], - }, - { + warnings: [ + { + elementId: 'T', + category: 'extensionAttribute', + message: + "The resultVariable 'out' on 'T' was imported as written, and " + + 'the printed script draws a warning at the step: ' + + 'BpmnParse.parseServiceTaskLike hands it to an expression ' + + 'binding alone, so an operaton:type binding never writes it.', + }, + ], + }, + { title: 'a shell flag spelled TRUE imports as written with a warning, since the engine deploys it and reads it as false', tag: 'serviceTask', @@ -6989,6 +9346,18 @@ describe('xmlToIr: a service-like task the engine would refuse is refused with i 'BpmnParse.validateFieldDeclarationsForShell casts every shell field ' + 'to a FixedValue, and an expression is not one', ], + [ + 'a shell task with a field written as a #{...} expression', + typed('shell', [ + COMMAND, + field('name="arg1"', '#{x}'), + ]), + 'Service task', + 'operaton:type="shell" with the field \'arg1\' written as an ' + + 'operaton:expression, which Operaton fails to deploy: ' + + 'BpmnParse.validateFieldDeclarationsForShell casts every shell field ' + + 'to a FixedValue, and an expression is not one', + ], [ 'a shell wait flag outside true and false', typed('shell', [COMMAND, field('name="wait" stringValue="yes"')]), @@ -7199,7 +9568,7 @@ describe('xmlToIr: a user task names a deployed form by reference', () => { binding: { kind: 'version', version: '3' }, }, }); - expect(warnings).toEqual([]); + expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); }); it.each([ @@ -7243,7 +9612,9 @@ describe('xmlToIr: a user task names a deployed form by reference', () => { [ 'a formRef bound by a word this tool cannot represent', 'operaton:formRef="review-form" operaton:formRefBinding="versionTag"', - 'formRefBinding="versionTag" is not a binding this tool can represent', + 'formRefBinding="versionTag" is outside the bindings ' + + 'BpmnParse.parseFormDefinition resolves (deployment, latest, ' + + 'version), so the engine refuses to deploy it', ], [ 'a formRef pinned to a version it never names', @@ -7263,64 +9634,210 @@ describe('xmlToIr: a user task names a deployed form by reference', () => { ); }); -describe('xmlToIr: a repeated extension block keeps the first and reports the rest', () => { - it('a second operaton:failedJobRetryTimeCycle warns and the first is kept', async () => { - const { node: task, warnings } = await importServiceTask( - ` R3/PT10M - R5/PT1H`, - ); - expect(task.retryCycle).toBe('R3/PT10M'); - expectOneWarning(warnings, { - elementId: 'Svc', - message: /FailedJobRetryTimeCycle/i, - }); - }); +describe('xmlToIr: a second extension block of one kind refuses, since Element.elementNS throws on it', () => { + const twice = (block: string): string => `${block}\n${block}`; + const PROPERTIES = ` + + `; - it('a second operaton:inputOutput warns and the first is kept', async () => { - const { node: task, warnings } = await importServiceTask( - `${ioBlock(' 1')} -${ioBlock(' 2')}`, - ); - expect(task.inputParameters).toEqual([ioParam('kept', textValue('1'))]); - expect(warnings).toHaveLength(1); - expect(warnings[0].message).toMatch(/InputOutput/i); - }); + // Revert: onlyExtensionElement back to returning the first match, and + // every row imports the first block under a warning. + it.each([ + [ + 'operaton:failedJobRetryTimeCycle', + serviceTaskWith( + twice( + ' R3/PT10M', + ), + ), + 'Svc', + 'failedJobRetryTimeCycle', + 'DefaultFailedJobParseListener.setFailedJobRetryTimeCycleValue reads ' + + 'them on an async step, a timer-driven event or a typed throw', + ], + [ + 'operaton:inputOutput', + serviceTaskWith( + twice( + ioBlock( + ' 1', + ), + ), + ), + 'Svc', + 'inputOutput', + 'BpmnParseUtil.parseInputOutput reads them', + ], + [ + 'operaton:inputOutput on an intermediate catch', + oneNodeDoc('intermediateCatchEvent', { + id: 'Wait', + children: `${extensionElements( + twice( + ioBlock( + ' 1', + ), + ), + )} + PT1H`, + }), + 'Wait', + 'inputOutput', + 'BpmnParseUtil.parseInputOutput reads them', + ], + [ + 'operaton:inputOutput on an intermediate throw carrying a definition', + oneNodeDoc('intermediateThrowEvent', { + id: 'Throw', + children: `${extensionElements( + twice( + ioBlock( + ' 1', + ), + ), + )} + `, + }), + 'Throw', + 'inputOutput', + 'BpmnParseUtil.parseInputOutput reads them', + ], + [ + 'operaton:formData', + userTaskWith( + twice(` + + `), + ), + 'Review', + 'formData', + 'DefaultFormHandler.parseFormData reads them', + ], + [ + 'operaton:properties on an external task', + oneNodeDoc('serviceTask', { + id: 'Svc', + attrs: 'operaton:type="external" operaton:topic="t"', + children: extensionElements(twice(PROPERTIES)), + }), + 'Svc', + 'properties', + 'BpmnParseUtil.parseOperatonExtensionProperties reads them', + ], + ])( + 'a second %s refuses, naming the reader that meets both', + async (_title, xml, elementId, tag, reads) => { + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedExtensionFormError, + `2 blocks, which Element.elementNS throws on ` + + `when ${reads} ("Parsing exception: multiple elements with tag ` + + `name '${tag}' found"), and BpmnParse.execute lets that fail the ` + + 'deployment', + ); + expect(e.elementId).toBe(elementId); + }, + ); - it('a second operaton:formData warns and the first is kept', async () => { - const { node: task, warnings } = await importUserTaskWith( - ` - - - - - `, - ); - expect(task.formFields).toEqual([{ id: 'kept', type: 'string' }]); - expect(warnings).toHaveLength(1); - expect(warnings[0].message).toMatch(/FormData/i); + it('two operaton:properties blocks on a step no external worker runs are both dropped, since nothing reads them there', async () => { + const { node, warnings } = await importServiceTask(twice(PROPERTIES)); + expect('properties' in node).toBe(false); + const dropped = { + elementId: 'Svc', + category: 'extensionAttribute', + message: + "The operaton:properties block on 'Svc' was not imported: Operaton " + + 'reads it in parseExternalServiceTask alone, which only ' + + 'operaton:type="external" reaches, so the step runs as written ' + + 'without it.', + }; + expect(warnings).toEqual([dropped, dropped]); }); }); -describe('xmlToIr: form data on an event handler trigger start', () => { - it('is read onto the trigger start event rather than dropped', async () => { - const xml = handlerDoc( - ` +describe('xmlToIr: an initiator and a form on a start that is not the process own', () => { + const START_FORM = ` - - `, - { - roots: ' \n', + `; + + const dropped = (id: string): ImportWarning[] => [ + { + elementId: id, + category: 'extensionAttribute', + message: + `The 'operaton:initiator' setting on '${id}' was not imported: ` + + 'BpmnParse.parseScopeStartEvent reads no operaton: attribute off a ' + + "start that is not the process's own " + + '(parseProcessDefinitionStartEvent reads it there alone), so the ' + + 'document written back runs the same.', + }, + { + elementId: id, + category: 'extensionAttribute', + message: + `The operaton:formData block on '${id}' was not imported: ` + + "BpmnParse.parseStartFormHandlers runs for the process's own start " + + 'alone and parseScopeStartEvent reads no form, so the document ' + + 'written back runs the same.', + }, + ]; + + // Revert: read `initiator` and the form off every start again, and both + // rows carry them with no warning. + it.each([ + [ + 'a handler start', + handlerDoc(`${START_FORM}\n `, { + startAttrs: 'operaton:initiator="who"', defs: operatonDefs, - }, - ); + }), + (ir: BpmnProcess) => byId(subProcess(ir, 'Handler'), 'HStart'), + 'HStart', + ], + [ + 'a start inside a subprocess', + operatonDoc` + + + ${START_FORM} + + + + + + + `, + (ir: BpmnProcess) => byId(subProcess(ir, 'Sub'), 'Inner'), + 'Inner', + ], + ])( + '%s drops both with one warning each, naming the reader that never runs there', + async (_title, xml, startOf, id) => { + const { ir, warnings } = await xmlToIr(xml); + const start = startOf(ir); + expect(start.kind === 'startEvent' && 'initiator' in start).toBe(false); + expect(start.kind === 'startEvent' && 'formFields' in start).toBe(false); + expect(warnings).toEqual(dropped(id)); + }, + ); - const { ir, warnings } = await xmlToIr(xml); - const start = byId(subProcess(ir, 'Handler'), 'HStart'); - expect(start.kind === 'startEvent' && start.formFields).toEqual([ - { id: 'reason', type: 'string' }, - ]); + it("the process's own start keeps both", async () => { + const { node, warnings } = await importOnly( + operatonDoc` + ${START_FORM} + + + `, + 'startEvent', + ); + expect(node).toEqual({ + kind: 'startEvent', + id: 'S', + initiator: 'who', + formFields: [{ id: 'reason', type: 'string' }], + }); expect(warnings).toEqual([]); }); }); @@ -7435,7 +9952,9 @@ ${validation(` constraints: ` `, name: 'required', - detail: /once per field/, + detail: + 'DefaultFormHandler.parseValidation deploys both, and this script ' + + 'holds each constraint once per field', }, ])('refuses $case', async ({ constraints, name, detail }) => { const err = await expectRefusal( @@ -7582,6 +10101,54 @@ ${validation(' `, + imported: { id: 'amount', type: 'number', defaultValue: '1.5' }, + warnings: [ + reviewWarning( + /^The default '1\.5' on form field 'amount' of 'Review' was imported as written.*LongFormType\.convertValue.*Long\.valueOf/, + ), + ], + }, + { + case: 'a word default on a boolean field is carried and the script draws an error', + field: ` `, + imported: { id: 'flag', type: 'boolean', defaultValue: 'maybe' }, + warnings: [ + reviewWarning( + /^The default 'maybe' on form field 'flag' of 'Review' was imported as written.*BooleanFormType\.convertValue.*Boolean\.valueOf/, + ), + ], + }, + { + case: 'an uppercase boolean default converts the same as lowercase, since Boolean.valueOf reads it case-insensitively', + field: ` `, + imported: { id: 'flag', type: 'boolean', defaultValue: 'TRUE' }, + warnings: [], + }, + { + case: 'an ISO default on a date field with no pattern is carried and the script draws an error', + field: ` `, + imported: { id: 'due', type: 'date', defaultValue: '2026-01-01' }, + warnings: [ + reviewWarning( + /^The default '2026-01-01' on form field 'due' of 'Review' was imported as written.*DateFormType.*dd\/MM\/yyyy/, + ), + ], + }, + { + case: 'an expression default on a number field is left to the render that evaluates it', + field: ` `, + imported: { id: 'amount', type: 'number', defaultValue: '${x}' }, + warnings: [], + }, + { + case: 'an empty label and an empty default are kept as written', + field: ` `, + imported: { id: 'note', type: 'string', label: '', defaultValue: '' }, + warnings: [], + }, { case: 'an enum default that is an expression is not checked against the values', field: ` @@ -7787,47 +10354,99 @@ ${extensionElements(errorMapping('errorRef="Err_Timeout" expression="${false}"') expect(warnings).toEqual([]); }); + const CODED_ROOT = + ' \n'; + const BLANK_ROOT = ' \n'; + + /** `Charge` beside the given error roots, carrying the given mapping. */ + const mappingDoc = (mapping: string, roots = CODED_ROOT): string => + operatonDefs`${roots} + +${externalTask('', mapping)} + + `; + + // Revert: refuse a dangling `errorRef` in `readErrorMapping` again, and the + // dangling row refuses; drop the no-`errorRef` warning and skip, and that + // row refuses too. it.each([ [ 'a mapping with no expression', errorMapping('errorRef="Err_Declined"'), /no expression/, ], + [ + 'a mapping whose errorRef names no root and carries no expression, which the engine checks before the root', + errorMapping('errorRef="Err_Missing"'), + /no expression/, + ], [ 'a mapping whose errorRef names a root with no code', errorMapping('errorRef="Err_Blank" expression="${true}"'), /'Err_Blank'.*no code/, ], + ])('%s is refused', async (_title, mapping, detail) => { + const err = await expectRefusal( + xmlToIr(mappingDoc(mapping, CODED_ROOT + BLANK_ROOT)), + UnsupportedErrorMappingError, + detail, + ); + expect(err.elementId).toBe('Charge'); + expect(err.message).toContain('parseOperatonErrorEventDefinitions'); + }); + + const SKIPPED_MAPPING_WARNING: ImportWarning = { + elementId: 'Charge', + category: 'unmappedConstruct', + message: + "The operaton:errorEventDefinition on 'Charge' carries no errorRef " + + 'and was not imported: BpmnParse.parseOperatonErrorEventDefinitions ' + + 'skips one without it, so the document written back runs the same.', + }; + + it.each([ [ - 'a mapping with no errorRef', + 'a mapping with no errorRef is skipped with a warning, as the engine skips it', errorMapping('expression="${true}"'), - /names no error root/, + undefined, + [SKIPPED_MAPPING_WARNING], ], [ - 'a mapping with neither attribute', + 'a mapping with neither attribute is skipped the same way', errorMapping(''), - /names no error root/, + undefined, + [SKIPPED_MAPPING_WARNING], ], [ - 'a mapping whose errorRef names no root in the document', + "a mapping whose errorRef names no root imports the reference text as the code, the engine's reading", errorMapping('errorRef="Err_Missing" expression="${true}"'), - /'Err_Missing'.*names no error root/, + [{ errorCode: 'Err_Missing', condition: '${true}' }], + [ + { + elementId: 'Charge', + category: 'unmappedConstruct', + message: + "The errorRef 'Err_Missing' on 'Charge' names no bpmn:error " + + "root and imports as the code 'Err_Missing': Operaton takes a " + + "dangling reference's text as the code " + + '(BpmnParse.parseBoundaryErrorEventDefinition, ' + + 'parseErrorStartEventDefinition, parseEndEvents, ' + + 'parseOperatonErrorEventDefinitions), and the document written ' + + 'back declares an error root carrying it.', + }, + ], ], - ])('%s is refused', async (_title, mapping, detail) => { - const xml = operatonDefs` - - - -${externalTask('', mapping)} - - `; - const err = await expectRefusal( - xmlToIr(xml), - UnsupportedErrorMappingError, - detail, + ] as const)('%s', async (_title, mapping, errorMappings, expected) => { + const { node, warnings } = await importById( + mappingDoc(mapping), + 'Charge', + 'serviceTask', ); - expect(err.elementId).toBe('Charge'); - expect(err.message).toContain('parseOperatonErrorEventDefinitions'); + expect(node.binding).toEqual({ + ...externalBinding('charge-card'), + ...(errorMappings === undefined ? {} : { errorMappings }), + }); + expect(warnings).toEqual(expected); }); it.each([ @@ -7932,41 +10551,24 @@ ${errorMapping('errorRef="Err_Declined" expression="${true}"')}`)}\n', body: ` @@ -7975,7 +10577,7 @@ ${errorMapping('errorRef="Err_Declined" expression="${true}"')}`)} ${extensionElements(propertiesOf(' '))}`, binding: undefined, - warnings: [ - warning( - 'Review', - /Extra configuration \(operaton:Properties\) on 'Review'/, - ), - ], + warnings: [unread('operaton:properties', 'Review', 'a ')], }, ])('$case', async ({ roots = '', body, binding, warnings: expected }) => { // The clean external task first, the offending element last: a warning @@ -8012,6 +10609,22 @@ ${body} } expect(warnings).toEqual(expected); }); + + // Revert: carry the priority as written in withExternalExtras, and both + // rows import with a warning instead of refusing. + it.each([ + ['a decimal task priority', '1.5'], + ['a task priority opening with a digit before its expression', '1 ${x}'], + ])('%s refuses, quoting parsePriority', async (_title, value) => { + const err = await expectRefusal( + xmlToIr( + codedErrorsDoc(externalTask(`operaton:taskPriority="${value}"`, '')), + ), + UnsupportedExtensionFormError, + priorityRefusal('taskPriority', value), + ); + expect(err.elementId).toBe('Charge'); + }); }); /** @@ -8046,16 +10659,6 @@ ${body} `); it.each([ - { - case: 'a parameter carrying both body text and a nested value', - on: 'Svc', - children: ioBlock( - ` text`, - ), - detail: - "operaton:inputParameter 'x' carries both body text and a nested " + - ' value, and a value is one or the other', - }, { case: 'a parameter carrying two nested values', on: 'Svc', @@ -8066,14 +10669,6 @@ ${body} "operaton:inputParameter 'x' carries 2 nested values (operaton:List, " + 'operaton:Map), and a value is one', }, - { - case: 'a map entry carrying both body text and a nested value', - on: 'Svc', - children: nested(` - text - `), - detail: /operaton:entry 'k'.*both body text and a nested/, - }, { case: 'a map entry carrying two nested values', on: 'Svc', @@ -8126,6 +10721,28 @@ ${body} "the operaton:script in operaton:inputParameter 'x' has no " + 'scriptFormat, so there is no language to evaluate its body in', }, + { + case: 'a script value whose body holds three backticks', + on: 'Svc', + children: nested( + ' x = "```"', + ), + detail: + "the operaton:script in operaton:inputParameter 'x' contains three " + + 'consecutive backticks, which no script fence this language has ' + + 'can enclose', + }, + { + case: 'a listener script whose body holds three backticks', + on: 'Svc', + children: ` + x = "\`\`\`" + `, + detail: + 'the operaton:script in an operaton:executionListener contains ' + + 'three consecutive backticks, which no script fence this language ' + + 'has can enclose', + }, { case: 'an operaton:entry inside an operaton:list', on: 'Svc', @@ -8153,13 +10770,49 @@ ${body} children: ioBlock(` 1 2`), - detail: /two operaton:inputParameter children share name="x"/, + detail: + /two operaton:inputParameter children share name="x"; Operaton runs both, the last write winning \(IoMapping\.executeInputParameters\)/, }, { - case: 'an injected field naming no value slot', + case: 'two output parameters sharing a name', on: 'Svc', - children: ` `, - detail: "the injected field 'greeting' on 'Svc' names no value", + children: + ioBlock(` 1 + 2`), + detail: + /two operaton:outputParameter children share name="x"; Operaton runs both, the last write winning \(IoMapping\.executeOutputParameters\)/, + }, + // A variable the engine sets under a name the script cannot spell: + // renaming it would change what runs, so the document is refused. + { + case: 'an input parameter whose name the script cannot spell', + on: 'Svc', + children: ioBlock( + ` 1`, + ), + detail: + "operaton:inputParameter 'my.param' names a variable the script " + + "cannot spell (a name is letters, digits and '_', with '-' " + + 'between them, and no keyword), and the engine sets the variable ' + + 'under that name, so writing another would change what runs', + }, + { + case: 'a form field whose id the script cannot spell', + on: 'Review', + children: ` + + `, + detail: + "operaton:formField 'my.field' names a variable the script " + + "cannot spell (a name is letters, digits and '_', with '-' " + + 'between them, and no keyword), and the engine sets the variable ' + + 'under that name, so writing another would change what runs', + }, + { + case: 'an injected field naming no value slot', + on: 'Svc', + children: ` `, + detail: "the injected field 'greeting' on 'Svc' names no value", }, { case: 'an injected field naming an attribute and a child of the same slot', @@ -8179,22 +10832,22 @@ ${body} 'what it runs', }, { - case: 'a listener carrying two attribute bindings', + case: 'an execution listener with class=""', on: 'Svc', - children: ` `, + children: ` `, detail: - 'an operaton:executionListener carries 2 bindings (class, ' + - 'expression), and a listener names exactly one', + 'an operaton:executionListener has class="", which ' + + "BpmnParse.parseExecutionListener refuses (\"Attribute 'class' " + + 'cannot be empty")', }, { - case: 'a listener carrying an attribute binding and a script child', + case: 'an execution listener with delegateExpression=""', on: 'Svc', - children: ` - 1 - `, + children: ` `, detail: - 'an operaton:executionListener carries 2 bindings (class, an ' + - 'operaton:script child), and a listener names exactly one', + 'an operaton:executionListener has delegateExpression="", which ' + + 'BpmnParse.parseExecutionListener refuses ("Attribute ' + + "'delegateExpression' cannot be empty\")", }, { case: 'a listener with no event', @@ -8210,19 +10863,19 @@ ${body} children: ` `, detail: /event="take".*start, end/, }, - { - case: 'two execution listeners sharing an event', - on: 'Svc', - children: ` - `, - detail: /two operaton:executionListener children share event="start"/, - }, { case: 'a task listener whose event is not one of the six task events', on: 'Review', children: ` `, detail: - /event="start".*create, assign, complete, update, delete, timeout/, + /event="start".*create, assignment, complete, update, delete, timeout/, + }, + { + case: "a task listener spelling the assignment event 'assign', which BpmnParse.parseTaskListeners refuses", + on: 'Review', + children: ` `, + detail: + /event="assign".*create, assignment, complete, update, delete, timeout/, }, { case: 'a timeout task listener with no timer', @@ -8232,23 +10885,6 @@ ${body} 'an operaton:taskListener with event="timeout" carries no ' + 'bpmn:timerEventDefinition, so nothing would ever fire it', }, - { - case: 'a task listener carrying a timer on any other event', - on: 'Review', - children: ` - - PT1H - - `, - detail: /event="create".*only a timeout listener/, - }, - { - case: 'two task listeners sharing an event', - on: 'Review', - children: ` - `, - detail: /share event="create"/, - }, ] as const)( '$case is refused, naming the shape and the element', async (row) => { @@ -8397,6 +11033,7 @@ describe('xmlToIr: task kinds', () => { 'operaton:class="com.example.Rate" ' + 'operaton:decisionRefBinding="latest" ' + 'operaton:decisionRefVersion="3" ' + + 'operaton:decisionRefTenantId="acme" ' + 'operaton:mapDecisionResult="singleEntry"', }), 'serviceTask', @@ -8404,10 +11041,30 @@ describe('xmlToIr: task kinds', () => { expect(warnings.map((w) => w.message)).toEqual([ "The 'decisionRefBinding' setting on 'T' has no effect without an operaton:decisionRef and was not imported.", "The 'decisionRefVersion' setting on 'T' has no effect without an operaton:decisionRef and was not imported.", + "The 'decisionRefTenantId' setting on 'T' has no effect without an operaton:decisionRef and was not imported.", "The 'mapDecisionResult' setting on 'T' has no effect without an operaton:decisionRef and was not imported.", ]); }); + it('a decision-bound business rule task naming a tenant refuses, as the call activity does', async () => { + const err = await expectRefusal( + xmlToIr( + oneNodeDoc('businessRuleTask', { + attrs: + 'operaton:decisionRef="riskRating" operaton:decisionRefTenantId="acme"', + }), + ), + UnsupportedServiceTaskFormError, + ); + expect([err.serviceTaskId, err.construct]).toEqual([ + 'T', + 'it names operaton:decisionRefTenantId="acme", which pins the tenant ' + + 'BpmnParse.parseTenantId resolves the decision against; dropping it ' + + 'would change which decision runs, and this surface has no tenant ' + + 'setting', + ]); + }); + it('a decision-bound business rule task warns about the implementation it drops', async () => { const { node, warnings } = await importOnly( oneNodeDoc('businessRuleTask', { @@ -8443,9 +11100,10 @@ describe('xmlToIr: task kinds', () => { 'the engine cannot resolve which version to use', ], [ - 'an unrecognized decisionRefBinding', + 'decisionRefBinding="versionTag"', 'operaton:decisionRefBinding="versionTag"', - 'decisionRefBinding="versionTag" is not a binding this tool can represent', + 'decisionRefBinding="versionTag" pins a version tag, which this ' + + 'surface has no setting for', ], ])( 'a business rule task with %s refuses rather than importing without it', @@ -8574,6 +11232,53 @@ describe('xmlToIr: task kinds', () => { expect(warnings).toEqual([MANUAL_TASK_WARNING]); }); + // Revert: refuse a definition-less intermediate throw again, and the + // import rejects instead of returning the step. + // Revert: drop readIoMapping from the none-throw arm, and the step loses + // its parameter under a second, residual warning. + it('an intermediate throw with no definition (the Modeler milestone) imports as a plain step carrying its io mapping, prints and re-parses, warning that history will report it as a task', async () => { + const { ir, warnings } = await xmlToIr( + oneNodeDoc('intermediateThrowEvent', { + id: 'M', + attrs: 'name="Milestone reached" operaton:asyncBefore="true"', + children: extensionElements( + ioBlock( + ' reached', + ), + ), + }), + ); + expect(byId(ir, 'M')).toEqual({ + kind: 'task', + id: 'M', + name: 'Milestone reached', + asyncBefore: true, + inputParameters: [ioParam('note', textValue('reached'))], + }); + expect(warnings).toEqual([ + { + elementId: 'M', + category: 'unmappedConstruct', + message: + "The bpmn:intermediateThrowEvent 'M' carries no event definition " + + 'and imports as a plain step: BpmnParse.parseIntermediateThrowEvent ' + + 'gives it IntermediateThrowNoneEventActivityBehavior, which only ' + + "leaves, as a task's behaviour does, so token flow, listeners, " + + 'async and job configuration are unchanged, but history and ' + + "Cockpit will report its activity type as 'task' rather than " + + "'intermediateThrowEvent'.", + }, + ]); + + const { source } = irToDsl(ir); + expect(source).toContain( + 'step M(label: "Milestone reached", asyncBefore: true)', + ); + const doc = await parse(source, { validation: true }); + expect(doc.parseResult.parserErrors).toEqual([]); + expect(doc.diagnostics ?? []).toEqual([]); + }); + it('a document holding all four kinds imports with no warnings at all', async () => { const ioBlock = (name: string, value: string) => ` @@ -8586,7 +11291,7 @@ describe('xmlToIr: task kinds', () => { const xml = operatonDefs`${ORDER_PAID_ROOT} ${ioBlock('note', 'recorded')} - ${ioBlock('to', 'ops@example.com')} + ${ioBlock('to', 'ops@example.com')} ${ioBlock('reference', 'INV-1')} ${ioBlock('applicant', 'acme')} @@ -8990,6 +11695,116 @@ describe('xmlToIr: an either-branch split', () => { expect(only(subProcess(ir, 'Sub'), 'inclusiveGateway').id).toBe('SubFork'); expect(warnings).toEqual([]); }); + + /** `S -> G -> {A, B, C} -> E`, `G` and its three routes written by the row. */ + const xorDoc = (gateway: string, routes: string): string => + bpmnDefs` + + ${gateway} + + + + + +${routes} + + + + `; + const route = (id: string, target: string, condition?: string): string => + condition === undefined + ? ` ` + : ` + ${condition} + `; + + it.each([ + [ + 'no outgoing flow', + '', + '', + "Exclusive Gateway 'G' has no outgoing sequence flows.", + ], + [ + 'one outgoing flow carrying a condition', + '', + route('fA', 'A', '${a}'), + "Exclusive Gateway 'G' has only one outgoing sequence flow ('fA'). " + + 'This is not allowed to have a condition.', + ], + [ + 'a default carrying a condition', + '', + [route('fA', 'A', '${a}'), route('fB', 'B')].join('\n'), + "Exclusive Gateway 'G' has outgoing sequence flow 'fA' which is the " + + 'default flow but has a condition too.', + ], + [ + 'a default beside an unconditioned route that is not it', + '', + [route('fA', 'A', '${a}'), route('fB', 'B'), route('fC', 'C')].join('\n'), + "Exclusive Gateway 'G' has outgoing sequence flow 'fC' without " + + 'condition which is not the default flow.', + ], + [ + 'a default naming a flow that leaves another node', + '', + [route('fA', 'A', '${a}'), route('fB', 'B')].join('\n'), + "Exclusive Gateway 'G' has outgoing sequence flow 'fB' without " + + 'condition which is not the default flow.', + ], + [ + 'two unconditioned routes and no default', + '', + [route('fA', 'A'), route('fB', 'B')].join('\n'), + "Exclusive Gateway 'G' has outgoing sequence flow 'fA' without " + + 'condition which is not the default flow.', + ], + [ + 'an empty condition body beside an unconditioned route, which the engine counts as conditioned', + '', + [route('fA', 'A', ''), route('fB', 'B'), route('fC', 'C')].join('\n'), + "Exclusive Gateway 'G' has outgoing sequence flow 'fB' without " + + 'condition which is not the default flow.', + ], + ])( + 'an exclusive gateway with %s refuses with the sentence validateExclusiveGateway fails the deployment with', + async (_title, gateway, routes, detail) => { + const e = await expectRefusal( + xmlToIr(xorDoc(gateway, routes)), + UnsupportedGatewayShapeError, + detail, + ); + expect(e.elementId).toBe('G'); + expect(e.message).toContain('BpmnParse.validateExclusiveGateway'); + }, + ); + + // Revert: read the dropped `default` reference without `|| undefined`, and + // the `default=""` row refuses the plain route as not the default. + it.each([ + ['no default', ''], + [ + 'default="", which validateExclusiveGateway reads as none', + '', + ], + ])( + 'an exclusive gateway with one conditioned and one plain route and %s imports, as the engine assumes the plain one is the default', + async (_title, gateway) => { + const { ir, warnings } = await xmlToIr( + xorDoc( + gateway, + [ + route('fA', 'A', '${a}'), + route('fB', 'B'), + route('fC', 'C', '${c}'), + ].join('\n'), + ), + ); + expect(byId(ir, 'G')).toEqual({ kind: 'exclusiveGateway', id: 'G' }); + expect(warnings).toEqual([]); + }, + ); }); describe('xmlToIr: a fallback route named on a step', () => { @@ -9012,26 +11827,41 @@ ${element} `; - it('reports the fallback named on a step, which no reader carries, and names the route', async () => { - const { node, warnings } = await importOnly( - stepDoc(' '), - 'userTask', - ); - - expect(node).toEqual({ kind: 'userTask', id: 'Triage' }); - const warning = expectOneWarning(warnings, { - elementId: 'Triage', - category: 'unmappedConstruct', - message: /The 'default' attribute on 'Triage' was not imported/, - }); - expect(warning.message).toContain("route ('F2')"); - expect(warning.message).toContain( - 'when no other route out of the step is taken', - ); - }); + it.each([ + [ + 'a task', + ' ', + { kind: 'userTask', id: 'Triage', defaultFlowId: 'F2' }, + ], + [ + 'a call activity', + ' ', + { + kind: 'callActivity', + id: 'Triage', + calledElement: 'other', + defaultFlowId: 'F2', + }, + ], + [ + 'an inclusive gateway', + ' ', + { kind: 'inclusiveGateway', id: 'Triage', defaultFlowId: 'F2' }, + ], + ] as const)( + 'the default named on %s carries as its defaultFlowId when it is one of its own routes', + async (_title, element, carried) => { + const { node, warnings } = await importOnly( + stepDoc(element), + carried.kind, + ); + expect(node).toEqual(carried); + expect(warnings).toEqual([]); + }, + ); - it('reports it on a block and on a step nested in one, one warning each', async () => { - const { warnings } = await xmlToIr( + it('carries it on a block and on a step nested in one', async () => { + const { ir, warnings } = await xmlToIr( stepDoc( ` @@ -9049,36 +11879,60 @@ ${element} ), ); - expect(warnings.map((w) => [w.elementId, w.category])).toEqual([ - ['Inner', 'unmappedConstruct'], - ['Triage', 'unmappedConstruct'], - ]); - }); - - it('reports it on a call activity, the kind that carries it furthest from a task', async () => { - const { warnings } = await xmlToIr( - stepDoc( - ' ', - ), - ); - - expectOneWarning(warnings, { - elementId: 'Triage', - category: 'unmappedConstruct', - message: /The 'default' attribute on 'Triage' was not imported/, + expect(byId(ir, 'Triage')).toEqual({ + kind: 'subProcess', + id: 'Triage', + defaultFlowId: 'F2', + flowElements: [ + { kind: 'startEvent', id: 'SubS' }, + { kind: 'userTask', id: 'Inner', defaultFlowId: 'SF2' }, + { kind: 'endEvent', id: 'SubE1' }, + { kind: 'endEvent', id: 'SubE2' }, + ], + sequenceFlows: [ + { id: 'SF0', sourceRef: 'SubS', targetRef: 'Inner' }, + { + id: 'SF1', + sourceRef: 'Inner', + targetRef: 'SubE1', + conditionExpression: '${a}', + }, + { + id: 'SF2', + sourceRef: 'Inner', + targetRef: 'SubE2', + conditionExpression: '${b}', + }, + ], }); + expect(warnings).toEqual([]); }); - it('says nothing about the two split kinds that do carry it', async () => { - for (const tag of ['exclusiveGateway', 'inclusiveGateway'] as const) { - const { ir, warnings } = await xmlToIr( - stepDoc(` `), + it.each([ + ['a flow that enters the step', 'F0'], + ['a flow the document does not hold', 'F9'], + ])( + 'a default naming %s is dropped with the warning handleNoTransitions earns it', + async (_title, named) => { + const { node, warnings } = await importOnly( + stepDoc(` `), + 'userTask', ); - - expect(byId(ir, 'Triage')).toMatchObject({ defaultFlowId: 'F2' }); - expect(warnings).toEqual([]); - } - }); + expect(node).toEqual({ kind: 'userTask', id: 'Triage' }); + expect(warnings).toEqual([ + { + elementId: 'Triage', + category: 'unmappedConstruct', + message: + `The 'default' attribute on 'Triage' was not imported: it names '${named}', ` + + 'which is not a route out of the step, so ' + + 'BpmnActivityBehavior.handleNoTransitions finds no flow to take ' + + 'and fails the step whenever no other route holds; the imported ' + + 'step names no fallback.', + }, + ]); + }, + ); }); describe('xmlToIr: a wait with several branches', () => { @@ -9361,3 +12215,447 @@ ${branches expect(e.elementId).toBe('OnLink'); }); }); + +/** + * Branches a coverage sweep found no row asserting: each pins the current + * wording (or the current warning list) of one refusal or warning arm named + * elsewhere in this file's own tables. A row's title names the one-line + * source change that would turn it red. + */ +describe('xmlToIr: refusal and warning wording', () => { + const potentialOwnerXml = ( + id: string, + expression: string, + extra = '', + ): string => + oneNodeDoc('userTask', { + id: 'Review', + children: ` + + + ${expression} + + ${extra} + `, + }); + + const candidateUsersWarning = (id: string, users: string) => ({ + elementId: 'Review', + category: 'unmappedConstruct' as const, + message: + `The bpmn:potentialOwner '${id}' on 'Review' imports as ` + + `candidateUsers: "${users}": Operaton reads its formal expression ` + + 'that way (BpmnParse.parsePotentialOwnerResourceAssignment), and ' + + 'this tool writes it back as operaton:candidateUsers, which the ' + + 'engine reads the same.', + }); + + const rows: [title: string, run: () => Promise][] = [ + [ + 'a document with no bpmn:process refuses, since there is nothing to import', + async () => { + await expectRefusal( + xmlToIr(bpmnDefs` `), + UnsupportedDocumentError, + 'it holds no bpmn:process, so there is nothing to import', + ); + }, + ], + [ + 'two bpmn:Error roots sharing a code, both messageless, merge into one declaration', + async () => { + const { ir, warnings } = await xmlToIr( + rootedDoc( + ' \n' + + ' \n', + ' \n' + + ' ', + operatonDefs, + ), + ); + expect(ir.errorDecls).toEqual([{ name: 'DUP', code: 'DUP' }]); + expect(warnings).toEqual([]); + }, + ], + [ + 'two bpmn:Error roots sharing a code, only the second carrying a message, merge with it', + async () => { + const { ir, warnings } = await xmlToIr( + rootedDoc( + ' \n' + + ' \n', + ' \n' + + ' ', + operatonDefs, + ), + ); + expect(ir.errorDecls).toEqual([ + { name: 'DUP', code: 'DUP', message: 'Oops' }, + ]); + expect(warnings).toEqual([]); + }, + ], + [ + 'two bpmn:Error roots sharing a code and agreeing on the message merge without refusing', + async () => { + const { ir, warnings } = await xmlToIr( + rootedDoc( + ' \n' + + ' \n', + ' \n' + + ' ', + operatonDefs, + ), + ); + expect(ir.errorDecls).toEqual([ + { name: 'DUP', code: 'DUP', message: 'Oops' }, + ]); + expect(warnings).toEqual([]); + }, + ], + [ + 'an unnamed, unreferenced bpmn:message root warns that it cannot be keyed, and is dropped', + async () => { + const { warnings } = await xmlToIr( + rootedDoc( + ' \n', + ' \n' + + ' ', + ), + ); + expect(warnings).toEqual([ + { + elementId: 'M1', + category: 'unreferencedRoot', + message: + "The message root 'M1' has no name, so it cannot be keyed " + + 'or represented in the model; it was not imported.', + }, + ]); + }, + ], + [ + 'operaton:in variables="all" local="true" imports the local flag beside all, and prints "in local *"', + async () => { + const xml = oneNodeDoc('callActivity', { + id: 'CallSub', + attrs: 'calledElement="sub-process"', + children: extensionElements( + '', + ), + }); + const { ir, warnings } = await xmlToIr(xml); + expect(only(ir, 'callActivity').inMappings).toEqual([ + { kind: 'all', local: true }, + ]); + expect(warnings).toEqual([]); + + const printed = irToDsl(ir).source; + expect(printed).toContain('in local *'); + const doc = await parse(printed, { validation: true }); + expect(doc.parseResult.parserErrors).toEqual([]); + expect(doc.diagnostics ?? []).toEqual([]); + }, + ], + [ + 'operaton:type="mail" with no fields at all refuses the same as one missing "to"', + async () => { + const e = await expectRefusal( + xmlToIr(oneNodeDoc('serviceTask', { attrs: 'operaton:type="mail"' })), + UnsupportedServiceTaskFormError, + ); + expect(e.subject).toBe('Service task'); + expect(e.construct).toBe( + 'operaton:type="mail" without a \'to\' field, which Operaton ' + + 'refuses to deploy: "No recipient is defined on the mail ' + + 'activity" (BpmnParse.validateFieldDeclarationsForEmail)', + ); + }, + ], + [ + 'a timeout listener with two timer event definitions refuses, since a timeout has one due time', + async () => { + const xml = oneNodeDoc('userTask', { + id: 'Review', + children: extensionElements( + ` + + PT8H + + + PT1H + + `, + ), + }); + // No binding attribute on the listener: this refusal fires reading + // its timer, before a binding is ever resolved, so it pins none of + // that wording. + await expectRefusal( + xmlToIr(xml), + UnsupportedExtensionFormError, + 'an operaton:taskListener with event="timeout" carries 2 ' + + 'bpmn:timerEventDefinition children, and a timeout has one due time', + ); + }, + ], + [ + 'a single operaton:value on a non-enum field warns in the singular', + async () => { + const { warnings } = await importForm( + ` + + `, + ); + expect(warnings).toEqual([ + { + elementId: 'Review', + category: 'extensionAttribute', + message: + "The 1 operaton:value child of form field 'note' of " + + "'Review' was not imported; FormTypes.parseFormPropertyType " + + 'reads them on an enum field alone.', + }, + ]); + }, + ], + [ + 'a timer boundary on an isForCompensation handler is not the paired pattern, and refuses with the general wording', + async () => { + const xml = operatonDoc` + + + + PT1H + + + + + + + `; + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedEventFeatureError, + IS_FOR_COMPENSATION_DETAIL, + ); + expect(e.elementId).toBe('CancelReservation'); + }, + ], + [ + 'a bpmn:association from a plain task to an isForCompensation handler is not the paired pattern either', + async () => { + const xml = operatonDoc` + + + + + + + `; + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedEventFeatureError, + IS_FOR_COMPENSATION_DETAIL, + ); + expect(e.elementId).toBe('CancelReservation'); + }, + ], + [ + 'a sub-process compensated host nests correctly in the rewrite preview, and the preview re-parses through the compiler', + async () => { + const xml = operatonDoc` + + + + + + + + + + + + + + + `; + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedEventFeatureError, + ); + expect(e.elementId).toBe('CompensationBoundary'); + const marker = 'Write it by hand instead:\n\n'; + const cut = e.detail.indexOf(marker); + expect(cut).toBeGreaterThan(-1); + const rewrite = e.detail.slice(cut + marker.length); + expect(rewrite).toBe( + [ + 'subprocess Compensated_ReserveRoom {', + ' subprocess ReserveRoom {', + ' start RStart', + ' user Hold', + ' end REnd', + ' }', + ' on compensation {', + ' user CancelReservation', + ' }', + '}', + ].join('\n'), + ); + + const wrapped = `process Preview {\n${rewrite}\n}\n`; + const doc = await parse(wrapped, { validation: true }); + expect(doc.parseResult.parserErrors).toEqual([]); + expect(doc.diagnostics ?? []).toEqual([]); + }, + ], + [ + 'a wait branch that leads straight into a bpmn:transaction refuses, naming it "an attempt block"', + async () => { + const xml = bpmnDoc` + + + + P3D + + + + + + + + + + + + + + + `; + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedEventFeatureError, + ); + expect(e.elementId).toBe('Attempt'); + expect(e.detail).toBe( + "a branch of the wait 'Wait' leads to 'Attempt', an attempt " + + 'block; every branch of a wait with several branches has to ' + + 'begin with something to wait for, and only a message, a ' + + 'timer, a signal, or a condition counts as one here', + ); + }, + ], + [ + 'a potential owner naming users only imports candidateUsers alone', + async () => { + const { node, warnings } = await importOnly( + potentialOwnerXml('PO_Users', 'user(alice), user(bob)'), + 'userTask', + ); + expect(node).toEqual({ + kind: 'userTask', + id: 'Review', + candidateUsers: 'alice,bob', + }); + expect(warnings).toEqual([ + candidateUsersWarning('PO_Users', 'alice,bob'), + ]); + }, + ], + [ + 'a potential owner expression with a trailing comma draws no empty candidate', + async () => { + const { node, warnings } = await importOnly( + potentialOwnerXml('PO_Trailing', 'user(alice), user(bob),'), + 'userTask', + ); + expect(node).toEqual({ + kind: 'userTask', + id: 'Review', + candidateUsers: 'alice,bob', + }); + expect(warnings).toEqual([ + candidateUsersWarning('PO_Trailing', 'alice,bob'), + ]); + }, + ], + [ + 'a potential owner carrying resourceParameterBindings imports the expression and warns about the rest', + async () => { + const { node, warnings } = await importOnly( + potentialOwnerXml( + 'PO_Bindings', + 'user(alice)', + '', + ), + 'userTask', + ); + expect(node).toEqual({ + kind: 'userTask', + id: 'Review', + candidateUsers: 'alice', + }); + expect(warnings).toEqual([ + candidateUsersWarning('PO_Bindings', 'alice'), + { + elementId: 'Review', + category: 'unmappedConstruct', + message: + 'The resourceParameterBindings on the bpmn:potentialOwner ' + + "'PO_Bindings' on 'Review' was not imported: Operaton reads " + + "the role's formal expression alone " + + '(BpmnParse.parsePotentialOwnerResourceAssignment).', + }, + ]); + }, + ], + [ + 'an unsupported element with no id is refused with no "(id=...)" clause', + async () => { + const xml = bpmnDoc` + + + `; + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedElementError, + ); + expect(e.qname).toBe('bpmn:ComplexGateway'); + expect(e.elementId).toBeUndefined(); + expect(e.message).toBe( + 'The BPMN element bpmn:ComplexGateway is a kind that this tool ' + + 'cannot import. Only start/end events, throws, emits, boundary ' + + 'events, event handlers, plain tasks, user tasks, service ' + + 'tasks, send tasks, receive tasks, business rule tasks, script ' + + 'tasks, exclusive gateways, parallel gateways, inclusive ' + + 'gateways, event-based gateways, embedded subprocesses, ' + + 'attempt blocks, call activities, and sequence flows are ' + + 'supported.', + ); + }, + ], + [ + 'a start carrying the abstract bpmn:eventDefinition tag names it a "special" definition', + async () => { + const xml = bpmnDoc` + + + + `; + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedEventDefinitionError, + ); + expect(e.definitionType).toBe('bpmn:EventDefinition'); + expect(e.message).toContain( + 'a special definition (bpmn:EventDefinition)', + ); + }, + ], + ]; + + it.each(rows)('%s', async (_title, run) => { + await run(); + }); +}); diff --git a/tests/branch-and-race.round-trip.test.ts b/tests/branch-and-race.round-trip.test.ts index bda02cf..ddc29d5 100644 --- a/tests/branch-and-race.round-trip.test.ts +++ b/tests/branch-and-race.round-trip.test.ts @@ -80,8 +80,9 @@ describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { `race gateways differ in ${label}`, ).toBe(2); - // Only the forks name a default; a join has one incoming path per branch - // and nothing to fall back to. + // Only the first fork names a default: a join has one incoming path per + // branch and nothing to fall back to, and the second fork's unheaded + // branch always runs, so a fallback there could never be taken. const carried = ir.flowElements.filter( (fe) => fe.kind === 'inclusiveGateway' && fe.defaultFlowId !== undefined, @@ -89,10 +90,7 @@ describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { expect( carried.map((fe) => fe.id), `defaults differ in ${label}`, - ).toEqual([ - 'Gateway_order-handling_2_fork', - 'Gateway_order-handling_3_fork', - ]); + ).toEqual(['Gateway_order-handling_2_fork']); } }); @@ -115,9 +113,9 @@ describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { }); it('printing the imported IR reports nothing at all', () => { - // Every gateway of the artifact is nameless and every fork names its - // default, so neither the elided-label nor the invented-fallback warning - // has anything to report. + // Every gateway of the artifact is nameless, and every fork either names + // its default or has an unheaded branch that always runs, so neither the + // elided-label nor the invented-fallback warning has anything to report. expect(irToDsl(rt.ir2).warnings).toEqual([]); }); }); @@ -141,25 +139,27 @@ describe('gateway shape pins on the frozen .bpmn', () => { ]); }); - it('the fallback with a branch of its own points at it, the one without at the join', () => { + it('the one fallback points at its else branch, and the fork beside an unheaded branch names none', () => { expect(declaredDefaults(rt.frozenXml)).toEqual([ [ 'Gateway_order-handling_2_fork', 'Flow_Gateway_order-handling_2_fork_default', ], - [ - 'Gateway_order-handling_3_fork', - 'Flow_Gateway_order-handling_3_fork_default', - ], ]); - const byId = new Map(sequenceFlows(rt.frozenXml).map((f) => [f.id, f])); + const flows = sequenceFlows(rt.frozenXml); expect( - byId.get('Flow_Gateway_order-handling_2_fork_default')?.targetRef, + flows.find((f) => f.id === 'Flow_Gateway_order-handling_2_fork_default') + ?.targetRef, ).toBe('TriageOrder'); expect( - byId.get('Flow_Gateway_order-handling_3_fork_default')?.targetRef, - ).toBe('Gateway_order-handling_3_join'); + flows + .filter((f) => f.sourceRef === 'Gateway_order-handling_3_fork') + .map((f) => [f.targetRef, f.conditioned]), + ).toEqual([ + ['PrepareCustoms', true], + ['PrintLabel', false], + ]); }); it('each race gateway waits on one catch event per branch and weighs none of them', () => { diff --git a/tests/call-activity.round-trip.test.ts b/tests/call-activity.round-trip.test.ts index 9a6e238..ef53299 100644 --- a/tests/call-activity.round-trip.test.ts +++ b/tests/call-activity.round-trip.test.ts @@ -16,6 +16,7 @@ import type { ImportWarning, } from '@bpmn-script/transform'; +import { camundaAliasWarning } from './helpers/import-first.js'; import { normalizeIr } from './helpers/normalize-ir.js'; import { idsOf, subProcess as findSubProcess } from './helpers/ir-query.js'; import { @@ -433,8 +434,8 @@ describe('round-trip: import-first, with interleaved mappings and the camunda: b irSecondImport = astToIr(await parseToAst(dsl)); }); - it('imports the alias binding and the interleaved mappings with zero warnings', () => { - expect(importWarnings).toEqual([]); + it('imports the alias binding and the interleaved mappings with exactly the one namespace-alias warning', () => { + expect(importWarnings).toEqual([camundaAliasWarning('call-import-demo')]); const call = findCallActivity(irFirstImport, 'ReviewApprovalCall'); expect(call.binding).toEqual({ kind: 'deployment' }); expect(call.inMappings).toEqual([ diff --git a/tests/e2e/deploy-sweep.test.ts b/tests/e2e/deploy-sweep.test.ts new file mode 100644 index 0000000..958882f --- /dev/null +++ b/tests/e2e/deploy-sweep.test.ts @@ -0,0 +1,139 @@ +// Every golden, fixture and example on the engine, in one boot: once as the +// file is written, and once rebuilt from the tool's own print of it. The +// second table is where a defect of the first kind hides: a deployable file +// whose print compiles back to one the engine refuses. +// +// Each row deploys in a tenant of its own, because +// BpmnDeployer.addMessageStartEventSubscription refuses a second message start +// of one name across process definitions in one tenant, and two independent +// files here start on the same message. The same process key deployed twice +// is only a new version to the engine and needs nothing. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { basename, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { astToIr, irToXml, xmlToIr } from '@bpmn-script/transform'; + +import { buildExample, startFixture } from '../fixtures/index.js'; +import type { FixtureAdapter } from '../fixtures/index.js'; +import { + ENGINE_BOOT_TIMEOUT_MS, + ENGINE_STOP_TIMEOUT_MS, + SKIP_DOCKER as SKIP, +} from '../helpers/e2e-fixture.js'; +import { parseToAst, printDsl } from '../helpers/pipeline.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, '../..'); +const OUT_DIR = resolve(REPO_ROOT, 'out/deploy-sweep'); + +// The engine's message for each file it refuses on purpose, keyed by row. +// BpmnParse.validateServiceTaskLike: a service task without a binding. +const ENGINE_REFUSES: Record = { + 'golden-bad-service-task-no-binding': /is mandatory on serviceTask/, +}; + +// The files the importer refuses on purpose: the bindingless service task, +// and an error start event, which the DSL has no form for. +const IMPORT_REFUSES: ReadonlySet = new Set([ + 'golden-bad-service-task-no-binding', + 'fixture-error-start', +]); + +interface Row { + // `-`, since a golden and an example share basenames. + key: string; + xmlPath: string; + // Present for an example, which the CLI compiles into `xmlPath` first. + dslPath?: string; +} + +// A directory-driven sweep that finds nothing would pass by asserting nothing. +function filesUnder(dir: string, extension: string): string[] { + const files = readdirSync(dir) + .filter((file) => file.endsWith(extension)) + .sort(); + if (files.length === 0) { + throw new Error(`no ${extension} files found under ${dir}`); + } + return files; +} + +function bpmnRows(origin: string, dir: string): Row[] { + return filesUnder(dir, '.bpmn').map((file) => ({ + key: `${origin}-${basename(file, '.bpmn')}`, + xmlPath: resolve(dir, file), + })); +} + +const EXAMPLES_DIR = resolve(REPO_ROOT, 'examples/spring-boot/processes'); + +const ROWS: Row[] = [ + ...bpmnRows('golden', resolve(REPO_ROOT, 'tests/golden')), + ...bpmnRows('fixture', resolve(REPO_ROOT, 'tests/fixtures')), + ...filesUnder(EXAMPLES_DIR, '.bpmnscript').map((file) => { + const name = basename(file, '.bpmnscript'); + return { + key: `example-${name}`, + xmlPath: resolve(OUT_DIR, `${name}.bpmn`), + dslPath: resolve(EXAMPLES_DIR, file), + }; + }), +]; + +describe.skipIf(SKIP)('E2E: every golden, fixture and example deploys', () => { + let fixture: FixtureAdapter; + + beforeAll(async () => { + for (const row of ROWS) { + if (row.dslPath !== undefined) { + buildExample(row.dslPath, row.xmlPath); + } + } + fixture = await startFixture('spring-boot'); + }, ENGINE_BOOT_TIMEOUT_MS); + + afterAll(async () => { + await fixture?.stop(); + }, ENGINE_STOP_TIMEOUT_MS); + + it.each(ROWS)( + '$key deploys as written', + async ({ key, xmlPath }) => { + const name = `${key}-as-written`; + const refusal = ENGINE_REFUSES[key]; + if (refusal !== undefined) { + await expect(fixture.deploy(xmlPath, name, name)).rejects.toThrow( + refusal, + ); + return; + } + const { deploymentId } = await fixture.deploy(xmlPath, name, name); + expect(deploymentId).toBeTruthy(); + }, + 60_000, + ); + + it.each(ROWS)( + '$key deploys again after a trip through the printer and the compiler', + async ({ key, xmlPath }) => { + const xml = readFileSync(xmlPath, 'utf-8'); + if (IMPORT_REFUSES.has(key)) { + await expect(xmlToIr(xml)).rejects.toThrow(); + return; + } + const { ir } = await xmlToIr(xml); + const rebuilt = await irToXml(astToIr(await parseToAst(printDsl(ir)))); + const rebuiltPath = resolve(OUT_DIR, 'rebuilt', `${key}.bpmn`); + mkdirSync(dirname(rebuiltPath), { recursive: true }); + writeFileSync(rebuiltPath, rebuilt); + + const name = `${key}-rebuilt`; + const { deploymentId } = await fixture.deploy(rebuiltPath, name, name); + expect(deploymentId).toBeTruthy(); + }, + 60_000, + ); +}); diff --git a/tests/engine-attributes.round-trip.test.ts b/tests/engine-attributes.round-trip.test.ts index f1364a5..af907b1 100644 --- a/tests/engine-attributes.round-trip.test.ts +++ b/tests/engine-attributes.round-trip.test.ts @@ -163,11 +163,20 @@ describe('a gateway written without settings gains none at any hop', () => { }); describe('where a handler puts the settings written on it', () => { - it("a hosted handler's settings land on the boundary event", () => { + // The lock is written twice on purpose: `BpmnParse.parseTimer` reads it + // off the definition for the timer job, `parseAsynchronousContinuation` off + // the tag for the asyncAfter job. + it("a hosted handler's settings land on the boundary event, its lock on the timer definition too", () => { const boundary = theOnly(rt.ir2, 'boundaryEvent'); expect(boundary.attachedToRef).toBe('ApprovePayout'); expect(boundary.asyncAfter).toBe(true); expect(boundary.exclusive).toBe(false); + expect(rt.frozenXml).toContain( + '\n' + + ' Flow_Boundary_ApprovePayout_timer_NudgeApprover\n' + + ' ', + ); }); it("a host-less handler's settings land on the event sub-process, not on its trigger", () => { diff --git a/tests/event-handlers.round-trip.test.ts b/tests/event-handlers.round-trip.test.ts index 360c58d..4ba5b46 100644 --- a/tests/event-handlers.round-trip.test.ts +++ b/tests/event-handlers.round-trip.test.ts @@ -11,7 +11,10 @@ import { describeDiContainment, describeSingleDiagram, } from './helpers/di-bounds.js'; -import { describeImportFirst } from './helpers/import-first.js'; +import { + camundaAliasWarning, + describeImportFirst, +} from './helpers/import-first.js'; import { definitionOf, handlerTriggerDef, @@ -168,4 +171,5 @@ describeImportFirst( ); }); }, + [camundaAliasWarning('import-first')], ); diff --git a/tests/event-triggers.round-trip.test.ts b/tests/event-triggers.round-trip.test.ts index 978b797..9e81e5e 100644 --- a/tests/event-triggers.round-trip.test.ts +++ b/tests/event-triggers.round-trip.test.ts @@ -34,14 +34,12 @@ function timerExpressions(container: FlowContainer): string[] { .sort(); } -// Handwritten import-first. Two `bpmn:Signal` roots share a name but are -// referenced by different elements, one by the intermediate throw and one by the -// end event. Every task label differs from the name humanized from its id, so -// the importer keeps it. +// Handwritten import-first. One `bpmn:Signal` root is referenced twice, by the +// intermediate throw and by the end event. Every task label differs from the +// name humanized from its id, so the importer keeps it. const IMPORT_FIRST_BPMN = ` - - + Flow_Begin_Dispatch @@ -53,11 +51,11 @@ const IMPORT_FIRST_BPMN = ` Flow_Dispatch_Broadcast Flow_Broadcast_Done - + Flow_Broadcast_Done - + @@ -178,7 +176,7 @@ describe('root sharing on the frozen .bpmn', () => { }); describeImportFirst( - 'a handwritten .bpmn with two same-name signals round-trips', + 'a handwritten .bpmn throwing one signal twice round-trips', IMPORT_FIRST_BPMN, (first) => { it('recovers each trigger payload into the DSL surface', () => { @@ -188,10 +186,10 @@ describeImportFirst( expect(first.dsl).toContain('on condition(stockLevel < 5) {'); }); - it('both broadcasts resolve to the one collapsed signal name', () => { - const collapsed = { kind: 'signal', signalName: 'ParcelDispatched' }; - expect(definitionOf(first.ir, 'Broadcast')).toEqual(collapsed); - expect(definitionOf(first.ir, 'Done')).toEqual(collapsed); + it('both broadcasts resolve to the one signal name', () => { + const signal = { kind: 'signal', signalName: 'ParcelDispatched' }; + expect(definitionOf(first.ir, 'Broadcast')).toEqual(signal); + expect(definitionOf(first.ir, 'Done')).toEqual(signal); }); }, ); diff --git a/tests/examples.health.test.ts b/tests/examples.health.test.ts index f26957e..05559c4 100644 --- a/tests/examples.health.test.ts +++ b/tests/examples.health.test.ts @@ -2,15 +2,18 @@ // moment it lands. A few examples are also the only place a construct's // desugared shape is pinned without a golden fixture, so those keep a block of // their own below. +// The round-trip block below is the examples' counterpart of the golden pair +// suites' import path: same hops, over the directory instead of one fixture. import { describe, it, expect } from 'vitest'; import { readdirSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { astToIr, irToXml } from '@bpmn-script/transform'; +import { astToIr, irToDsl, irToXml, xmlToIr } from '@bpmn-script/transform'; -import { parse, validate } from './helpers/pipeline.js'; +import { parse, parseToAst, validate } from './helpers/pipeline.js'; +import { normalizeIr } from './helpers/normalize-ir.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -43,6 +46,48 @@ describe('every deployable example', () => { }); }); +describe("every example round-trips through the tool's own output without a word", () => { + it.each(EXAMPLES)( + "%s round-trips through the tool's own output without a word", + async (file) => { + const document = await parse( + readFileSync(resolve(PROCESSES_DIR, file), 'utf-8'), + ); + expect(document.parseResult.parserErrors).toEqual([]); + const ir1 = astToIr(document.parseResult.value); + + const xml1 = await irToXml(ir1); + const { ir: ir2, warnings: importWarnings } = await xmlToIr(xml1); + const { source: dslPrime, warnings: printWarnings } = irToDsl(ir2); + const ir3 = astToIr(await parseToAst(dslPrime)); + const { diagnostics } = await validate(dslPrime); + + // Mapped rather than compared raw: a failure then names the id and the + // category or message, not a range and offset nobody reads. + expect({ + importWarnings: importWarnings.map((w) => ({ + elementId: w.elementId, + category: w.category, + })), + printWarnings: printWarnings.map((w) => ({ + elementId: w.elementId, + category: w.category, + })), + dslPrimeDiagnostics: diagnostics.map((d) => ({ + severity: d.severity, + message: d.message, + })), + }).toEqual({ + importWarnings: [], + printWarnings: [], + dslPrimeDiagnostics: [], + }); + + expect(normalizeIr(ir3)).toEqual(normalizeIr(ir1)); + }, + ); +}); + describe('construct shapes pinned only by a deployable example', () => { it('awaiting-confirmation desugars the await into an intermediateCatchEvent carrying the message definition', async () => { // Desugaring decides what an `await` lowers to, so XML alone would miss it. diff --git a/tests/fixtures/adapters/spring-boot.ts b/tests/fixtures/adapters/spring-boot.ts index f2930f9..e9ab14e 100644 --- a/tests/fixtures/adapters/spring-boot.ts +++ b/tests/fixtures/adapters/spring-boot.ts @@ -79,9 +79,14 @@ class SpringBootAdapter implements FixtureAdapter { async deploy( xmlPath: string, deploymentName = path.basename(xmlPath, '.bpmn'), + tenantId?: string, ): Promise<{ deploymentId: string }> { const form = new FormData(); form.append('deployment-name', deploymentName); + // The part name DeploymentRestServiceImpl.createDeployment reads. + if (tenantId !== undefined) { + form.append('tenant-id', tenantId); + } const xmlBytes = await import('node:fs/promises').then((fs) => fs.readFile(xmlPath), diff --git a/tests/fixtures/error-start.bpmn b/tests/fixtures/error-start.bpmn index 2b9ae6b..c42ae50 100644 --- a/tests/fixtures/error-start.bpmn +++ b/tests/fixtures/error-start.bpmn @@ -1,13 +1,14 @@ - + Flow_ShipmentFailed_Notify diff --git a/tests/fixtures/index.ts b/tests/fixtures/index.ts index 7a2e5f3..120f1aa 100644 --- a/tests/fixtures/index.ts +++ b/tests/fixtures/index.ts @@ -25,7 +25,7 @@ export function buildExample(dslPath: string, xmlOutPath: string): void { mkdirSync(dirname(xmlOutPath), { recursive: true }); execFileSync( process.execPath, - [CLI_ENTRY, 'build', dslPath, '-o', xmlOutPath], + [CLI_ENTRY, 'build', dslPath, '-o', xmlOutPath, '--force'], { stdio: 'inherit' }, ); } diff --git a/tests/fixtures/invoice-receipt.bpmn b/tests/fixtures/invoice-receipt.bpmn new file mode 100644 index 0000000..200eb48 --- /dev/null +++ b/tests/fixtures/invoice-receipt.bpmn @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + + prepareBankTransfer + invoiceProcessed + ServiceTask_1 + + + reviewSuccessful_gw + assignApprover + StartEvent_1 + invoiceNotProcessed + reviewInvoice + + + approveInvoice + invoice_approved + + + + + Approve the invoice (or not). + + + if(!!task.getVariable('approver')) { + task.setAssignee(approver); +} + + + task.setVariable('approver', task.getAssignee()); + + + sequenceFlow_178 + reviewSuccessful + sequenceFlow_180 + + + sequenceFlow_180 + invoiceNotApproved + invoiceApproved + + + sequenceFlow_183 + reviewNotSuccessful + reviewSuccessful + + + Prepare the bank transfer. + invoiceApproved + SequenceFlow_2 + + + DataStoreReference_1 + Property_0bzbsgl + + + + SequenceFlow_1 + sequenceFlow_178 + + + SequenceFlow_1 + + + reviewNotSuccessful + + + SequenceFlow_3 + + + SequenceFlow_2 + SequenceFlow_3 + + + + + + + + + + + invoiceNotApproved + sequenceFlow_183 + + + + + + + ${clarified} + + + ${!clarified} + + + ${approved} + + + + + ${!approved} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/lanes-and-async.bpmn b/tests/fixtures/lanes-and-async.bpmn index bba900a..fc5cef7 100644 --- a/tests/fixtures/lanes-and-async.bpmn +++ b/tests/fixtures/lanes-and-async.bpmn @@ -7,7 +7,7 @@ exporter="Operaton Modeler" exporterVersion="5.0.0"> - + diff --git a/tests/fixtures/types.ts b/tests/fixtures/types.ts index d3d2be8..99b2625 100644 --- a/tests/fixtures/types.ts +++ b/tests/fixtures/types.ts @@ -11,9 +11,12 @@ export interface FixtureAdapter { // Returns when the runtime accepts deployments. start(): Promise; + // A tenant id isolates the deployment's message-start subscriptions from + // every other tenant's; without one the deployment is shared. deploy( xmlPath: string, deploymentName?: string, + tenantId?: string, ): Promise<{ deploymentId: string }>; startProcess( diff --git a/tests/gateway-settings.round-trip.test.ts b/tests/gateway-settings.round-trip.test.ts index d33efa9..277e8f4 100644 --- a/tests/gateway-settings.round-trip.test.ts +++ b/tests/gateway-settings.round-trip.test.ts @@ -37,7 +37,11 @@ const GATEWAY_SETTINGS: Record = { }, 'Gateway_loan-application_3_1_split': {}, 'Gateway_loan-application_3_1_join': {}, - 'Gateway_loan-application_4_0_loop': { exclusive: false, jobPriority: '10' }, + 'Gateway_loan-application_4_0_loop': { + asyncBefore: true, + exclusive: false, + jobPriority: '10', + }, 'Gateway_loan-application_5_fork': { asyncBefore: true }, 'Gateway_loan-application_5_join': { asyncAfter: true, @@ -45,6 +49,7 @@ const GATEWAY_SETTINGS: Record = { retryCycle: 'R2/PT1M', }, 'Gateway_loan-application_6_fork': { + asyncBefore: true, jobPriority: '${selfEmployed ? 90 : 50}', }, 'Gateway_loan-application_6_join': { asyncBefore: true }, @@ -121,6 +126,7 @@ describe('the frozen gateway-settings contract', () => { 'operaton:failedJobRetryTimeCycle', 'R3/PT10M', ], + ['Gateway_loan-application_4_0_loop', 'operaton:asyncBefore', 'true'], ['Gateway_loan-application_4_0_loop', 'operaton:exclusive', 'false'], ['Gateway_loan-application_4_0_loop', 'operaton:jobPriority', '10'], ['Gateway_loan-application_5_fork', 'operaton:asyncBefore', 'true'], @@ -131,6 +137,7 @@ describe('the frozen gateway-settings contract', () => { 'operaton:failedJobRetryTimeCycle', 'R2/PT1M', ], + ['Gateway_loan-application_6_fork', 'operaton:asyncBefore', 'true'], [ 'Gateway_loan-application_6_fork', 'operaton:jobPriority', @@ -157,9 +164,9 @@ describe('the frozen gateway-settings contract', () => { '} else if (creditScore >= 600) {', 'while (documentsMissing) (asyncAfter: true, retryCycle: "R3/PT10M") {', 'if (selfEmployed) {', - '} while (referencesPending) (exclusive: false, jobPriority: 10)', + '} while (referencesPending) (asyncBefore: true, exclusive: false, jobPriority: 10)', 'parallel (asyncBefore: true, joinAsyncAfter: true, joinExclusive: false, joinRetryCycle: "R2/PT1M") {', - 'parallel (jobPriority: "${selfEmployed ? 90 : 50}", joinAsyncBefore: true) {', + 'parallel (asyncBefore: true, jobPriority: "${selfEmployed ? 90 : 50}", joinAsyncBefore: true) {', 'if (amount > 250000) {', 'await (asyncBefore: true, jobPriority: 5, joinAsyncBefore: true) {', ]); @@ -201,6 +208,7 @@ describeImportFirst( expect(first.dsl).toBe( [ 'process p {', + ' var creditScore: any', ' start Applied', ' if (creditScore >= 700) (jobPriority: 7, joinAsyncBefore: true) {', ' user Approve(assignee: "demo")', diff --git a/tests/golden/README.md b/tests/golden/README.md index a618f57..2c79213 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -32,6 +32,7 @@ A few stand alone as inputs for one direction only. | `external-task.{bpmnscript,bpmn}` | The extras a topic-bound task carries: priority, properties, error mappings | | `gateway-settings.{bpmnscript,bpmn}` | The job settings on the five gateway statements, for the split and for the join | | `mail-and-shell.{bpmnscript,bpmn}` | The mail and shell behaviours, with their fields, on the three tags that take `type` | +| `review-loop.{bpmnscript,bpmn}` | The approve-review loop, a split inside a loop body with a condition on every route | | `unstructured-goto.bpmn` | The `goto` degradation path on import | The three invoice-approval files all describe the same process (review, then a gateway on `amount > 1000`, then senior approval or auto-approve) but come from different sources and pull the tests in different directions. @@ -42,7 +43,7 @@ Change the parser, the desugarer, or `irToXml` in a way that should alter the ou 1. Run the full pipeline on the source: `irToXml(astToIr(parse(source)))`, wiring the Langium services exactly as `tests/round-trip.test.ts` does, with `createBpmnScriptServices(EmptyFileSystem)` and `parseHelper`. `bpmns build` is not a substitute. - It passes the source file's name and its own version into `irToXml`, so what it writes carries `id="Definitions_"` and the real `exporterVersion`, where a frozen file carries `Definitions_` and `0.0.0`. + It passes its own version into `irToXml`, so what it writes carries the real `exporterVersion`, where a frozen file carries `0.0.0`; the `bpmn:Definitions` id is `Definitions_` either way. 2. Write the returned string over the frozen `.bpmn`. 3. Read the diff and confirm every change is intended. @@ -56,6 +57,7 @@ Each pair has a round-trip test at `tests/.round-trip.test.ts`, and `tests Every pair but `intermediate-catch` also asserts that the frozen `.bpmn` imports without a single warning and re-desugars back to the IR the fixture compiled to. The DI assertion is each suite's own, since what the layout has to get right differs: nested shapes inside their parent's bounds wherever a fixture nests (the `isExpanded` hint from `irToXml`, without which a disconnected event sub-process leaks into the root plane), and a boundary shape centered on its host's lower edge in `boundary-events`. Per-fixture extras, such as which root elements must be shared or how strictly the restructured DSL is validated, live in the test file. +`tests/e2e/deploy-sweep.test.ts` deploys every frozen `.bpmn` here, every fixture under `tests/fixtures/`, and every example, on the engine, both as written and rebuilt from its own print; that suite lives outside the pair suites and runs once per file rather than once per pair. ## `invoice-approval-handwritten.bpmn` @@ -68,8 +70,8 @@ This is the input for the XML to IR direction. ## `invoice-approval-generated.bpmn` The frozen output of the full pipeline, checked in: `irToXml(astToIr(parse(examples/spring-boot/processes/invoice-approval.bpmnscript)))`. -The `irToXml` test (`packages/transform/test/ir-to-xml.test.ts`, describe block "irToXml, full-pipeline golden diff") reproduces that pipeline and compares byte-for-byte, so any accidental change to the parser, the desugarer, or the serializer shows up as a failed diff. -This is the same XML the spring-boot engine E2E deploys. +The `irToXml` test (`packages/transform/test/ir-to-xml.test.ts`, describe block "irToXml: full-pipeline golden diff") reproduces that pipeline and compares byte-for-byte, so any accidental change to the parser, the desugarer, or the serializer shows up as a failed diff. +`tests/e2e/deploy-sweep.test.ts` deploys this frozen file as written, while `tests/e2e/invoice-approval.test.ts` deploys the same source compiled fresh through the CLI; the two documents differ in `exporterVersion` alone (see "Regenerating a frozen output" above). Contract: the process id `invoice-approval`, the userTask ids `ReviewInvoice` and `SeniorApproval`, `operaton:class="com.example.invoice.AutoApproveDelegate"`, the `demo` and `manager` assignees, and the condition `${amount > 1000}`. @@ -151,8 +153,10 @@ Five of the seven keys a user task owns (`assignee`, `formKey`, `candidateGroups The header's `historyTimeToLive` is `P90D` rather than the `P30D` the exporter stamps on a process that wrote none, because the importer reads that value back as unwritten and the fixture would otherwise pin nothing. Both handler forms are here so the placement rule is pinned in the artifact rather than only in prose. -A hosted `on ApprovePayout: timer` writes its settings on the boundary event it lowers to, and a host-less `on escalation` writes them on the event sub-process rather than on the trigger start event nested inside it. +A hosted `on ApprovePayout: timer` writes its settings on the boundary event it lowers to, and its `exclusive` on the timer definition as well, since `BpmnParse.parseTimer` locks the timer job from there and the tag's copy reaches only the `asyncAfter` job. +A host-less `on escalation` writes them on the event sub-process rather than on the trigger start event nested inside it. That start event's own block belongs to the `start` statement written inside the handler body, and a synthesized start prints no statement at all, so a setting stored there could have nowhere to go. +A host-less `on timer` is the one exception: its `jobPriority`, `retryCycle` and `exclusive` go on the trigger start event, whose job they configure, and the printer lifts them back into the head as long as that start prints no statement ([ADR-0047](../../docs/decisions/0047-timer-job-settings-on-the-element-the-engine-reads.md)). A `while`, an `if`, and a `parallel` put five synthesized gateways in the same artifact, none of which is written with a setting, so the pair pins that no direction invents one on a gateway; what a gateway written with settings carries is the `gateway-settings` pair's to pin. Every named node carries an explicit id, and the frozen artifact imports without a single warning. @@ -160,22 +164,24 @@ Contract: the `(node id, attribute, value)` table in `tests/engine-attributes.ro ## `input-output.{bpmnscript,bpmn}` -A process exercising the `operaton:inputOutput` block in all four value forms, a scalar, an inline script, a list, and a map, on a user task, a service task, a script task, a subprocess, a call, and an `on message` handler. +A process exercising the `operaton:inputOutput` block in all four value forms, a scalar, an inline script, a list, and a map, on a user task, a service task, a script task, a subprocess, and a call. The scalar and the inline script each appear in both directions; as a parameter's own value the list is only ever an input and the map only ever an output, and each of the two structured forms nests inside the other. The call carries its own `in`/`out` variable mappings beside its parameters, so one artifact pins the two mechanisms as distinct: a variable mapping crosses the process boundary into the callee, a parameter binds a value into the activity's own execution scope. +The `on message` handler at the end carries no parameter: it lowers to an event sub-process, which `BpmnParse.checkActivityInputOutputSupported` refuses a mapping on ([ADR-0046](../../docs/decisions/0046-listener-shapes-the-engine-deploys.md)). Contract: the parameter names and their declaration order per direction, each value's form, the `scriptFormat` on each inline script, and the call's variable mappings serialized beside the `operaton:inputOutput` block rather than inside it. ## `listeners.{bpmnscript,bpmn}` -A process registering execution listeners on a service task, a subprocess, an end event, and an `on message` handler, and all six task-listener events, `create`, `assign`, `complete`, `update`, `delete`, and `timeout`, on one user task. +A process registering execution listeners on a service task, a subprocess, an end event, and an `on message` handler, and all six task-listener events, `create`, `assignment`, `complete`, `update`, `delete`, and `timeout`, on one user task. The service task carries both execution events, `start` and `end`; the other three carry one of the two each. All four bindings appear, a Java class, a JUEL expression, a delegate expression, and an inline fenced script, and the `timeout` listener carries the timer clause a caught timer event spells the same way. +The `timeout` listener is written with the id `InspectVehicle_timeout_1`, which `BpmnParse.parseTimeoutTaskListener` requires and the export mints from the task id, and the `on message` handler carries no parameter, since the engine refuses a mapping on the event sub-process it lowers to ([ADR-0046](../../docs/decisions/0046-listener-shapes-the-engine-deploys.md)). Three bindings in the artifact also carry an injected field, every carrier one rides but the built-in `type` binding the `mail-and-shell` pair pins: the service task's own class binding takes two, a literal and a `${...}` expression, its `on start` execution listener takes one, and the delegate-bound `on complete` task listener takes one. -The expression-bound `on assign` listener carries none, because Operaton hands a field list to a class binding, a delegate binding, and a built-in `type` binding, and to no other. +The expression-bound `on assignment` listener carries none, because Operaton hands a field list to a class binding, a delegate binding, and a built-in `type` binding, and to no other. -Contract: the `operaton:executionListener` and `operaton:taskListener` children in their authored order, the event word on each, the single binding each carries, the `scriptFormat` on the inline script, the timer child under the `timeout` listener, and every `operaton:field` under the binding that receives it, a literal in the `stringValue` attribute and an expression in an `operaton:expression` child. +Contract: the `operaton:executionListener` and `operaton:taskListener` children in their authored order, the event word on each, the single binding each carries, the `scriptFormat` on the inline script, the id and the timer child under the `timeout` listener, and every `operaton:field` under the binding that receives it, a literal in the `stringValue` attribute and an expression in an `operaton:expression` child. ## `event-positions.{bpmnscript,bpmn}` @@ -223,8 +229,9 @@ An order-handling narrative carrying every shape a `parallel` block takes and bo The first `parallel` closes with an `else`, and every sibling of that `else` carries a condition, because a branch carrying none always runs and would leave the fallback nothing to pick up. The second drops the `else` and leaves a branch unheaded; the third heads nothing at all. A condition on any branch makes the fork and the join `bpmn:inclusiveGateway`, so the first two blocks freeze one such pair each, while the third stays the `bpmn:parallelGateway` pair a `parallel` has always produced. -An inclusive fork always names a default flow, because a gateway whose every branch is conditioned and which names none deploys, runs, and then throws a stuck execution the first time no condition holds. -Where the block writes an `else` that default points at the branch, and where it does not it runs straight to the join, which is the one flow the source never shows and the decompiler leaves out again. +An inclusive fork names a default flow only where the engine can take it. +With an `else` the default points at that branch, and with every branch conditioned it runs straight to the join, because a gateway whose every branch is conditioned and which names none deploys, runs, and then throws a stuck execution the first time no condition holds. +The second block leaves a branch unheaded, and that branch runs every time, so its fork names no default at all. Both races open on `await {`: the first weighs a settled payment against a three-day timer whose branch carries `asyncBefore`, the second the restocking signal against a condition on the order, so all four triggers a branch can wait on sit in one artifact. A plain `await` follows the two, so both forms of the keyword are frozen side by side. @@ -233,7 +240,7 @@ No flow out of that gateway carries a condition: Operaton builds no transition f The gateway, its catch events, and the merge are all elided on print, each of them recovered from the `await` block the branches spell out, and a branch's settings are written back on the catch event the engine waits at. Every condition reads a field of the start form rather than a `var`, so the declaration comes back out of the XML on the way in. -Contract: two `bpmn:inclusiveGateway` pairs and one `bpmn:parallelGateway` pair, each a `_fork` and a `_join`; exactly two `default` attributes, one naming the flow into the `else` branch and one the flow into the join; one `bpmn:eventBasedGateway` per race, each with one unconditioned flow per catch event and an exclusive merge; and every authored id. +Contract: two `bpmn:inclusiveGateway` pairs and one `bpmn:parallelGateway` pair, each a `_fork` and a `_join`; exactly one `default` attribute, naming the flow into the `else` branch; one `bpmn:eventBasedGateway` per race, each with one unconditioned flow per catch event and an exclusive merge; and every authored id. ## `documentation.{bpmnscript,bpmn}` @@ -296,8 +303,8 @@ Contract: the `EXTERNAL_BINDINGS` record in `tests/external-task.round-trip.test A loan-application narrative in which each of the five gateway statements carries a parens of its own, so one artifact freezes all ten spellings a gateway head takes: the five engine keys for the split, the loop gateway, the fork or the race, and the five `join*` keys for the join synthesized beside it. The `if` chain carries three head keys and two join keys and its `else if` head none, because the chain lowers to one split and one join and the head parens governs both. The `while` carries `asyncAfter` and a retry cycle, and its body holds a plain `if` whose two gateways carry nothing, so a gateway written bare is frozen beside the ones written with settings. -The `do ... while` sits inside a sub-process, the one nesting the artifact has, and carries `exclusive: false` and a priority, the two keys a loop takes beside the async flags. -The first `parallel` weighs nothing, so the pair stays `bpmn:parallelGateway`, and it puts `asyncBefore` on the fork and the other three `join*` keys on the join; the second weighs one branch, so the pair is `bpmn:inclusiveGateway`, and it puts an expression priority on the fork and one key on the join. +The `do ... while` sits inside a sub-process, the one nesting the artifact has, and carries `asyncBefore`, `exclusive: false`, and a priority, since a bare priority or `exclusive` needs the async flag beside it to configure a job at all. +The first `parallel` weighs nothing, so the pair stays `bpmn:parallelGateway`, and it puts `asyncBefore` on the fork and the other three `join*` keys on the join; the second weighs one branch, so the pair is `bpmn:inclusiveGateway`, and it puts `asyncBefore` and an expression priority on the fork and one key on the join. The `await` carries `asyncBefore` and a priority on the head and one `join*` key on the exclusive merge behind it, and never `asyncAfter`, which `BpmnParse.parseEventBasedGateway` refuses to deploy. Every condition reads a field of the start form rather than a `var`, so the declaration comes back out of the XML on the way in, and every label differs from the name humanized from its id. @@ -310,11 +317,23 @@ Contract: the `(gateway id, JobSettings)` record in `tests/gateway-settings.roun An incident-report narrative binding the two behaviours Operaton builds itself, `type: "mail"` and `type: "shell"`, spread over the three tags that take the binding. `RateSeverity` is a `decide` step running a shell command and nothing else, the one field `BpmnParse.validateFieldDeclarationsForShell` requires; `PageOnCall` is a `send` task running a shell command with an argument, the variables its output and exit code land in, and `wait = "true"`, spelled the one way `ShellActivityBehavior.readFields` reads as true; `MailOnCall` is a `service` task naming `to`, `cc`, a `subject` expression, a `text` literal and an `html` expression, so both value slots a field takes sit on one task. -`PageOnCall` also names a `resultVariable`, which the engine accepts on the tag and never reads for a shell task, so the accepted-and-ignored setting is pinned beside the binding. The two form fields the mail's expressions read are declared on the start form rather than as a `var`, so the declarations come back out of the XML on the way in, and every label differs from the name humanized from its id. Contract: the `BUILTIN_BINDINGS` record in `tests/mail-and-shell.round-trip.test.ts`, asserting the three tasks keep their type and every field, in order and in its value slot, at each hop and through import; `operaton:type="shell"` on the `bpmn:businessRuleTask` and the `bpmn:sendTask` and `operaton:type="mail"` on the `bpmn:serviceTask`, each with its `operaton:field` children written as a `stringValue` attribute or an `operaton:expression` child; the printed head of each task with its `type` and the field lines beneath it; an import of the frozen artifact that reports no warning at all; and every authored id. +## `review-loop.{bpmnscript,bpmn}` + +The approve-review loop of an invoice process, the shape every approve-or-rework loop drawn by hand takes and the one the Operaton invoice example is built around. +An invoice is approved or sent back for review, and a review that clarifies it goes round again, so the loop is a `do ... while` whose head is the "clarified" decision. +The split inside the loop body carries a condition on both routes, `approved` leaving the loop by `goto` for the bank transfer and `!approved` staying in it for the review call, and the chain names no `else`. +The route leaving the loop puts that split's immediate post-dominator outside the loop, so the split has no join of its own, and with no unconditioned route it has no guard clause either; the printer continues it at the loop gateway instead, so the review call prints inside the loop and only the leaving route is a jump. +The two condition variables are declared as `var` lines, which the IR does not carry, so the restructured DSL' declares them again as `var x: any`, the type a read alone can say. + +The frozen file's split carries a default flow to the join, which the compiled else-less chain gives it and which a hand-drawn original lacks. +That is why the import-first pin of the modeler's shape, with a condition on every route and no default anywhere, lives in `tests/goto-fallback.round-trip.test.ts` rather than here. + +Contract: one `bpmn:exclusiveGateway` split carrying `${approved}`, `${!approved}` and a `default` into the join, that join with two incoming flows, the loop gateway carrying the `${clarified}` back edge into `ApproveInvoice` and a default exit into `InvoiceNotProcessed`; the restructured DSL' printing the `if`/`else if` chain inside the `do` body with the review call inline; an import of the frozen artifact that reports no warning at all; and every authored id. + ## `unstructured-goto.bpmn` A deliberately unstructured BPMN file. diff --git a/tests/golden/branch-and-race.bpmn b/tests/golden/branch-and-race.bpmn index b98779e..c26de23 100644 --- a/tests/golden/branch-and-race.bpmn +++ b/tests/golden/branch-and-race.bpmn @@ -39,16 +39,14 @@ Flow_Gateway_order-handling_2_fork_default Flow_TriageOrder_Gateway_order-handling_2_join - + Flow_Gateway_order-handling_2_join_Gateway_order-handling_3_fork Flow_Gateway_order-handling_3_fork_PrepareCustoms Flow_Gateway_order-handling_3_fork_PrintLabel - Flow_Gateway_order-handling_3_fork_default Flow_PrepareCustoms_Gateway_order-handling_3_join Flow_PrintLabel_Gateway_order-handling_3_join - Flow_Gateway_order-handling_3_fork_default Flow_Gateway_order-handling_3_join_Gateway_order-handling_4_fork @@ -167,7 +165,6 @@ - @@ -326,12 +323,6 @@ - - - - - - diff --git a/tests/golden/branch-and-race.bpmnscript b/tests/golden/branch-and-race.bpmnscript index b1c2298..2e1404e 100644 --- a/tests/golden/branch-and-race.bpmnscript +++ b/tests/golden/branch-and-race.bpmnscript @@ -11,8 +11,9 @@ // Every sibling of an `else` carries a condition, because a branch without // one is always taken and would leave the fallback nothing to pick up; // - the second `parallel` leaves the fallback out. Customs paperwork is -// prepared only for an overseas parcel and the label is always printed, so -// the default flow runs straight to the join and the source never shows it; +// prepared only for an overseas parcel and the label is always printed; +// the unconditioned branch always keeps a token arriving at the join, so +// the fork reserves no default flow at all; // - the third `parallel` weighs nothing, so both branches always run and the // pair stays an AND fork and join; // - the first `await` block races the settled payment against a three-day diff --git a/tests/golden/engine-attributes.bpmn b/tests/golden/engine-attributes.bpmn index 9393e20..4a8048e 100644 --- a/tests/golden/engine-attributes.bpmn +++ b/tests/golden/engine-attributes.bpmn @@ -102,7 +102,7 @@ execution.setVariable("mechanicsGrade", grade) Flow_Boundary_ApprovePayout_timer_NudgeApprover - + PT8H diff --git a/tests/golden/gateway-settings.bpmn b/tests/golden/gateway-settings.bpmn index f74e303..802da84 100644 --- a/tests/golden/gateway-settings.bpmn +++ b/tests/golden/gateway-settings.bpmn @@ -79,7 +79,7 @@ Flow_StartEvent_CollectReferences_ChaseReference Flow_ChaseReference_Gateway_loan-application_4_0_loop - + Flow_ChaseReference_Gateway_loan-application_4_0_loop Flow_Gateway_loan-application_4_0_loop_ChaseReference Flow_Gateway_loan-application_4_0_loop_default @@ -115,16 +115,14 @@ Flow_Gateway_loan-application_5_fork_ScreenFraud Flow_ScreenFraud_Gateway_loan-application_5_join - + Flow_Gateway_loan-application_5_join_Gateway_loan-application_6_fork Flow_Gateway_loan-application_6_fork_AppraiseCollateral Flow_Gateway_loan-application_6_fork_PriceLoan - Flow_Gateway_loan-application_6_fork_default Flow_AppraiseCollateral_Gateway_loan-application_6_join Flow_PriceLoan_Gateway_loan-application_6_join - Flow_Gateway_loan-application_6_fork_default Flow_Gateway_loan-application_6_join_MakeOffer @@ -207,7 +205,6 @@ - @@ -407,12 +404,6 @@ - - - - - - diff --git a/tests/golden/gateway-settings.bpmnscript b/tests/golden/gateway-settings.bpmnscript index bcc1d6a..ea85b54 100644 --- a/tests/golden/gateway-settings.bpmnscript +++ b/tests/golden/gateway-settings.bpmnscript @@ -57,7 +57,7 @@ process loan-application(label: "Consumer loan application") { subprocess CollectReferences(label: "Collect the references the applicant named") { do { user ChaseReference(label: "Chase one outstanding reference", assignee: "demo") - } while (referencesPending) (exclusive: false, jobPriority: 10) + } while (referencesPending) (asyncBefore: true, exclusive: false, jobPriority: 10) } parallel (asyncBefore: true, joinAsyncAfter: true, joinExclusive: false, joinRetryCycle: "R2/PT1M") { @@ -69,7 +69,7 @@ process loan-application(label: "Consumer loan application") { } } - parallel (jobPriority: "${selfEmployed ? 90 : 50}", joinAsyncBefore: true) { + parallel (asyncBefore: true, jobPriority: "${selfEmployed ? 90 : 50}", joinAsyncBefore: true) { if (amount > 250000) { user AppraiseCollateral(label: "Appraise the offered collateral", assignee: "manager") } diff --git a/tests/golden/input-output.bpmn b/tests/golden/input-output.bpmn index 63c9649..5a85a01 100644 --- a/tests/golden/input-output.bpmn +++ b/tests/golden/input-output.bpmn @@ -110,11 +110,6 @@ execution.setVariable("allowanceBase", base) Flow_PayOut_ClaimSettled - - - ${recallText} - - Flow_RecallNoted_RecordRecall diff --git a/tests/golden/input-output.bpmnscript b/tests/golden/input-output.bpmnscript index 042662f..5e42343 100644 --- a/tests/golden/input-output.bpmnscript +++ b/tests/golden/input-output.bpmnscript @@ -49,8 +49,6 @@ execution.setVariable("allowanceBase", base) end ClaimSettled on message("ClaimRecalled") { - input recallReason = "${recallText}" - } { start RecallNoted service RecordRecall(label: "Record the recalled claim", class: "com.example.expenses.RecallDelegate") end RecallRecorded diff --git a/tests/golden/listeners.bpmn b/tests/golden/listeners.bpmn index 48680e5..a476ac8 100644 --- a/tests/golden/listeners.bpmn +++ b/tests/golden/listeners.bpmn @@ -30,7 +30,7 @@ - + ${task.assignee} @@ -41,7 +41,7 @@ - + PT8H @@ -68,9 +68,6 @@ - - ${initiator} - diff --git a/tests/golden/listeners.bpmnscript b/tests/golden/listeners.bpmnscript index 02e76f2..f3580c2 100644 --- a/tests/golden/listeners.bpmnscript +++ b/tests/golden/listeners.bpmnscript @@ -16,7 +16,7 @@ process claim-settlement { start AssessmentOpened user InspectVehicle(label: "Inspect the vehicle", assignee: "demo", candidateGroups: "assessors") { on create(class: "com.example.claims.NotifyAssessor") - on assign(expression: "${assessorRoster.record(task)}") + on assignment(expression: "${assessorRoster.record(task)}") on complete(delegate: "${assessmentRecorder}") { field reviewedBy = "${task.assignee}" } @@ -36,7 +36,6 @@ task.setVariable("assessmentRevised", true); } on message("ClaimWithdrawn") { - input withdrawnBy = "${initiator}" on start(class: "com.example.claims.LogWithdrawal") } { start WithdrawalOpened diff --git a/tests/golden/mail-and-shell.bpmn b/tests/golden/mail-and-shell.bpmn index 8883b74..dc41f08 100644 --- a/tests/golden/mail-and-shell.bpmn +++ b/tests/golden/mail-and-shell.bpmn @@ -17,7 +17,7 @@ Flow_IncidentLogged_RateSeverity Flow_RateSeverity_PageOnCall - + diff --git a/tests/golden/mail-and-shell.bpmnscript b/tests/golden/mail-and-shell.bpmnscript index 3fe0ef4..548fffb 100644 --- a/tests/golden/mail-and-shell.bpmnscript +++ b/tests/golden/mail-and-shell.bpmnscript @@ -6,8 +6,7 @@ // the engine requires of a shell task; // - a send task running a shell command with an argument, the variables its // output and exit code land in, and `wait` spelled the one way the engine -// reads as true, beside a `resultVariable` the engine accepts on the tag -// and never reads for a shell task; +// reads as true; // - a service task mailing the on-call engineer: `to`, `cc`, a `subject` // given as an expression, a `text` literal, and an `html` body given as an // expression, so both value slots a field takes sit on one task. @@ -28,7 +27,7 @@ process incident-report { field command = "rate-severity" } - send PageOnCall(label: "Page the on-call engineer", type: "shell", resultVariable: "pageResult") { + send PageOnCall(label: "Page the on-call engineer", type: "shell") { field command = "page" field arg1 = "oncall" field outputVariable = "pageReceipt" diff --git a/tests/golden/review-loop.bpmn b/tests/golden/review-loop.bpmn new file mode 100644 index 0000000..be8acd3 --- /dev/null +++ b/tests/golden/review-loop.bpmn @@ -0,0 +1,153 @@ + + + + + Flow_InvoiceReceived_ApproveInvoice + + + Flow_Gateway_invoice-review_1_loop_ApproveInvoice + Flow_InvoiceReceived_ApproveInvoice + Flow_ApproveInvoice_Gateway_invoice-review_1_1_split + + + Flow_ApproveInvoice_Gateway_invoice-review_1_1_split + Flow_Gateway_invoice-review_1_1_split_PrepareBankTransfer + Flow_Gateway_invoice-review_1_1_split_ReviewInvoice + Flow_Gateway_invoice-review_1_1_split_default + + + Flow_ReviewInvoice_Gateway_invoice-review_1_1_join + Flow_Gateway_invoice-review_1_1_split_default + Flow_Gateway_invoice-review_1_1_join_Gateway_invoice-review_1_loop + + + + + + + Flow_Gateway_invoice-review_1_1_split_ReviewInvoice + Flow_ReviewInvoice_Gateway_invoice-review_1_1_join + + + Flow_Gateway_invoice-review_1_1_join_Gateway_invoice-review_1_loop + Flow_Gateway_invoice-review_1_loop_ApproveInvoice + Flow_Gateway_invoice-review_1_loop_default + + + Flow_Gateway_invoice-review_1_loop_default + + + Flow_Gateway_invoice-review_1_1_split_PrepareBankTransfer + Flow_PrepareBankTransfer_ArchiveInvoice + + + Flow_PrepareBankTransfer_ArchiveInvoice + Flow_ArchiveInvoice_InvoiceProcessed + + + Flow_ArchiveInvoice_InvoiceProcessed + + + ${approved} + + + ${!approved} + + + + + + + ${clarified} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/golden/review-loop.bpmnscript b/tests/golden/review-loop.bpmnscript new file mode 100644 index 0000000..a53b66b --- /dev/null +++ b/tests/golden/review-loop.bpmnscript @@ -0,0 +1,36 @@ +// Review-loop round-trip fixture: the approve-review loop of an invoice +// process, the shape every approve-or-rework loop drawn by hand takes. +// +// - an invoice is approved or sent back for review, and a review that +// clarifies it goes round again, so the loop is a `do ... while` whose +// head is the "clarified" decision; +// - the split inside the loop body carries a condition on both routes, one +// leaving the loop for the bank transfer and one staying in it for the +// review, and the chain names no `else`. +// +// The two condition variables are declared as `var` lines, which the IR does +// not carry, so the restructured DSL' declares them again as `var x: any`. +process invoice-review { + var approved: boolean + var clarified: boolean + + start InvoiceReceived + + do { + user ApproveInvoice(assignee: "demo") + if (approved) { + goto PrepareBankTransfer + } else if (!approved) { + call ReviewInvoice(process: "ReviewInvoice") { + in invoiceDocument + out clarified + } + } + } while (clarified) + + end InvoiceNotProcessed(label: "Invoice not processed") + + user PrepareBankTransfer(candidateGroups: "accounting") + service ArchiveInvoice(class: "org.operaton.bpm.example.invoice.service.ArchiveInvoiceService") + end InvoiceProcessed(label: "Invoice processed") +} diff --git a/tests/golden/structured-control-flow.bpmnscript b/tests/golden/structured-control-flow.bpmnscript index 99ea5d7..51fde60 100644 --- a/tests/golden/structured-control-flow.bpmnscript +++ b/tests/golden/structured-control-flow.bpmnscript @@ -1,6 +1,9 @@ // Exercises if/else, while, and parallel in a single process; see README.md. process structured-control-flow { + var priority: number + var retries: number + start Begin user Triage(label: "Triage request", assignee: "demo") diff --git a/tests/goto-fallback.round-trip.test.ts b/tests/goto-fallback.round-trip.test.ts index 4147bcd..b79eea5 100644 --- a/tests/goto-fallback.round-trip.test.ts +++ b/tests/goto-fallback.round-trip.test.ts @@ -22,13 +22,18 @@ import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver-types'; import { xmlToIr, astToIr, + irToDsl, irToXml, UNSTRUCTURED_MARKER, } from '@bpmn-script/transform'; import type { BpmnProcess } from '@bpmn-script/transform'; import { realNodeReachability } from './helpers/real-node-reachability.js'; -import { parse, printDsl } from './helpers/pipeline.js'; +import { + parse, + parseToAst as parseUnvalidated, + printDsl, +} from './helpers/pipeline.js'; // Validation, not just parsing: a `goto` naming an elided node parses fine and // fails only once the reference is linked, and a `goto` written ahead of a @@ -171,6 +176,55 @@ describe('loop condition on the back-edge (no expressible jump target)', () => { }); }); +// The approve-review loop as a modeler draws it: the split inside the body +// carries a condition on both routes and names no default, and so does the +// loop gateway. The route leaving the loop puts the split's immediate +// post-dominator outside it, so the split has no clean join, and with no +// unconditioned route it has no guard clause either. +const REVIEW_LOOP: BpmnProcess = { + id: 'ReviewLoop', + isExecutable: true, + flowElements: [ + { kind: 'startEvent', id: 'Start_1' }, + { kind: 'userTask', id: 'Approve' }, + { kind: 'exclusiveGateway', id: 'Gateway_approved' }, + { kind: 'userTask', id: 'Review' }, + { kind: 'exclusiveGateway', id: 'Gateway_clarified' }, + { kind: 'userTask', id: 'Pay' }, + { kind: 'endEvent', id: 'End_1' }, + { kind: 'endEvent', id: 'End_2' }, + ], + sequenceFlows: [ + flow('f1', 'Start_1', 'Approve'), + flow('f2', 'Approve', 'Gateway_approved'), + flow('f3', 'Gateway_approved', 'Pay', '${approved}'), + flow('f4', 'Gateway_approved', 'Review', '${!approved}'), + flow('f5', 'Review', 'Gateway_clarified'), + flow('f6', 'Gateway_clarified', 'Approve', '${clarified}'), + flow('f7', 'Gateway_clarified', 'End_2', '${!clarified}'), + flow('f8', 'Pay', 'End_1'), + ], +}; + +describe('a split inside a loop body whose every route is conditioned', () => { + it('keeps the route that stays in the loop, so only the leaving route is a jump', async () => { + const { imported, dsl } = await emit(REVIEW_LOOP); + expect(dsl).not.toContain(UNSTRUCTURED_MARKER); + + // The chain closes with no `else`, so the recompiled split falls through + // to the loop gateway where the model had no route: `Approve` gains the + // two routes that gateway takes, and loses none it had. + const reDesugared = astToIr(await parseToAst(dsl)); + expect(realNodeReachability(reDesugared)).toEqual( + [ + ...realNodeReachability(imported), + 'Approve->Approve', + 'Approve->End_2', + ].sort(), + ); + }); +}); + const SURPLUS_EDGE_ON_TASK: BpmnProcess = { id: 'SurplusEdgeOnTask', isExecutable: true, @@ -204,11 +258,15 @@ describe('surplus out-edge on a plain node keeps the fall-through', () => { // everything after it with no incoming flow. Both routes head a branch // instead, so the second route keeps its edge and the first keeps its // chain. The back edge names `T1` because the join it runs into has one - // way out, and that is the step it leads to. + // way out, and that is the step it leads to. The step forks in the model + // and no merge closes it, which the marker names; no edge is dropped. expect(dsl).toContain('if (true) {'); expect(dsl).toContain('goto Cont'); expect(dsl).toContain('goto T1'); - expect(dsl).not.toContain(UNSTRUCTURED_MARKER); + expect(dsl).toContain( + `${UNSTRUCTURED_MARKER} (split T1 degraded to jumps; was parallel)`, + ); + expect(dsl).not.toContain('dropped edge'); }); it('keeps the rest of the process reachable and valid', async () => { @@ -311,6 +369,8 @@ describe('shapes that structure cleanly stay structured', () => { expect(printDsl(ir)).toBe( [ 'process NestedIf {', + ' var a: any', + ' var b: any', ' start Start_1', ' if (a) {', ' if (b) {', @@ -357,6 +417,8 @@ describe('shapes that structure cleanly stay structured', () => { expect(printDsl(ir)).toBe( [ 'process SiblingIfs {', + ' var a: any', + ' var b: any', ' start Start_1', ' if (a) {', ' user A_1', @@ -371,3 +433,134 @@ describe('shapes that structure cleanly stay structured', () => { ); }); }); + +// Shapes a fuzz run drew, each compiled, exported, imported and printed. A +// row pins the print's whole warning list by category and whether the print +// validates, so a shape that stops being reported, or starts failing, turns +// the row red rather than the next fuzz run. +describe('composite shapes through compile, import and print', () => { + it.each< + [title: string, source: string, categories: string[], valid: boolean] + >([ + [ + 'a loop and a throw inside a guarded branch walk inline', + [ + 'process p {', + ' escalation E', + ' start S', + ' if (true) {', + ' while (true) {', + ' script A ```javascript', + 'x = 1', + '```', + ' }', + ' throw escalation X(E)', + ' }', + ' end Done', + '}', + ].join('\n'), + [], + true, + ], + [ + 'a pre-test loop whose body ends prints as a guard clause jumping to the body', + 'process p {\n start S\n while (true) {\n user A\n end X\n }\n user B\n end Done\n}', + [], + true, + ], + [ + 'a fork with an empty weighed branch beside an ending one prints whole', + 'process p {\n start S\n parallel {\n if (true) {\n }\n {\n end X\n }\n }\n end Done\n}', + [], + true, + ], + [ + 'a fork carrying settings with an ending branch prints whole', + 'process p {\n start S\n parallel (retryCycle: "R1/PT1M") {\n {\n user A\n }\n {\n end X\n }\n }\n end Done\n}', + [], + true, + ], + ])('%s', async (_title, source, categories, valid) => { + const { ir: imported, warnings } = await xmlToIr( + await irToXml(astToIr(await parseToAst(source))), + ); + expect(warnings).toEqual([]); + const printed = irToDsl(imported); + + expect(printed.warnings.map((w) => w.category)).toEqual(categories); + expect( + await parseToAst(printed.source).then( + () => true, + () => false, + ), + ).toBe(valid); + }); + + // `do { end X } while (true)` inside a branch is refused as source, since + // the loop gateway would have no incoming flow, so the two shapes below + // skip validation, the way a Modeler file carrying them would import. + it.each<[string, string]>([ + [ + 'a race branch holding a post-test loop whose body ends draws a jump into the branch', + 'process p {\n start S\n await {\n timer("PT1M") {\n }\n message("m") {\n do {\n end X\n } while (true)\n }\n }\n end Done\n}', + ], + [ + 'a fork branch holding a post-test loop whose body ends draws a jump into the branch', + 'process p {\n start S\n parallel {\n {\n user F\n }\n if (true) {\n do {\n end S2\n } while (true)\n }\n }\n end Done\n}', + ], + ])('%s', async (_title, source) => { + const { ir: imported, warnings } = await xmlToIr( + await irToXml(astToIr(await parseUnvalidated(source))), + ); + expect(warnings).toEqual([]); + const printed = irToDsl(imported); + + expect(printed.warnings.map((w) => w.category)).toEqual([ + 'refusedStatement', + ]); + expect( + await parseToAst(printed.source).then( + () => true, + () => false, + ), + ).toBe(false); + }); + + // `do { end X } while (true)` compiles a loop gateway with no incoming + // flow: the body always ends, so `lowerDoWhile` never wires the back edge + // from the body into the gateway, only the gateway's two outgoing edges. + // A step after such a loop can no longer be written as source, since the + // validator now refuses a statement it can prove unreachable, so this + // shape is built as IR directly, the way a Modeler file would import it. + const DANGLING_LOOP_HEAD: BpmnProcess = { + id: 'DanglingLoopHead', + isExecutable: true, + flowElements: [ + { kind: 'startEvent', id: 'S' }, + { kind: 'endEvent', id: 'X' }, + { kind: 'exclusiveGateway', id: 'Gateway_L_loop', defaultFlowId: 'f3' }, + { kind: 'endEvent', id: 'Done' }, + ], + sequenceFlows: [ + flow('f1', 'S', 'X'), + flow('f2', 'Gateway_L_loop', 'X', '${true}'), + flow('f3', 'Gateway_L_loop', 'Done'), + ], + }; + + it('a post-test loop whose body ends prints its unreachable head as a jump', async () => { + const { ir: imported, warnings } = await xmlToIr( + await irToXml(DANGLING_LOOP_HEAD), + ); + expect(warnings).toEqual([]); + const printed = irToDsl(imported); + + expect(printed.warnings.map((w) => w.category)).toEqual([]); + expect( + await parseToAst(printed.source).then( + () => true, + () => false, + ), + ).toBe(false); + }); +}); diff --git a/tests/helpers/import-first.ts b/tests/helpers/import-first.ts index 29b9a4e..b8b86bb 100644 --- a/tests/helpers/import-first.ts +++ b/tests/helpers/import-first.ts @@ -11,6 +11,17 @@ import type { BpmnProcess, ImportWarning } from '@bpmn-script/transform'; import { normalizeIr } from './normalize-ir.js'; import { parseToAst, printDsl } from './pipeline.js'; +/** The one warning `xmlToIr` pushes for a document declaring the camunda namespace, against the process `elementId`. */ +export const camundaAliasWarning = (elementId: string): ImportWarning => ({ + elementId, + category: 'unmappedConstruct', + message: + 'The file declares the camunda namespace; it was read as the operaton ' + + 'namespace, since `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` falls back ' + + 'to the camunda URI wherever the operaton spelling is absent, and the ' + + 'document written back carries `operaton:` alone.', +}); + export interface ImportFirst { ir: BpmnProcess; warnings: ImportWarning[]; @@ -24,6 +35,7 @@ export function describeImportFirst( what: string, xml: string, extra: (first: ImportFirst) => void = () => undefined, + expectedWarnings: ImportWarning[] = [], ): void { const first = {} as ImportFirst; @@ -36,8 +48,8 @@ export function describeImportFirst( first.reDesugared = astToIr(await parseToAst(first.dsl)); }); - it('imports warning-free', () => { - expect(first.warnings).toEqual([]); + it('imports with exactly the expected warnings', () => { + expect(first.warnings).toEqual(expectedWarnings); }); extra(first); diff --git a/tests/helpers/normalize-ir.test.ts b/tests/helpers/normalize-ir.test.ts index 1e53f97..cefad57 100644 --- a/tests/helpers/normalize-ir.test.ts +++ b/tests/helpers/normalize-ir.test.ts @@ -238,6 +238,60 @@ describe('normalizeIr: gateway re-key', () => { ).toBe(true); }); + // Revert: the `subProcess` branch returning its normalized container + // without `reKeyedDefault` leaves the block row's default at `Flow_2`. + it.each([ + [ + 'a task naming a generated route as its default', + 'userTask', + 'Flow_2', + 'Flow_Triage_Skip', + ], + [ + 'a block naming a generated route as its default', + 'subProcess', + 'Flow_2', + 'Flow_Triage_Skip', + ], + ['a task naming no default', 'userTask', undefined, undefined], + ] as const)( + "re-keys %s's defaultFlowId with the flow it names, and adds none where there is none", + (_title, kind, authored, expected) => { + const triage: FlowElement = + kind === 'subProcess' + ? { kind, id: 'Triage', flowElements: [], sequenceFlows: [] } + : { kind, id: 'Triage' }; + const ir = process( + [ + { + ...triage, + ...(authored === undefined ? {} : { defaultFlowId: authored }), + }, + { kind: 'userTask', id: 'Review' }, + { kind: 'userTask', id: 'Skip' }, + ], + [ + { + id: 'Flow_1', + sourceRef: 'Triage', + targetRef: 'Review', + conditionExpression: '${big}', + }, + { id: 'Flow_2', sourceRef: 'Triage', targetRef: 'Skip' }, + ], + ); + const normalized = normalizeIr(ir); + expect(normalized.flowElements.find((fe) => fe.id === 'Triage')).toEqual({ + ...triage, + ...(expected === undefined ? {} : { defaultFlowId: expected }), + }); + expect(normalized.sequenceFlows.map((sf) => sf.id)).toEqual([ + 'Flow_Triage_Review', + 'Flow_Triage_Skip', + ]); + }, + ); + it('re-keys an event-based gateway by its position, the way the other three kinds are re-keyed', () => { const race = (id: string): BpmnProcess => process( diff --git a/tests/helpers/normalize-ir.ts b/tests/helpers/normalize-ir.ts index 7a50572..4929a53 100644 --- a/tests/helpers/normalize-ir.ts +++ b/tests/helpers/normalize-ir.ts @@ -12,7 +12,6 @@ import { ENGINE_KEYS } from '@bpmn-script/language'; import { - gatewayDefaultFlowId, isGateway, type BpmnProcess, type EventDefinition, @@ -44,9 +43,10 @@ function normalizeContainer(container: T): T { const flowElements: FlowElement[] = inlined.flowElements .map((fe) => { // A plain sub-process id is authored, so it stays. A `triggeredByEvent` - // one has no surface id and is re-keyed to its trigger signature. + // one has no surface id and is re-keyed to its trigger signature. Its + // own `defaultFlowId` names a flow of this container, not of its body. if (fe.kind === 'subProcess') { - const normalized = normalizeContainer(fe); + const normalized = { ...normalizeContainer(fe), ...reKeyedDefault(fe) }; return fe.triggeredByEvent === true ? { ...normalized, id: handlerIdMap.get(fe.id) ?? fe.id } : normalized; @@ -57,29 +57,30 @@ function normalizeContainer(container: T): T { return { ...fe, id: canonicalId(fe.id) }; } - if (!isGateway(fe)) return fe; + // A step's `defaultFlowId` points at a flow that is itself re-keyed + // below; a gateway's is handled with the gateway. + if (!isGateway(fe)) return { ...fe, ...reKeyedDefault(fe) }; const id = canonicalId(fe.id); // The structured syntax has no slot for a gateway label, so only gateways // lose their name. Task and event names survive verbatim. const { name: _name, ...withoutName } = fe; - - // `defaultFlowId` points at a flow that is itself re-keyed below. - const declaredDefault = gatewayDefaultFlowId(fe); - if (declaredDefault !== undefined) { - const target = inlined.sequenceFlows.find( - (sf) => sf.id === declaredDefault, - ); - const defaultFlowId = - target !== undefined - ? canonicalFlowKey(target, canonicalId) - : declaredDefault; - return { ...withoutName, id, defaultFlowId }; - } - return { ...withoutName, id }; + return { ...withoutName, id, ...reKeyedDefault(fe) }; }) .sort((a, b) => a.id.localeCompare(b.id)); + // The key a flow ends up under after `normalizeFlow`, so a `defaultFlowId` + // agrees with the flow it names; absent when the element names none. + function reKeyedDefault(fe: FlowElement): { defaultFlowId?: string } { + const declared = 'defaultFlowId' in fe ? fe.defaultFlowId : undefined; + if (declared === undefined) return {}; + const target = inlined.sequenceFlows.find((sf) => sf.id === declared); + return { + defaultFlowId: + target === undefined ? declared : normalizeFlow(target, canonicalId).id, + }; + } + const sequenceFlows: SequenceFlow[] = inlined.sequenceFlows .map((sf) => normalizeFlow(sf, canonicalId)) .sort((a, b) => a.id.localeCompare(b.id)); @@ -259,7 +260,7 @@ function normalizeFlow( return sf; } -// Flow ids and a gateway's `defaultFlowId` both route through here so they agree. +// A re-keyed flow's id; `reKeyedDefault` reaches it through `normalizeFlow`, so the two agree. function canonicalFlowKey( sf: SequenceFlow, canonicalId: (id: string) => string, diff --git a/tests/helpers/pipeline.ts b/tests/helpers/pipeline.ts index 916e1c1..004403c 100644 --- a/tests/helpers/pipeline.ts +++ b/tests/helpers/pipeline.ts @@ -58,3 +58,25 @@ export function roundTripOf(source: string): RoundTripRun { }); return run; } + +// Two full compile/decompile passes, chaining the second onto the first's +// printed DSL. Every other suite stops after one hop each direction, which +// misses a value that is stable on the first print but drifts on the second +// (a fuzz-only class of bug: idempotence, not correctness of a single hop). +export interface RoundTripTwice { + xml1: string; + dsl1: string; + xml2: string; + dsl2: string; +} + +export async function roundTripTwice(source: string): Promise { + const first = await roundTrip(source); + const second = await roundTrip(first.dsl); + return { + xml1: first.xml, + dsl1: first.dsl, + xml2: second.xml, + dsl2: second.dsl, + }; +} diff --git a/tests/input-output.round-trip.test.ts b/tests/input-output.round-trip.test.ts index 0489408..0d439d9 100644 --- a/tests/input-output.round-trip.test.ts +++ b/tests/input-output.round-trip.test.ts @@ -79,10 +79,6 @@ function parameterSignatures( return into; } -// A handler has no name of its own, so this is the one carrier in the fixture -// whose id is structural rather than authored. -const RECALL_HANDLER_ID = 'EventSubProcess_expense-reimbursement_7'; - const EXPECTED_PARAMETERS: Record = { ReviewReceipts: [ 'input receiptHint = "Match every receipt against the trip dates"', @@ -105,7 +101,6 @@ const EXPECTED_PARAMETERS: Record = { 'input payoutChannel = "sepa"', 'output payoutReceipt = {"start": "${payoutStart}", "lines": ["principal", "vat"]}', ], - [RECALL_HANDLER_ID]: ['input recallReason = "${recallText}"'], }; describe("idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3", () => { diff --git a/tests/invoice-receipt.import.test.ts b/tests/invoice-receipt.import.test.ts new file mode 100644 index 0000000..64db589 --- /dev/null +++ b/tests/invoice-receipt.import.test.ts @@ -0,0 +1,146 @@ +// The Operaton invoice example, the thesis's demonstration process: a pool +// wrapping three lanes, a data store and its reference, an unreferenced +// message root, and an approve/review loop whose inner split names no +// default (`invoice_approved`). `describeImportFirst` asserts a warning-free +// import, which this document is not, so the pipeline is driven by hand +// here: every per-feature suite in this package pins one shape apiece, and +// this one pins that the whole file survives import, print, re-parse, +// validation and a second import together. + +import { describe, it, expect, beforeAll } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { xmlToIr, astToIr, irToDsl, irToXml } from '@bpmn-script/transform'; +import type { BpmnProcess } from '@bpmn-script/transform'; + +import { realNodeReachability } from './helpers/real-node-reachability.js'; +import { parseToAst, validate } from './helpers/pipeline.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = readFileSync( + resolve(__dirname, 'fixtures/invoice-receipt.bpmn'), + 'utf-8', +); + +// The whole print: the start, the async respelling on the archive step, both +// loop variables declared, the review loop restructured as `do ... while` +// and no dropped edge. The assignment listener keeps its script body, +// printed like `on create` just above it; the listener carries no settings, +// so no parens precede the fenced body. +const PRINTED = [ + 'process invoice(label: "Invoice Receipt", versionTag: "V2.0", historyTimeToLive: "45") {', + ' var clarified: any', + ' var approved: any', + ' start StartEvent_1(label: "Invoice\\nreceived")', + ' decide assignApprover(label: "Assign Approver Group", decision: "invoice-assign-approver", mapDecisionResult: collectEntries, resultVariable: "approverGroups")', + ' do {', + ' user approveInvoice(documentation: "Approve the invoice (or not).", formKey: "embedded:app:forms/approve-invoice.html", candidateGroups: "${approverGroups}", dueDate: "${dateTime().plusWeeks(1).toDate()}") {', + ' on create ```javascript', + "if(!!task.getVariable('approver')) {", + ' task.setAssignee(approver);', + '}```', + ' on assignment ```javascript', + "task.setVariable('approver', task.getAssignee());```", + ' }', + ' if (approved) {', + ' goto prepareBankTransfer', + ' } else if (!approved) {', + ' call reviewInvoice(process: "ReviewInvoice") {', + ' in invoiceDocument', + ' in creditor', + ' in amount', + ' in invoiceCategory', + ' in invoiceNumber', + ' out clarified', + ' }', + ' }', + ' } while (clarified)', + ' end invoiceNotProcessed(label: "Invoice not\\nprocessed")', + ' user prepareBankTransfer(label: "Prepare\\nBank\\nTransfer", documentation: "Prepare the bank transfer.", formKey: "embedded:app:forms/prepare-bank-transfer.html", candidateGroups: "accounting", dueDate: "${dateTime().plusWeeks(1).toDate()}")', + ' service ServiceTask_1(label: "Archive Invoice", class: "org.operaton.bpm.example.invoice.service.ArchiveInvoiceService", asyncBefore: true)', + ' end invoiceProcessed(label: "Invoice\\nprocessed")', + '}', + '', +].join('\n'); + +describe('the Operaton invoice example', () => { + let imported: BpmnProcess; + let importWarnings: Awaited>['warnings']; + let dsl: string; + let printWarnings: ReturnType['warnings']; + let reDesugared: BpmnProcess; + + beforeAll(async () => { + ({ ir: imported, warnings: importWarnings } = await xmlToIr(FIXTURE)); + const printed = irToDsl(imported); + dsl = printed.source; + printWarnings = printed.warnings; + reDesugared = astToIr(await parseToAst(dsl)); + }); + + it('imports with exactly the warnings the pool, lanes and dropped extras produce', () => { + expect( + importWarnings.map((w) => ({ + elementId: w.elementId, + category: w.category, + })), + ).toEqual([ + { elementId: 'Accountant', category: 'lane' }, + { elementId: 'teamAssistant', category: 'lane' }, + { elementId: 'Approver', category: 'lane' }, + { elementId: 'DataStoreReference_1', category: 'unmappedConstruct' }, + { elementId: 'prepareBankTransfer', category: 'unmappedConstruct' }, + { elementId: 'prepareBankTransfer', category: 'unmappedConstruct' }, + { elementId: 'StartEvent_1', category: 'extensionAttribute' }, + { elementId: 'ServiceTask_1', category: 'unmappedConstruct' }, + { elementId: 'reviewSuccessful', category: 'label' }, + { elementId: 'reviewNotSuccessful', category: 'label' }, + { elementId: 'invoiceApproved', category: 'label' }, + { elementId: 'invoiceNotApproved', category: 'label' }, + { elementId: 'Process_Engine_1', category: 'unmappedConstruct' }, + { elementId: 'foxMessage_en', category: 'unreferencedRoot' }, + { elementId: 'FinancialAccountingSystem', category: 'unmappedConstruct' }, + ]); + }); + + it('prints the whole process as one structured script', () => { + expect(dsl).toEqual(PRINTED); + + expect( + printWarnings.map((w) => ({ + elementId: w.elementId, + category: w.category, + })), + ).toEqual([ + { elementId: 'invoice_approved', category: 'label' }, + { elementId: 'reviewSuccessful_gw', category: 'label' }, + { elementId: 'invoice_approved', category: 'defaultFlow' }, + { elementId: 'reviewSuccessful_gw', category: 'droppedCondition' }, + ]); + }); + + it('validates with no diagnostics at all', async () => { + const { diagnostics } = await validate(dsl); + expect(diagnostics).toEqual([]); + }); + + it('re-desugars to the import reachability plus the join edges the default-less split invents, and re-imports clean', async () => { + // `invoice_approved` names no default: the else-less `if`/`else if` chain + // the printer emits for it falls through to the loop gateway on + // recompile, which gains `approveInvoice` every pair that gateway reaches. + expect(realNodeReachability(reDesugared)).toEqual( + [ + ...realNodeReachability(imported), + 'approveInvoice->approveInvoice', + 'approveInvoice->invoiceNotProcessed', + ].sort(), + ); + + const { warnings: secondImportWarnings } = await xmlToIr( + await irToXml(reDesugared), + ); + expect(secondImportWarnings).toEqual([]); + }); +}); diff --git a/tests/listeners.round-trip.test.ts b/tests/listeners.round-trip.test.ts index 481c7d0..84dd897 100644 --- a/tests/listeners.round-trip.test.ts +++ b/tests/listeners.round-trip.test.ts @@ -31,7 +31,7 @@ const EXPECTED_LISTENERS = [ 'ValidateClaim end expression=${auditTrail.close(execution)}', 'AssessDamage start delegateExpression=${assessmentTracker}', 'InspectVehicle create class=com.example.claims.NotifyAssessor', - 'InspectVehicle assign expression=${assessorRoster.record(task)}', + 'InspectVehicle assignment expression=${assessorRoster.record(task)}', 'InspectVehicle complete delegateExpression=${assessmentRecorder}', `InspectVehicle update script=javascript ${JSON.stringify(SCRIPT_BODY)}`, 'InspectVehicle delete class=com.example.claims.ReleaseAssessor', @@ -165,10 +165,10 @@ describe("idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3", () => { }); describe('golden generation: the pipeline output matches the frozen .bpmn', () => { - it('the timeout listener carries its timer as a bpmn:timerEventDefinition child', () => { + it('the timeout listener carries the id BpmnParse.parseTimeoutTaskListener requires and its timer as a bpmn:timerEventDefinition child', () => { expect(rt.frozenXml).toContain( [ - '', + '', ' ', ' PT8H', ' ', diff --git a/tests/mail-and-shell.round-trip.test.ts b/tests/mail-and-shell.round-trip.test.ts index 1639194..36c5b87 100644 --- a/tests/mail-and-shell.round-trip.test.ts +++ b/tests/mail-and-shell.round-trip.test.ts @@ -1,9 +1,7 @@ // The three tags that take a `type` binding each carry one of the two // behaviours Operaton builds itself, with the fields the engine sets on it, so // a field that stops travelling in one direction or a type that comes back in -// another spelling fails the whole-object comparison below. A dropped -// `resultVariable` sits beside the binding, so the frozen tag, the printed -// head, and the fixture's idempotence check catch that one instead. +// another spelling fails the whole-object comparison below. import { describe, it, expect } from 'vitest'; @@ -80,7 +78,7 @@ describe('the frozen mail-and-shell binding contract', () => { ' ', ); expect(rt.frozenXml).toContain( - '\n' + + '\n' + ' \n' + ' \n' + ' \n' + @@ -117,7 +115,7 @@ describe('the frozen mail-and-shell binding contract', () => { expect(lines).toEqual([ 'decide RateSeverity(label: "Rate the severity", type: "shell") {', 'field command = "rate-severity"', - 'send PageOnCall(label: "Page the on-call engineer", type: "shell", resultVariable: "pageResult") {', + 'send PageOnCall(label: "Page the on-call engineer", type: "shell") {', 'field command = "page"', 'field arg1 = "oncall"', 'field outputVariable = "pageReceipt"', diff --git a/tests/parse-performance.test.ts b/tests/parse-performance.test.ts new file mode 100644 index 0000000..1d62c4c --- /dev/null +++ b/tests/parse-performance.test.ts @@ -0,0 +1,80 @@ +/** + * `xmlToIr` followed by `irToDsl` must stay linear in the number of flow + * nodes. Each of `tryWhile` and `tryDoWhileEntry` in `ir-to-dsl.ts` asks + * `CfgAnalysis.backEdges()` once per emitted node; if that recomputes the + * whole flow list by walking the dominator chain per edge, a straight chain + * of n tasks costs O(n^3) and a Modeler-style export of a few thousand tasks + * hangs with no output. + * + * Two guards in `cfg-analysis.ts` hold this together: `backEdges()` returns + * a list computed once, and `dominates` answers in two map lookups. Reverting + * either one alone leaves the chain at O(n^2), which still finishes inside + * the budget; only reverting both turns this test red. + */ +import { describe, expect, it } from 'vitest'; +import { xmlToIr } from '@bpmn-script/transform'; +import { parseToAst, printDsl } from './helpers/pipeline.js'; + +const TASK_COUNT = 1000; + +/** A Modeler-style straight-line chain: start -> N plain tasks -> end. */ +function chainBpmn(taskCount: number): string { + const flowElements: string[] = [ + 'Flow_0', + ]; + const sequenceFlows: string[] = []; + let previousId = 'Begin'; + + for (let i = 1; i <= taskCount; i++) { + const taskId = `Task_${i}`; + const flowId = `Flow_${i - 1}`; + // `name` matches `humanize(taskId)` exactly, so the printer drops it as + // a redundant label and each line below reads as plain `step Task_`. + flowElements.push( + `${flowId}`, + ); + sequenceFlows.push( + ``, + ); + previousId = taskId; + } + + const endFlowId = `Flow_${taskCount}`; + flowElements.push( + `${endFlowId}`, + ); + sequenceFlows.push( + ``, + ); + + return [ + '', + '', + ' ', + ...flowElements.map((line) => ` ${line}`), + ...sequenceFlows.map((line) => ` ${line}`), + ' ', + '', + ].join('\n'); +} + +describe('CFG analysis performance', () => { + it('imports and prints a 1,000-task straight-line chain in under 3 s', async () => { + const xml = chainBpmn(TASK_COUNT); + + const started = performance.now(); + const { ir } = await xmlToIr(xml); + const source = printDsl(ir); + const elapsed = performance.now() - started; + + const stepLines = source + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('step ')); + expect(stepLines).toEqual( + Array.from({ length: TASK_COUNT }, (_, i) => `step Task_${i + 1}`), + ); + expect(elapsed).toBeLessThan(3000); + await parseToAst(source); + }); +}); diff --git a/tests/review-loop.round-trip.test.ts b/tests/review-loop.round-trip.test.ts new file mode 100644 index 0000000..97808fe --- /dev/null +++ b/tests/review-loop.round-trip.test.ts @@ -0,0 +1,36 @@ +// The approve-review loop, frozen: a `do ... while` whose body splits under a +// condition on every route, one route leaving the loop. What this suite catches +// is the printer hoisting the staying route out of the loop, which the IR +// comparison sees as a lost edge into the loop gateway, and the compiled split +// losing the fallback the else-less chain gives it. + +import { describe, it, expect } from 'vitest'; + +import { roundTripFixture } from './helpers/round-trip-fixture.js'; + +const rt = roundTripFixture('review-loop', { + dslPrimeFrom: 'frozen', + importPath: true, + recompile: 'clean', +}); + +describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { + it('prints the split inside the loop body, the leaving route as a jump and the staying route inline', () => { + expect(rt.dslPrime).toContain( + [ + ' do {', + ' user ApproveInvoice(assignee: "demo")', + ' if (approved) {', + ' goto PrepareBankTransfer', + ' } else if (!approved) {', + ' call ReviewInvoice(process: "ReviewInvoice") {', + ' in invoiceDocument', + ' out clarified', + ' }', + ' }', + ' } while (clarified)', + '', + ].join('\n'), + ); + }); +}); diff --git a/tests/round-trip-constructs.test.ts b/tests/round-trip-constructs.test.ts index 01102c2..44f09ad 100644 --- a/tests/round-trip-constructs.test.ts +++ b/tests/round-trip-constructs.test.ts @@ -13,6 +13,8 @@ import { parseToAst, printDsl, roundTripOf, + roundTripTwice, + validate, } from './helpers/pipeline.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -243,3 +245,338 @@ describe('bean-call condition stays quoted-raw end-to-end', () => { expect(condition(run.ir3)).toBe('${myBean.check()}'); }); }); + +// The fuzz found every row below by printing, rebuilding and printing again; +// the suites elsewhere in this repo stop after one hop each direction and +// never see a value that is stable on the first print but drifts on the +// second. `xml2`/`dsl2` come from feeding `dsl1` back through the same two +// hops, so a row pins idempotence, not just a single compile. +// +// Two attribute values below carry a literal `"`, which this pipeline's XML +// writer cannot place directly in an attribute and instead numeric-escapes +// (`"`, ` `); decoding those before the substring check lets a +// fragment read the same whether it landed in an attribute or an element body. +function decodeXmlEntities(xml: string): string { + return xml.replace(/"/g, '"').replace(/ /g, '\n'); +} + +const RAW_CONDITION_QUOTE_SRC = [ + 'process p {', + ' if (\'${execution.getVariable("x")}\') {', + ' user A', + ' }', + '}', + '', +].join('\n'); + +const RAW_UNDER_UNARY_SRC = [ + 'process p {', + " if (!'${x}') {", + ' user A', + ' }', + '}', + '', +].join('\n'); + +const QUOTED_RAW_BINDINGS_SRC = [ + 'process p {', + ' service S(delegate: \'${fn("a")}\')', + ' service T(expression: "${bean.m(\\"a\\")}")', + ' user U(assignee: "${who(\\"a\\")}")', + '}', + '', +].join('\n'); + +const BARE_VARIABLE_IO_SRC = [ + 'process p {', + ' var notify_sync: any', + ' service A(class: "x") {', + ' output a = notify_sync', + ' input b = notify_sync.lines', + ' input c = [notify_sync, 1, -644, "s"]', + ' input d = { k: notify_sync, k2: null, k3: true }', + ' }', + ' call C(process: "x") {', + ' in y = notify_sync', + ' }', + '}', + '', +].join('\n'); + +const HASH_TEMPLATE_POSITIONS_SRC = [ + 'process p {', + ' user Review(assignee: "#{initiator}", priority: "#{prio}") {', + ' on end(delegate: "#{auditListener}")', + ' on create(expression: "#{notifier.created(task)}")', + ' }', + ' service Notify(delegate: "#{emailAdapter}")', + ' service Compute(expression: "#{calc.run(order)}", resultVariable: "result")', + ' call Sub(process: "#{subKey}", businessKey: "#{execution.processBusinessKey}") {', + ' in orderId = "#{order.id}"', + ' out subResult = "#{result}"', + ' }', + '}', + '', +].join('\n'); + +const CONTROL_CHAR_BODIES_SRC = [ + 'process p {', + ' service A(class: "x") {', + ' input m = { "a\\nb": 1 }', + ' input s = "l1\\nl2"', + ' field f = "f1\\nf2"', + ' }', + '}', + '', +].join('\n'); + +const HYPHENATED_COLLECTION_SRC = [ + 'process p {', + ' var check-close: any', + ' user A for each in check-close', + '}', + '', +].join('\n'); + +const LITERAL_BUSINESS_KEY_SRC = [ + 'process p {', + ' call C(process: "x", businessKey: "abc") {', + ' in y = "u"', + ' }', + '}', + '', +].join('\n'); + +const CONDITION_VARIABLE_SRC = [ + 'process p {', + ' var shipPlan: any', + ' await condition SendLoad(419 >= shipPlan)', + '}', + '', +].join('\n'); + +const SHARED_END_STARTS_SRC = [ + 'process claim-review {', + ' var urgent: boolean', + ' start FromDesk', + ' start WhenUrgent condition(urgent)', + '}', + '', +].join('\n'); + +const RACE_INTO_SELF_LOOPING_STEP_SRC = [ + 'process claim-review {', + ' await {', + ' message("OrderReceived") {', + ' user Log', + ' }', + ' condition("${fn(\\"a\\")}" * 0.91 == (247)) {', + ' user Nudge', + ' }', + ' }', + ' decide Ship6(decision: "approve-claim")', + ' goto Ship6', + '}', + '', +].join('\n'); + +const ENDING_BRANCH_BESIDE_ELSE_CHAIN_SRC = [ + 'process onboarding {', + ' var a: boolean', + ' var b: boolean', + ' if (a) {', + ' end Done7', + ' } else if (b) {', + ' user Log', + ' } else {', + ' service Approve10(class: "org.acme.Audit")', + ' }', + '}', + '', +].join('\n'); + +const EXPRESSION_MAP_KEY_SRC = [ + 'process p {', + ' service A(class: "x") {', + ' input m = { "\\${dyn}": 1, "\\#{other}": 2, plain: 3 }', + ' }', + '}', + '', +].join('\n'); + +const BACKSLASH_IN_JUEL_STRING_SRC = [ + 'process order-fulfilment {', + ' if ("${region == \\"a\\\\\\\\b\\"}") {', + ' user A', + ' }', + '}', + '', +].join('\n'); + +describe('two-pass round trip: printed expression text stays engine-runnable and stable', () => { + it.each([ + [ + 'a backslash inside a JUEL string literal prints as the doubled escape and compiles back to the same three characters', + BACKSLASH_IN_JUEL_STRING_SRC, + ['${region == "a\\\\b"}'], + ['if (region == "a\\\\b") {'], + ], + [ + 'an out-of-subset raw condition with a quoted method call keeps its inner quotes, not doubled escapes', + RAW_CONDITION_QUOTE_SRC, + ['${execution.getVariable("x")}'], + [], + ], + [ + 'a raw operand under a unary operator splices its body instead of nesting a second ${ }', + RAW_UNDER_UNARY_SRC, + ['${!(x)}'], + [], + ], + [ + 'quoted-raw method-call bindings on delegate, expression and assignee keep their inner quotes', + QUOTED_RAW_BINDINGS_SRC, + [ + 'operaton:delegateExpression="${fn("a")}"', + 'operaton:expression="${bean.m("a")}"', + 'operaton:assignee="${who("a")}"', + ], + [], + ], + [ + 'a bare variable in an io value, a list item or a map entry renders as ${var}, not as its name in text', + BARE_VARIABLE_IO_SRC, + [ + '${notify_sync}', + '${notify_sync.lines}', + '${notify_sync}', + '${1}', + '${-644}', + 's', + '${notify_sync}', + '${null}', + '${true}', + '', + ], + [], + ], + [ + '#{ } bindings across a listener, tasks and a call activity print verbatim with the # opener kept', + HASH_TEMPLATE_POSITIONS_SRC, + [ + 'operaton:assignee="#{initiator}"', + 'operaton:priority="#{prio}"', + '', + '', + 'operaton:delegateExpression="#{emailAdapter}"', + 'operaton:expression="#{calc.run(order)}" operaton:resultVariable="result"', + 'calledElement="#{subKey}"', + '', + '', + '', + ], + [], + ], + [ + // No tab sub-case: moddle-xml's attribute escaper does not escape a + // tab, so a tab in an attribute value reaches a strict XML parser as a + // space (XML attribute-value normalization). Pinning it here would only + // pin this tool's own reader, not byte fidelity through a strict parser. + 'a real line feed in an io value, a map key or a field value prints as its two-character escape', + CONTROL_CHAR_BODIES_SRC, + [ + 'stringValue="f1\nf2"', + 'key="a\nb"', + 'l1\nl2', + ], + ['"f1\\nf2"', '"l1\\nl2"'], + ], + [ + 'a map key that is itself a template opener prints with a backslash so it re-parses as a literal key, not a raw template', + EXPRESSION_MAP_KEY_SRC, + [ + '${1}', + '${2}', + '${3}', + ], + ['"\\${dyn}"', '"\\#{other}"'], + ], + [ + 'a hyphenated collection variable prints as a bare operaton:collection reference, not a quoted string', + HYPHENATED_COLLECTION_SRC, + ['operaton:collection="check-close"'], + ['for each in check-close'], + ], + [ + 'a literal businessKey and a literal in-mapping source print as bare EL text, not a wrapped literal', + LITERAL_BUSINESS_KEY_SRC, + [ + '', + '', + ], + [], + ], + [ + 'a variable a condition reads comes back declared, so the printed script validates clean', + CONDITION_VARIABLE_SRC, + [ + '${419 >= shipPlan}', + ], + [ + ' var shipPlan: any\n', + ' await condition SendLoad(419 >= shipPlan)\n', + ], + ], + [ + 'two starts sharing the implicit end print back to back, and the end stays unwritten', + SHARED_END_STARTS_SRC, + [ + '', + '', + ], + [' start FromDesk\n start WhenUrgent condition(urgent)\n}\n'], + ], + [ + // The step after the race loops on itself, so no node reaches the exit + // and the container has no post-dominators to find the merge with. + 'a race with empty branches into a step that loops on itself keeps the step after the block', + RACE_INTO_SELF_LOOPING_STEP_SRC, + [ + '', + '', + ], + [ + ' }\n }\n decide Ship6(decision: "approve-claim")\n goto Ship6\n}\n', + ], + ], + [ + // The ending branch takes the split's post-dominator to the exit, so + // the else chain used to be read as the continuation and the middle + // branch walked on through the join to the implicit end. + 'an if chain with an ending branch beside an else chain keeps the else, and the implicit end stays unwritten', + ENDING_BRANCH_BESIDE_ELSE_CHAIN_SRC, + [ + '', + ], + [ + ' } else if (b) {\n user Log\n } else {\n service Approve10(class: "org.acme.Audit")\n }\n}\n', + ], + ], + ] as const)('%s', async (_title, source, xmlContains, dslContains) => { + const { xml1, dsl1, xml2, dsl2 } = await roundTripTwice(source); + + const decodedXml = decodeXmlEntities(xml1); + for (const fragment of xmlContains) { + expect(decodedXml).toContain(fragment); + } + for (const fragment of dslContains) { + expect(dsl1).toContain(fragment); + } + + const { diagnostics } = await validate(dsl1); + expect(diagnostics.map((d) => d.message)).toEqual([]); + + expect(xml2).toBe(xml1); + expect(dsl2).toBe(dsl1); + }); +}); From 08dd537f458452c4dc2668265810714016d40518 Mon Sep 17 00:00:00 2001 From: Marlon Kranz Date: Sat, 26 Sep 2026 14:17:02 +0200 Subject: [PATCH 03/11] Print branch chains and block convergence as the model routes them --- README.md | 2 +- ...003-auto-layout-for-diagram-interchange.md | 15 +- ...0034-multiple-start-events-on-a-process.md | 11 +- ...0052-loop-bodies-with-conditioned-exits.md | 1 + packages/language/README.md | 2 +- .../language/src/bpmn-script-validator.ts | 23 +- packages/language/src/vocabulary.ts | 18 +- packages/language/test/validating.test.ts | 18 ++ packages/transform/src/ir-to-dsl.ts | 196 ++++++++++--- packages/transform/src/ir-to-xml.ts | 15 +- packages/transform/src/juel.ts | 8 +- packages/transform/test/ir-to-dsl.test.ts | 262 +++++++++++++++++- tests/examples.health.test.ts | 84 +++--- tests/golden/README.md | 4 +- tests/golden/multiple-starts.bpmnscript | 7 +- tests/round-trip-constructs.test.ts | 228 ++++++++++++++- 16 files changed, 761 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index da8ff57..819b342 100644 --- a/README.md +++ b/README.md @@ -302,7 +302,7 @@ flowchart LR IR -- irToDsl --> SRC ``` -A source file is parsed into an AST, converted into the IR (a small set of plain TypeScript objects in `packages/transform/src/ir/types.ts` that describe a process without reference to any specific engine), and written out from there. +A source file is parsed into an AST, converted into the IR (a small set of plain TypeScript objects in `packages/transform/src/ir/types.ts` that describe a process in the engine's own terms, tied to no file format), and written out from there. Compiling is `.bpmnscript` -> AST -> IR -> `.bpmn`; decompiling is `.bpmn` -> IR -> `.bpmnscript`. The IR carries Operaton's semantics under names with no vendor prefix: the engine's bindings, execution settings, input/output parameters and lifecycle listeners are all plain-named IR fields, and `operaton:` is applied only where `irToXml` builds the moddle element from a local [moddle extension](packages/transform/src/operaton-moddle.json) ([ADR-0006](docs/decisions/0006-intermediate-representation-between-ast-and-bpmn.md), [ADR-0022](docs/decisions/0022-engine-attributes-as-named-ir-fields.md), [ADR-0023](docs/decisions/0023-listeners-on-the-attribute-block.md)). diff --git a/docs/decisions/0003-auto-layout-for-diagram-interchange.md b/docs/decisions/0003-auto-layout-for-diagram-interchange.md index f709216..64c8320 100644 --- a/docs/decisions/0003-auto-layout-for-diagram-interchange.md +++ b/docs/decisions/0003-auto-layout-for-diagram-interchange.md @@ -42,13 +42,18 @@ The CLI and the extension catch it, write that diagram-less document, and warn t The engine deploys it unchanged: `BpmnParse.parseDiagramInterchangeElements` reads a diagram only when the document's `BPMNDiagram` list is non-empty. A document with none skips diagram parsing and deploys on its process content alone. -The layout as it stands has two known defects, each measured on the current tree. +The layout as it stands has three known defects, each measured on the current tree. The measurement walks every flow node for a shape, every sequence flow for an edge, and every edge segment for an activity shape it crosses that is neither its source nor its target. First, a `goto` edge into a sub-process is routed across the sub-process's own children instead of around them. The example `order-handling`'s `Flow_MarkAutoApproved_Payment` crosses both the `Payment` sub-process's start event and its first task. The golden `boundary-events`' `Flow_MarkAddressVerified_PackGoods` crosses the `PackGoods` sub-process's start event and its first task the same way. -Second, two boundary events on one host are placed along the host's lower edge with no spread of their own. -In the golden `boundary-events`, the two events on `CheckAddress` sit only a few pixels apart on a host narrower than their combined width. -The two on `PackGoods` are both centered on the host's bottom edge with no vertical offset between them. -A border-termination pass for the first defect and a boundary-spread pass for the second are both considered options, not committed work. +Second, the spread of several boundary events on one host scales with the host's width rather than with the events' own. +The library places the `i`-th of `n` attachers at `host.x + (i + 1) * host.width / (n + 1) - 18`, all on the host's lower edge. +Two events on a 100 px task therefore sit 33 px apart at their centers while each is 36 px wide. +In the golden `boundary-events`, the two events on `CheckAddress` (x 625 to 725) span x 640.3 to 676.3 and x 673.7 to 709.7, overlapping by 2.7 px. +Two events overlap on any host narrower than 108 px, three times an event's width, and every further event narrows the spacing again; the two on the 850 px wide `PackGoods` sub-process land 283 px apart and clear each other. +Third, a guard clause such as `if (chargeDeclined) { end BookingAbandoned }` keeps the join that only its default flow reaches (ADR-0045). +The layouter puts the end event in the cell between the split and that join on the same row, so the default edge runs straight through it. +The example `booking-attempt`'s `Flow_Gateway_booking-attempt_1_2_split_default` crosses `BookingAbandoned` this way, and the goldens `event-handlers` (`PaymentFailed`) and `transactions` (`BookingAbandoned`) carry the same shape. +A border-termination pass for the first defect and a boundary-spread pass for the second are both considered options, not committed work; the third goes away with the join elision ADR-0045 defers. diff --git a/docs/decisions/0034-multiple-start-events-on-a-process.md b/docs/decisions/0034-multiple-start-events-on-a-process.md index 3b7d6ba..975a0dd 100644 --- a/docs/decisions/0034-multiple-start-events-on-a-process.md +++ b/docs/decisions/0034-multiple-start-events-on-a-process.md @@ -76,8 +76,11 @@ The decompiler walks an unnamed plain start ahead of every other. Such a start is elided on print and re-derived by the compiler at the head of the body, so its chain has to be the first one printed, else the entry point is lost on the way back. One corner is accepted rather than fixed, the one ADR-0019 already records for a boundary chain. -A start whose first step is a synthesized gateway, as in `start A`, `start B`, `if (x) { ... }`, compiles to correct XML but prints back as a dropped-edge marker, because the gateway's out-edges were consumed by the `if` printed for the first chain. -The authoring rule that follows: write each start ahead of a named step. +A start whose first step is a synthesized gateway prints the branch when every start into that gateway prints: `start A`, `start B`, `if (x) { ... }` round-trips clean. +The corner survives when one of those starts is the elided plain start, the one carrying the minted `StartEvent_` id and no content. +Its chain is walked first, the `if` printed for it consumes the gateway's out-edges, and the other start prints back as a dropped-edge marker. +No source spells that shape, since `goto` names an authored statement and never a synthesized gateway, so it reaches the printer through an imported document alone. +The authoring rule that keeps clear of it: write each start ahead of a named step. The golden fixture and the `support-ticket` example both obey it. ### Consequences @@ -88,9 +91,9 @@ The golden fixture and the `support-ticket` example both obey it. Refusing the shape is what rules that out. - Neutral, because `initiator` is read off every start and set on the process definition by `BpmnParse.parseProcessDefinitionStartEvent`, last one wins, and the validator warns on every start but the last that names one. - Neutral, because a message start whose name is already subscribed by another deployed definition fails in `BpmnDeployer.addMessageStartEventSubscription`, a fact about the deployment rather than the file, and out of this tool's reach. -- Bad, because the printed form of a process with several starts is not the authored one: every start after the first chain prints after that chain's `end`, followed by a `goto` onto the shared step. +- Bad, because the printed form of a process with several starts is not always the authored one: starts entering the same step print back to back above it, even one written after an `end` with a `goto` onto that step. The graph is the same, which the idempotence block asserts, but the reader has to learn the shape. -- Bad, because the corner above means a start into a synthesized gateway round-trips with a warning rather than cleanly. +- Bad, because the corner above means an imported document with an unnamed plain start into a shared synthesized gateway round-trips with a warning rather than cleanly. ### Confirmation diff --git a/docs/decisions/0052-loop-bodies-with-conditioned-exits.md b/docs/decisions/0052-loop-bodies-with-conditioned-exits.md index c6523cb..4ec9c1e 100644 --- a/docs/decisions/0052-loop-bodies-with-conditioned-exits.md +++ b/docs/decisions/0052-loop-bodies-with-conditioned-exits.md @@ -84,6 +84,7 @@ The `review-loop` round-trip suite pins the frozen artifact byte for byte, its i ## More Information The predicate is `enclosingContinuation` in `packages/transform/src/ir-to-dsl.ts`; the containment check is in `cleanJoin` beside it. +`emitRoutes` asks a further step between the clean join and the guard clause, `convergence`, which reads where the split's live routes come back together off the model's routes by forward reachability bounded by the stop node, and so answers a split whose branch can end and whose post-dominator therefore lies at the exit. Amends ADR-0009, whose catalog gains the enclosing continuation beside the clean join and the guard clause. diff --git a/packages/language/README.md b/packages/language/README.md index ea4cc29..714b2e6 100644 --- a/packages/language/README.md +++ b/packages/language/README.md @@ -418,7 +418,7 @@ An element name with no collection to take it from is a deployment error in Oper The runs happen at once, which is the engine's own default, unless `sequentially` orders them one after another. `until ( )` drops the runs still outstanding, once the condition holds. -Operaton keeps `nrOfInstances`, `nrOfActiveInstances`, and `nrOfCompletedInstances` on the repetition as a whole and evaluates the completion condition there, so those three are the names the condition can read. +Operaton keeps `nrOfInstances`, `nrOfActiveInstances`, and `nrOfCompletedInstances` on the repetition as a whole, where every run's condition can read them, so those three are the names the condition can read. `loopCounter` is set on each run and is readable there too: in the run's body, its `input` parameters, and the `until` condition alike. A sequential repetition evaluates the condition on the run that just finished, since only one run is ever active (`SequentialMultiInstanceActivityBehavior.complete`); a parallel one evaluates it on whichever concurrent run just ended, not the whole set, so `loopCounter` there names that one run's index (`ParallelMultiInstanceActivityBehavior.concurrentChildExecutionEnded`). All four are in scope without a `var` declaration, as is the element name. diff --git a/packages/language/src/bpmn-script-validator.ts b/packages/language/src/bpmn-script-validator.ts index f211233..2b4e3d6 100644 --- a/packages/language/src/bpmn-script-validator.ts +++ b/packages/language/src/bpmn-script-validator.ts @@ -162,6 +162,7 @@ import { JOIN_ENGINE_KEYS, JOIN_KEY_BY_ENGINE_KEY, joinSettingKey, + JUEL_LITERAL_WORDS, JUEL_RESERVED_WORDS, LISTENER_BINDING_KEYS, listenerEventsFor, @@ -789,6 +790,17 @@ const JUEL_RESERVED_WORD_SET: ReadonlySet = new Set( JUEL_RESERVED_WORDS, ); +/** + * A raw template is plain text, so unlike a declaration or a rendered + * accessor it can spell `true`, `false` or `null` as a `.prop` read; the + * scanner still returns their keyword tokens there (see + * {@link JUEL_LITERAL_WORDS}). + */ +const JUEL_RAW_TEMPLATE_WORD_SET: ReadonlySet = new Set([ + ...JUEL_RESERVED_WORDS, + ...JUEL_LITERAL_WORDS, +]); + /** @param key The string-key spelling that reaches the name, `execution.getVariable('x')` or `order['x']`. */ const juelKeywordMessage = (word: string, key: string) => `'${word}' is a JUEL keyword (Scanner.addKeyToken), so the engine refuses any expression naming it. Reach the variable through a string key instead: "\${${key}}".`; @@ -4773,6 +4785,9 @@ export class BpmnScriptValidator { catchEvent: CatchHeader, accept: ValidationAcceptor, ): void { + // A race branch whose leading `ID` a syntax error swallows still reaches + // the AST with no trigger at all; the parser errors already say so, and + // naming 'undefined' as an unknown event kind would only pile on. if (catchEvent.trigger === undefined) return; if (!CATCH_TRIGGERS_SET.has(catchEvent.trigger)) { @@ -5708,8 +5723,10 @@ const STRING_OR_PROPERTY_READ = /** * Every `.prop` a raw template reads, held to the keyword rule a rendered - * accessor is under: the scanner registers the keywords as operators wherever - * they stand, so `${order.and}` fails to parse at deployment. Text between + * accessor is under, plus `true`/`false`/`null`: the scanner registers all of + * them as operators or literal tokens wherever they stand, so `${order.and}` + * and `${order.true}` both fail to parse at deployment, though the grammar + * would never let either spell a variable or an accessor name. Text between * two templates is never scanned as an expression, so a word there is text. */ function checkRawTemplateNames( @@ -5720,7 +5737,7 @@ function checkRawTemplateNames( for (const [, body] of raw.matchAll(TEMPLATE_BODIES)) { for (const match of body!.matchAll(STRING_OR_PROPERTY_READ)) { const word = match[1]; - if (word === undefined || !JUEL_RESERVED_WORD_SET.has(word)) continue; + if (word === undefined || !JUEL_RAW_TEMPLATE_WORD_SET.has(word)) continue; // The object as written, `items[0]` or `order.line`, for the hint. const object = /[\w$.[\]'"]*$/.exec( body!.slice(0, match.index).trimEnd(), diff --git a/packages/language/src/vocabulary.ts b/packages/language/src/vocabulary.ts index c78e7d3..a4225f3 100644 --- a/packages/language/src/vocabulary.ts +++ b/packages/language/src/vocabulary.ts @@ -326,10 +326,10 @@ export const CALL_BINDING_VALUES: readonly string[] = ['latest', 'deployment']; * The settings a process header takes, in the order they are offered and * printed. Each attaches to the `bpmn:process` element itself rather than to * any node inside it. `BpmnParse.parseProcess` also reads `jobPriority`, - * `taskPriority`, `isStartableInTasklist`, a process-level execution listener, - * and `potentialStarter` off the same element; this set leaves them out on - * purpose, since giving them authoring surface is a separate addition, not a - * gap in this list. + * `taskPriority`, and `isStartableInTasklist` off the same element, and, + * through `parseScope`, a process-level execution listener and + * `potentialStarter`; this set leaves them out on purpose, since giving them + * authoring surface is a separate addition, not a gap in this list. */ export const PROCESS_HEADER_KEYS: readonly string[] = [ 'label', @@ -527,6 +527,16 @@ export const JUEL_RESERVED_WORDS = [ 'instanceof', ] as const; +/** + * `true`, `false` and `null`, which the grammar spells as literals so a + * variable or a rendered `.prop` accessor can never carry one. A raw template + * is plain text, so `${order.true}` reaches `Scanner.nextIdentifier`, which + * returns the `Symbol.TRUE` keyword token there too; `Parser.parseDotToken`'s + * `consumeToken(IDENTIFIER)` then refuses it. The raw-template scan checks the + * union of this and {@link JUEL_RESERVED_WORDS}. + */ +export const JUEL_LITERAL_WORDS = ['true', 'false', 'null'] as const; + /** * Written to `datePattern`, which `FormTypes.parseFormPropertyType` reads on * a `date` field alone. A type parameter rather than a constraint, so it is diff --git a/packages/language/test/validating.test.ts b/packages/language/test/validating.test.ts index c10d42b..95df107 100644 --- a/packages/language/test/validating.test.ts +++ b/packages/language/test/validating.test.ts @@ -1061,6 +1061,11 @@ checks('Validation - a JUEL keyword or a hyphen in a rendered name', [ [juelKeyword(word, `order['${word}']`)], ], ]), + [ + "'true' as a property inside a raw template is the same operator", + `process p { if ("\${order.true}") { user A } }`, + [juelKeyword('true', "order['true']")], + ], [ 'a keyword read off a bracketed object in a raw template is keyed on that object', `process p { if ("\${items[0].and}") { user A } }`, @@ -4424,6 +4429,19 @@ checks('Validation - awaited events', [ `process p { await { message("Dup") { user A } signal("Dup") { user B } } }`, [], ], + [ + 'a race branch a parse error leaves without a trigger draws no phantom trigger error', + `process p { start S await { (label: "x") message M } }`, + [ + "Expecting token of type 'ID' but found `(`.", + "Expecting token of type '{' but found `message`.", + "Expecting token of type '=' but found `}`.", + "Expecting: expecting at least one iteration which starts with one of these possible Token sequences::\n <[ID]>\nbut found: '}'", + notValidOn('label', 'a branch of an await block'), + warn(emptyNumberedBranch(1, 'await')), + blockParameter('message'), + ], + ], ]); describe('Validation - a race branch runs the plain await rules', () => { diff --git a/packages/transform/src/ir-to-dsl.ts b/packages/transform/src/ir-to-dsl.ts index 881414a..dcdd5b0 100644 --- a/packages/transform/src/ir-to-dsl.ts +++ b/packages/transform/src/ir-to-dsl.ts @@ -333,6 +333,13 @@ class Emitter { stmt: Lines; id: string; }[] = []; + /** + * The plain end minted for this container when the printer leaves it out, + * which is the block's tail unless a chain hoisted behind it forces it to + * print. A boundary body's minted end is not it: that body prints in an + * array of its own, where nothing follows its end. + */ + private readonly elidedTail: string | undefined; constructor( private readonly container: FlowContainer, @@ -347,6 +354,13 @@ class Emitter { private readonly startTriggerSuppressed = false, ) { this.cfg = analyzeCfg(container); + this.elidedTail = container.flowElements.find( + (el) => + el.kind === 'endEvent' && + el.eventDefinition === undefined && + isMintedEndId(el.id, container.id, []) && + isElidedOnPrint(el, container), + )?.id; for (const el of container.flowElements) { // A duplicate would corrupt the walk. The desugarer resolves collisions, // so one here means malformed IR. @@ -365,11 +379,13 @@ class Emitter { } /** - * The order of the first three passes is a constraint. A boundary escape - * chain is never reached from a start event, so walking it before the orphan - * sweep stops that sweep printing the chain as detached top-level statements, - * and walking it after the structured pass makes a chain rejoining the main - * flow degrade to a `goto`, its targets being emitted already. + * The order of the first four passes is a constraint. A node belongs to the + * chain that reaches it from one of the container's own entries, a start or + * a node no flow enters (a link catch above all), so every such chain is + * walked before the boundary handlers and a handler whose body jumps to one + * degrades to a `goto` there, its target being emitted already. A boundary + * escape chain is reached from no entry, so walking it before the orphan + * sweep stops that sweep printing the chain as detached top-level statements. */ emit(): string[] { const lines: string[] = []; @@ -394,9 +410,19 @@ class Emitter { } } - // 2. Boundary handlers, held back because the surface requires a handler + // 2. Every chain an entry reaches that pass 1 left for a jump, in model + // order like the orphan sweep, which is what places a link catch's + // chain among them. + const owned = this.reachableFromEntries(); + for (const el of this.container.flowElements) { + if (owned.has(el.id) && !this.emittedNodes.has(el.id)) { + this.emitFrom(el.id, undefined, lines, 0); + } + } + + // 3. Boundary handlers, held back because the surface requires a handler // block to follow the body it guards. Each block is kept under its - // element's index so pass 5 can place it among the event sub-processes + // element's index so pass 6 can place it among the event sub-processes // the way the model orders them. const boundaryBlocks = new Map(); this.container.flowElements.forEach((el, index) => { @@ -407,7 +433,8 @@ class Emitter { } }); - // 3. Orphaned fragments. + // 4. Orphaned fragments: a cycle no entry reaches, and what a handler's + // walk left for a jump. for (const el of this.container.flowElements) { if (isHandler(el) || isBoundary(el)) continue; if (!this.emittedNodes.has(el.id)) { @@ -415,7 +442,7 @@ class Emitter { } } - // 4. Final goto sweep. A jump carries the route and nothing else, so a + // 5. Final goto sweep. A jump carries the route and nothing else, so a // condition on one of these goes the way a fall-through's does. for (const f of this.container.sequenceFlows) { if (!this.consumedFlows.has(f.id)) { @@ -438,7 +465,7 @@ class Emitter { } for (const id of printedEnds) this.warnings.push(reservedNameWarning(id)); - // 5. Trailing handler group, in model order: the compiler lays handlers + // 6. Trailing handler group, in model order: the compiler lays handlers // down in statement order and numbers an event sub-process by its // statement index, so any other order renumbers it on the next compile. this.container.flowElements.forEach((el, index) => { @@ -512,6 +539,36 @@ class Emitter { this.emitFrom(route.targetRef, undefined, lines, 0); } + /** + * Every node a flow can carry a token to from one of the container's own + * entries: a start, or a node no flow enters, which is a link catch or a + * stranded fragment. A boundary event is an entry too, but its chain is the + * handler's own until an entry's chain reaches into it. + */ + private reachableFromEntries(): Set { + const entered = new Set( + this.container.sequenceFlows.map((f) => f.targetRef), + ); + const reached = new Set(); + const pending = this.container.flowElements + .filter( + (el) => + !isBoundary(el) && + !isHandler(el) && + (el.kind === 'startEvent' || !entered.has(el.id)), + ) + .map((el) => el.id); + while (pending.length > 0) { + const id = pending.pop()!; + if (reached.has(id)) continue; + reached.add(id); + for (const f of this.outgoingBySource.get(id) ?? []) { + pending.push(f.targetRef); + } + } + return reached; + } + /** The one route the model gives a node, or `undefined` where it splits or ends. */ private soleRoute(id: string): SequenceFlow | undefined { const outs = this.outgoingBySource.get(id) ?? []; @@ -1003,17 +1060,20 @@ class Emitter { * a branch walked past it prints the continuation inside the block. * * A route straight into a sink is a branch that ends and never counts. Of - * the rest, a route is live when it reaches `stop` or a node another route - * reaches; where none does, every one is. Among the nodes the live routes - * share, in the first one's breadth order: a merge, a gateway with one - * route out, ahead of a step on the way to it, since the compiler lowers - * every block with a merge and a step ahead of it belongs to the branch; - * then `stop`; then the nearest, except for a lone live route heading a - * branch with its condition, whose whole chain is that branch and which - * continues where the chain ends. `joinKind` narrows the answer to a merge - * of that kind the split dominates, the only node a fork or a race - * synchronizes at: a merge entered from outside the block as well would - * synchronize that entry too once the block's own join stands for it. + * the rest, the routes that reach the block's exit, which is `stop` or, + * at the top of the container, its elided end, are the live ones; where + * none does, a route is live when it reaches a node another route reaches, + * and where none does that either, every one is. Among the nodes the live + * routes share, in the first one's breadth order: a merge, a gateway with + * one route out, ahead of a step on the way to it, since the compiler + * lowers every block with a merge and a step ahead of it belongs to the + * branch; then the exit; then the nearest step no live route can end or + * leave before, except for a lone live route heading a branch with its + * condition, whose whole chain is that branch and which continues at the + * farthest such step. `joinKind` narrows the answer to a merge of that kind + * the split dominates, the only node a fork or a race synchronizes at: a + * merge entered from outside the block as well would synchronize that + * entry too once the block's own join stands for it. */ private convergence( splitId: string, @@ -1021,39 +1081,86 @@ class Emitter { stop: string | undefined, joinKind?: Gateway['kind'], ): string | undefined { + const exit = stop ?? this.elidedTail; const routes = outs .filter((f) => (this.outgoingBySource.get(f.targetRef) ?? []).length > 0) .map((f) => ({ f, reach: this.reachable(f.targetRef, splitId, stop) })); const shares = ({ reach }: (typeof routes)[number]): boolean => - [...reach].some( - (n) => - n === stop || routes.some((r) => r.reach !== reach && r.reach.has(n)), + [...reach].some((n) => + routes.some((r) => r.reach !== reach && r.reach.has(n)), ); - const live = routes.some(shares) ? routes.filter(shares) : routes; + const running = routes.filter( + ({ reach }) => exit !== undefined && reach.has(exit), + ); + const live = + running.length > 0 + ? running + : routes.some(shares) + ? routes.filter(shares) + : routes; if (live.length === 0) return undefined; - const shared = [...live[0]!.reach].filter((n) => - live.every((r) => r.reach.has(n)), + // A node every path into the split passes lies upstream of it, so + // continuing there is a jump back over the block, not its merge. `stop` + // is kept: a loop head dominates its body and is where the body ends. + const shared = [...live[0]!.reach].filter( + (n) => + live.every((r) => r.reach.has(n)) && + (n === stop || !this.cfg.dominates(n, splitId)), ); - const isMerge = (n: string): boolean => { + const isMerge = (n: string, kind?: FlowElement['kind']): boolean => { const el = this.byId.get(n); return ( el !== undefined && isGateway(el) && (this.outgoingBySource.get(n) ?? []).length === 1 && - (joinKind === undefined || - (el.kind === joinKind && this.cfg.dominates(splitId, n))) + (kind === undefined || + (el.kind === kind && this.cfg.dominates(splitId, n))) ); }; - const merge = shared.find(isMerge); + // A lone live route's reach holds every merge on its way, a nested or a + // later block's beside this block's own, and the graph does not tell + // them apart in general: the compiler keeps a join one route enters. + // What it does tell: this block's join is entered by the lone route + // alone where a nested if/else's is entered by each of its routes, and a + // choice's join is a merge of its own kind where a nested fork's is not. + // A nested guard clause's join is entered by one route too, so it can + // still be taken first; the block's tail then prints after the block. + const ownKind = joinKind ?? this.byId.get(splitId)?.kind; + const merge = + live.length === 1 + ? (shared.find( + (n) => isMerge(n, ownKind) && this.cfg.incoming(n).length === 1, + ) ?? + shared.find((n) => isMerge(n, ownKind)) ?? + (joinKind === undefined ? shared.find((n) => isMerge(n)) : undefined)) + : shared.find((n) => isMerge(n, joinKind)); if (joinKind !== undefined) return merge; if (merge !== undefined) return merge; - if (stop !== undefined && shared.includes(stop)) return stop; + if (exit !== undefined && shared.includes(exit)) { + // A branch's chain leaves the block through its join, so a lone route + // that runs into the elided end past no merge was written behind an + // authored end and jumped to; the guard clause keeps it that way. + return live.length === 1 && stop === undefined ? undefined : exit; + } const [only] = live; const weighed = live.length === 1 && only!.f.conditionExpression !== undefined && only!.f.id !== this.splitFallbackFlowId(splitId); - return weighed ? shared.at(-1) : shared[0]; + // A step every live route reaches is the merge only when none can end + // or leave for `stop` before it: two jumps into one step from a branch + // and from the chain after the block reach it as well, and the chain + // walked with the step as its stop prints nested inside the branch, + // which the next pass prints hoisted. + const leavesBefore = (entry: string, n: string): boolean => + [...this.reachable(entry, splitId, n)].some( + (m) => + m !== n && + (m === stop || (this.outgoingBySource.get(m) ?? []).length === 0), + ); + const holds = (n: string): boolean => + live.every((r) => !leavesBefore(r.f.targetRef, n)); + return weighed ? shared.findLast(holds) : shared.find(holds); } /** @@ -1159,9 +1266,18 @@ class Emitter { * a guard clause whose entry is a bare authored end that the split's route * reaches as its only incoming flow; and a guard clause whose entry is a * gateway the split owns, which a jump could not name anyway, and whose - * routes the walk sorts with `join` as their stop node. An authored entry - * with a chain of its own stays a `goto`, keeping that chain at its authored - * scope so its coordinate-derived ids survive the round trip. + * routes the walk sorts with `join` as their stop node. + * + * An authored entry with a chain of its own is decided by the block's tail. + * A jump hoists the chain behind everything walked from the starts; where + * that ends on the plain end the printer leaves out, the chain would follow + * that end on the page and push it off the one position the compiler + * re-derives it at, so the chain prints inside the branch. Where the block + * ends on authored terminals the chain stays a `goto` at its authored + * scope, so the coordinate-derived ids of its unnamed events survive the + * round trip. A chain that runs into the elided end is that tail itself and + * stays a `goto` as well: printed inside the branch, the end would print + * under its reserved id. */ private branchStaysInRegion( entry: string, @@ -1179,7 +1295,13 @@ class Emitter { } if (isGateway(el)) return true; if (isSynthesizedTerminalId(entry, el.kind, this.container)) return true; - return el.kind === 'endEvent' && this.cfg.incoming(entry).length === 1; + if (el.kind === 'endEvent' && this.cfg.incoming(entry).length === 1) { + return true; + } + return ( + this.elidedTail !== undefined && + !this.reachable(entry, splitId, join).has(this.elidedTail) + ); } /** diff --git a/packages/transform/src/ir-to-xml.ts b/packages/transform/src/ir-to-xml.ts index 8b2a0e3..4acd070 100644 --- a/packages/transform/src/ir-to-xml.ts +++ b/packages/transform/src/ir-to-xml.ts @@ -1,8 +1,8 @@ /** * IR to BPMN 2.0 XML, producing a document Operaton can parse and deploy. The * `operaton:` namespace is attached here through the local - * `operaton-moddle.json` extension; the IR itself stays vendor-neutral (ADR - * 0006). + * `operaton-moddle.json` extension; the IR carries the same settings under + * vendor-free names (ADR 0006). * * Three steps are more than a mapping: ``/`` are * computed per flow node, a `bpmndi:BPMNShape isExpanded` hint is authored per @@ -62,9 +62,10 @@ import { /** * `BpmnParse.parseDefinitionsAttributes` stores this as `this.targetNamespace`, - * `parseProcess` sets it as the process definition's category, and - * `resolveName` prefixes every unqualified message and signal id and ref with - * it. None of that dereferences it as a URI; the engine never fetches it. + * `parseProcess` sets it as the process definition's category, `parseMessages` + * and `parseSignals` prefix every message and signal id with it, and + * `resolveName` prefixes an unqualified `messageRef` or `signalRef` the same + * way. None of that dereferences it as a URI; the engine never fetches it. */ const TARGET_NAMESPACE = 'http://bpmnscript.io/processes'; @@ -1199,8 +1200,8 @@ function buildScript( } /** - * `number` becomes `long`, so the IR spelling can stay vendor-neutral (ADR - * 0006). Read back by the import direction, which inverts it. + * `number` becomes `long`: the IR carries the engine's type under a vendor-free + * name (ADR 0006). Read back by the import direction, which inverts it. */ export const FORM_FIELD_TYPE_TO_OPERATON: Record = { string: 'string', diff --git a/packages/transform/src/juel.ts b/packages/transform/src/juel.ts index e2dc4a2..3f30916 100644 --- a/packages/transform/src/juel.ts +++ b/packages/transform/src/juel.ts @@ -488,9 +488,11 @@ function renderNode(node: JuelNode): string { case 'decimal': return String(node.value); case 'string': - // The grammar's string reader turns `\b` into a backspace and drops any - // other lone backslash, so only the doubled form reads back; it is also - // the one escape `Scanner.nextString` in operaton-juel takes besides `\"`. + // The grammar's string reader (Langium's `convertEscapeCharacter`) + // resolves `\b \f \n \r \t \v \0` to control characters and, for any + // other character, drops the backslash and keeps the character, so only + // the doubled form reads back as a backslash; it is also the one escape + // `Scanner.nextString` in operaton-juel takes besides `\"`. return `"${escapeQuoted(node.value)}"`; case 'bool': return node.value; diff --git a/packages/transform/test/ir-to-dsl.test.ts b/packages/transform/test/ir-to-dsl.test.ts index fcc1b54..4bf6ae6 100644 --- a/packages/transform/test/ir-to-dsl.test.ts +++ b/packages/transform/test/ir-to-dsl.test.ts @@ -1884,11 +1884,11 @@ describe('irToDsl: event layer (compensation)', () => { }); }); -// A boundary event is the only IR node with outgoing but no incoming flow, so -// its chain is unreachable from the start event and a pass of its own prints -// it, before the orphan sweep would flush it as a detached top-level chain. -// The chain lives in the same container as the main flow, so the shared -// emitted-node bookkeeping is what makes a rejoin degrade to a `goto`. +// A boundary event's chain is entered by no start or link catch, so the entry +// passes never reach it and a pass of its own prints it, before the orphan +// sweep would flush it as a detached top-level chain. The chain lives in the +// same container as the main flow, so the shared emitted-node bookkeeping is +// what makes a rejoin degrade to a `goto`. describe('irToDsl: boundary events', () => { /** @@ -2525,18 +2525,21 @@ describe("irToDsl: a synthesized plain end that is not its block's tail", () => [], ], [ - 'a plain end whose chain a later goto-reached step follows prints before that step, and its label rides along', - 'process p { start S step A if (x) { goto B } step C end Done(label: "Order filed") step B end Fin }', + // A link catch is an entry of its own, so its chain is never walked + // inside the guard the way a chain the split owns is. + "a plain end whose chain a link catch's chain follows prints before that catch, and its label rides along", + 'process p { start S step A if (x) { emit link L("x") } step C end Done(label: "Order filed") await link M("x") step B end Fin }', [ 'process p {', ' var x: any', ' start S', ' step A', ' if (x) {', - ' goto B', + ' emit link L("x")', ' }', ' step C', ' end EndEvent_p(label: "Order filed")', + ' await link M("x")', ' step B', ' end Fin', '}', @@ -2729,6 +2732,91 @@ describe('irToDsl: authored terminal in a guard clause', () => { }); }); +// A branch whose chain ends before the join is walked inline only where the +// block's tail is the end the printer leaves out: hoisted behind that end, the +// chain would push it off the tail and print it under its reserved id. With +// an authored end closing the block the chain stays a jump at its authored +// scope, so the coordinate ids of its unnamed events survive the round trip, +// which the multiset comparison pins; a chain running into the elided end is +// that tail and stays a jump too. +describe('irToDsl: an authored chain a branch owns', () => { + it.each([ + [ + 'two branches whose chains end print them inline, and the implicit end stays unwritten', + [ + 'process p {', + ' var a: any', + ' var b: any', + ' if (a) {', + ' user A', + ' throw message("Quote Received")', + ' } else if (b) {', + ' user B', + ' await {', + ' message("OrderReceived") {', + ' end Done', + ' }', + ' message("Quote Received") {', + ' user C', + ' }', + ' }', + ' } else {', + ' user D', + ' }', + '}', + ], + ], + [ + 'a guard clause whose branch runs a step into an end prints the step inside the branch', + [ + 'process p {', + ' var a: any', + ' if (a) {', + ' user A', + ' end Stop', + ' }', + ' user B', + '}', + ], + ], + [ + 'a chain jumped to behind an authored end stays behind it, keeping the coordinate id of its unnamed throw', + [ + 'process p {', + ' error E', + ' var x: any', + ' if (x) {', + ' goto X', + ' }', + ' user C', + ' end Done', + ' user X', + ' throw error(E)', + '}', + ], + ], + [ + 'a chain that runs into the implicit end is the tail and stays a jump', + [ + 'process p {', + ' var a: any', + ' if (a) {', + ' goto X', + ' }', + ' user B', + ' end Done', + ' user X', + '}', + ], + ], + ])('%s', async (_title, lines) => { + const source = `${lines.join('\n')}\n`; + const ir = await reDesugar(source); + expect(await expectIdempotent(ir)).toBe(source); + expect(printDsl(ir).warnings).toEqual([]); + }); +}); + describe('irToDsl: routes leaving a loop beside the two it is built from', () => { /** * A review loop with an escalate exit: the loop head routes back, escalates, @@ -5839,12 +5927,170 @@ describe('irToDsl: a branch walk stops where the block comes back together', () '}', ], ], + [ + 'an if whose one branch runs on to the implicit end beside two that end keeps the end unwritten', + [ + 'process p {', + ' if (b) {', + ' user A', + ' } else if (c) {', + ' user B', + ' end D', + ' } else {', + ' user C', + ' end E', + ' }', + '}', + ], + ], + [ + 'an if over a parallel beside two ending branches that share a step stops at its own join, not at that step', + [ + 'process p {', + ' if (b) {', + ' parallel {', + ' {', + ' user A1', + ' }', + ' {', + ' user A2', + ' }', + ' }', + ' } else if (c) {', + ' user B', + ' end D', + ' } else {', + ' user C', + ' goto B', + ' }', + '}', + ], + ], + [ + 'an if over a parallel beside an ending else keeps the join settings on the parallel', + [ + 'process p {', + ' if (b) {', + ' parallel (joinAsyncBefore: true) {', + ' {', + ' user A1', + ' }', + ' {', + ' user A2', + ' }', + ' }', + ' } else {', + ' end E', + ' }', + '}', + ], + ], + [ + 'an if with an ending else whose branch holds a nested if stops at its own join, not at the nested one', + [ + 'process p {', + ' if (c) {', + ' user A', + ' if (b) {', + ' user B1', + ' }', + ' user A2', + ' } else {', + ' end X', + ' }', + '}', + ], + ], + [ + 'a race whose one branch ends and whose other runs on to the implicit end keeps that branch whole', + [ + 'process p {', + ' await {', + ' message("M") {', + ' if (x) {', + ' emit message("PaymentDone")', + ' } else {', + ' user Charge', + ' }', + ' step Approve', + ' }', + ' timer("P3D") {', + ' user Archive', + ' end Done terminate', + ' }', + ' }', + '}', + ], + ], ] as const)('%s', async (_title, lines) => { const source = `${lines.join('\n')}\n`; const ir = await reDesugar(source); expect(bodyOf(await expectIdempotent(ir))).toEqual(source); expect(printDsl(ir).warnings).toEqual([]); }); + + // Every path into the split passes `P`, so `P` lies upstream of the block + // and is no merge of it: taken as the join, the print would jump back to + // it from a position nothing reaches, after a block whose every branch + // jumps. + it.each([ + [ + 'an if whose branch and following chain both jump back to the step above the split', + [ + 'process p {', + ' var a: any', + ' user P', + ' if (a) {', + ' user A', + ' goto P', + ' }', + ' user B', + ' goto P', + '}', + ], + ], + [ + 'an if whose two branches both jump back to the step above the split', + [ + 'process p {', + ' var a: any', + ' user P', + ' if (a) {', + ' user A', + ' goto P', + ' } else {', + ' user B', + ' goto P', + ' }', + '}', + ], + ], + ] as const)( + '%s prints as the guard clause, with the jump back outside the block', + async (_title, lines) => { + const ir = await reDesugar(`${lines.join('\n')}\n`); + const source = await printed(ir); + expect(bodyOf(source)).toEqual( + [ + 'process p {', + ' user P', + ' if (a) {', + ' goto A', + ' }', + ' user B', + ' goto P', + ' user A', + ' goto P', + '}', + '', + ].join('\n'), + ); + expect(printDsl(ir).warnings).toEqual([]); + expect(realReachability(await reDesugar(source))).toEqual( + realReachability(ir), + ); + }, + ); }); describe('irToDsl: an event sub-process a flow edge leads into', () => { diff --git a/tests/examples.health.test.ts b/tests/examples.health.test.ts index 05559c4..598e3a2 100644 --- a/tests/examples.health.test.ts +++ b/tests/examples.health.test.ts @@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url'; import { astToIr, irToDsl, irToXml, xmlToIr } from '@bpmn-script/transform'; -import { parse, parseToAst, validate } from './helpers/pipeline.js'; +import { parseToAst, validate } from './helpers/pipeline.js'; import { normalizeIr } from './helpers/normalize-ir.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -32,9 +32,7 @@ const sourceOf = (file: string): string => readFileSync(resolve(PROCESSES_DIR, `${file}.bpmnscript`), 'utf-8'); async function compile(file: string): Promise { - const document = await parse(sourceOf(file)); - expect(document.parseResult.parserErrors).toEqual([]); - return irToXml(astToIr(document.parseResult.value)); + return irToXml(astToIr(await parseToAst(sourceOf(file)))); } describe('every deployable example', () => { @@ -47,54 +45,46 @@ describe('every deployable example', () => { }); describe("every example round-trips through the tool's own output without a word", () => { - it.each(EXAMPLES)( - "%s round-trips through the tool's own output without a word", - async (file) => { - const document = await parse( - readFileSync(resolve(PROCESSES_DIR, file), 'utf-8'), - ); - expect(document.parseResult.parserErrors).toEqual([]); - const ir1 = astToIr(document.parseResult.value); - - const xml1 = await irToXml(ir1); - const { ir: ir2, warnings: importWarnings } = await xmlToIr(xml1); - const { source: dslPrime, warnings: printWarnings } = irToDsl(ir2); - const ir3 = astToIr(await parseToAst(dslPrime)); - const { diagnostics } = await validate(dslPrime); - - // Mapped rather than compared raw: a failure then names the id and the - // category or message, not a range and offset nobody reads. - expect({ - importWarnings: importWarnings.map((w) => ({ - elementId: w.elementId, - category: w.category, - })), - printWarnings: printWarnings.map((w) => ({ - elementId: w.elementId, - category: w.category, - })), - dslPrimeDiagnostics: diagnostics.map((d) => ({ - severity: d.severity, - message: d.message, - })), - }).toEqual({ - importWarnings: [], - printWarnings: [], - dslPrimeDiagnostics: [], - }); - - expect(normalizeIr(ir3)).toEqual(normalizeIr(ir1)); - }, - ); + it.each(EXAMPLES)('%s', async (file) => { + const ir1 = astToIr( + await parseToAst(readFileSync(resolve(PROCESSES_DIR, file), 'utf-8')), + ); + + const xml1 = await irToXml(ir1); + const { ir: ir2, warnings: importWarnings } = await xmlToIr(xml1); + const { source: dslPrime, warnings: printWarnings } = irToDsl(ir2); + const ir3 = astToIr(await parseToAst(dslPrime)); + const { diagnostics } = await validate(dslPrime); + + // Mapped rather than compared raw: a failure then names the id and the + // category or message, not a range and offset nobody reads. + expect({ + importWarnings: importWarnings.map((w) => ({ + elementId: w.elementId, + category: w.category, + })), + printWarnings: printWarnings.map((w) => ({ + elementId: w.elementId, + category: w.category, + })), + dslPrimeDiagnostics: diagnostics.map((d) => ({ + severity: d.severity, + message: d.message, + })), + }).toEqual({ + importWarnings: [], + printWarnings: [], + dslPrimeDiagnostics: [], + }); + + expect(normalizeIr(ir3)).toEqual(normalizeIr(ir1)); + }); }); describe('construct shapes pinned only by a deployable example', () => { it('awaiting-confirmation desugars the await into an intermediateCatchEvent carrying the message definition', async () => { // Desugaring decides what an `await` lowers to, so XML alone would miss it. - const document = await parse(sourceOf('awaiting-confirmation')); - expect(document.parseResult.parserErrors).toEqual([]); - - const ir = astToIr(document.parseResult.value); + const ir = astToIr(await parseToAst(sourceOf('awaiting-confirmation'))); const catchNode = ir.flowElements.find( (el) => el.kind === 'intermediateCatchEvent', ); diff --git a/tests/golden/README.md b/tests/golden/README.md index 2c79213..3392b23 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -256,8 +256,8 @@ Contract: the `bpmn:documentation` child on the process element and on each carr An order-entry narrative carrying four start events on one process, across two chains. `FromDesk` is the only plain start, so it is the process's default and the only one whose `form` is ever offered; `FromShop` (message) and `FromWarehouse` (signal) sit right after it with no step of their own, so all three enter `CheckOrder` directly. `FromPartner` (message) opens a second chain after the first chain's `end`, runs a service task of its own, and rejoins `CheckOrder` by `goto`. -Every start enters an authored step, because a start whose first step is a synthesized branch gateway prints back as a dropped-edge marker instead of the branch, so none of the four is written that way. -The restructured DSL keeps the first chain as authored and prints each later start after that chain's `end`, followed by its own steps and a `goto CheckOrder`, so `FromShop` and `FromWarehouse` come back as two one-line chains rather than as siblings of `FromDesk`; the graph is the same, which the idempotence block asserts. +Every start enters an authored step, so none of the four opens on a synthesized branch gateway. +The restructured DSL prints the file as authored: the three starts sharing `CheckOrder` print back to back above it, and `FromPartner`'s chain after `end Accepted` with its `goto`; the idempotence block asserts the graph. Contract: four `bpmn:startEvent` elements, none carrying a `bpmn:incoming`; the four `bpmn:incoming` children of `CheckOrder` in the order their flows are met in the source, the three starts ahead of `ImportPartnerOrder`; one `bpmn:Message` root per distinct name in first-appearance order (`OrderPlaced`, `PartnerOrderReceived`) and one `bpmn:Signal` root; `operaton:formData` on `FromDesk` and on no other start; an import of the frozen artifact that reports no warning at all; and every authored id. diff --git a/tests/golden/multiple-starts.bpmnscript b/tests/golden/multiple-starts.bpmnscript index 46119d8..c57b776 100644 --- a/tests/golden/multiple-starts.bpmnscript +++ b/tests/golden/multiple-starts.bpmnscript @@ -8,9 +8,10 @@ // - `FromPartner` (message) opens a second chain after the first chain's // `end` and rejoins the shared step by `goto`. // -// Every start enters an authored step: a start whose first step is a -// synthesized branch gateway prints back as a dropped-edge marker instead of -// the branch, so none of the four is written that way. +// Every start enters an authored step, so none of the four opens on a +// synthesized branch gateway, and the print is the file as authored: the three +// starts sharing `CheckOrder` back to back above it, `FromPartner`'s chain +// after `end Accepted` with its `goto`. process order-entry { start FromDesk { diff --git a/tests/round-trip-constructs.test.ts b/tests/round-trip-constructs.test.ts index 44f09ad..1c23a35 100644 --- a/tests/round-trip-constructs.test.ts +++ b/tests/round-trip-constructs.test.ts @@ -246,11 +246,11 @@ describe('bean-call condition stays quoted-raw end-to-end', () => { }); }); -// The fuzz found every row below by printing, rebuilding and printing again; -// the suites elsewhere in this repo stop after one hop each direction and -// never see a value that is stable on the first print but drifts on the -// second. `xml2`/`dsl2` come from feeding `dsl1` back through the same two -// hops, so a row pins idempotence, not just a single compile. +// Every row below prints, rebuilds and prints again; the suites elsewhere in +// this repo stop after one hop each direction and never see a value that is +// stable on the first print but drifts on the second. `xml2`/`dsl2` come from +// feeding `dsl1` back through the same two hops, so a row pins idempotence, +// not just a single compile. // // Two attribute values below carry a literal `"`, which this pipeline's XML // writer cannot place directly in an attribute and instead numeric-escapes @@ -395,6 +395,46 @@ const ENDING_BRANCH_BESIDE_ELSE_CHAIN_SRC = [ '', ].join('\n'); +const LOOP_INSIDE_BRANCH_SRC = [ + 'process claim-review {', + ' if (true) {', + ' do {', + ' await message Wait3("PaymentDone")', + ' } while (true)', + ' } else if (true) {', + ' throw message("Quote Received")', + ' } else {', + ' service Escalate(class: "com.example.Delegate")', + ' }', + ' end Done9', + '}', + '', +].join('\n'); + +const ENDING_CHAINS_IN_BRANCHES_SRC = [ + 'process p {', + ' var a: any', + ' var b: any', + ' if (a) {', + ' user A', + ' throw message("Quote Received")', + ' } else if (b) {', + ' user B', + ' await {', + ' message("OrderReceived") {', + ' end Done', + ' }', + ' message("Quote Received") {', + ' user C', + ' }', + ' }', + ' } else {', + ' user D', + ' }', + '}', + '', +].join('\n'); + const EXPRESSION_MAP_KEY_SRC = [ 'process p {', ' service A(class: "x") {', @@ -413,6 +453,52 @@ const BACKSLASH_IN_JUEL_STRING_SRC = [ '', ].join('\n'); +const HANDLER_JUMPING_TO_ITS_HOST_SRC = [ + 'process invoice_batch {', + ' emit link("Skip")', + ' await link("Skip")', + ' receive Hold14', + ' on Hold14: message("Cancelled") {', + ' goto Hold14', + ' }', + '}', + '', +].join('\n'); + +const HANDLER_JUMPING_TO_A_LINKED_STEP_SRC = [ + 'process invoice_batch {', + ' var order: any', + ' var items: any', + ' step Check6', + ' emit link("Retry")', + ' await link("Retry")', + ' call Call(process: "payment-run")', + ' on Check6: condition(order.name != items[0]) {', + ' goto Call', + ' }', + '}', + '', +].join('\n'); + +const TWO_JUMPS_INTO_ONE_STEP_SRC = [ + 'process p {', + ' var a: any', + ' var b: any', + ' if (a) {', + ' goto X', + ' }', + ' user B', + ' if (b) {', + ' goto X', + ' }', + ' user C', + ' end Done', + ' user X', + ' throw message("M")', + '}', + '', +].join('\n'); + describe('two-pass round trip: printed expression text stays engine-runnable and stable', () => { it.each([ [ @@ -550,9 +636,10 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ], ], [ - // The ending branch takes the split's post-dominator to the exit, so - // the else chain used to be read as the continuation and the middle - // branch walked on through the join to the implicit end. + // The ending branch puts the split's post-dominator at the exit, so + // the join is read off the routes: the middle branch and the else + // chain come back together at it, and neither walks on to the + // implicit end. 'an if chain with an ending branch beside an else chain keeps the else, and the implicit end stays unwritten', ENDING_BRANCH_BESIDE_ELSE_CHAIN_SRC, [ @@ -562,6 +649,74 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ' } else if (b) {\n user Log\n } else {\n service Approve10(class: "org.acme.Audit")\n }\n}\n', ], ], + [ + // The branch's entry is the loop body, which the block's join + // post-dominates, so the walk stays inside the branch and prints the + // loop where it was written. + 'a loop inside a branch prints inside it, not as a jump with the loop hoisted behind the end', + LOOP_INSIDE_BRANCH_SRC, + [ + '', + '', + ], + [ + ' if (true) {\n do {\n await message Wait3("PaymentDone")\n } while (true)\n } else if (true) {\n', + ], + ], + [ + // Neither chain reaches the join, and the block's tail is the implicit + // end; hoisted behind it as jump targets, the chains would push that + // end off its tail and print it under its reserved id. + 'two branches whose chains end print them inside the branches, and the implicit end stays unwritten', + ENDING_CHAINS_IN_BRANCHES_SRC, + [ + '', + '', + '', + ], + [ + ' if (a) {\n user A\n throw message("Quote Received")\n } else if (b) {\n user B\n await {\n', + ' } else {\n user D\n }\n}\n', + ], + ], + [ + // Both routes of the first split reach `X`, and the else route can + // also end at `Done` without passing it, so `X` is no merge of the + // block: taken as one, the else chain prints nested inside the branch + // and the next pass prints it hoisted. + 'two jumps from sibling branches into one step keep the step after the chain, not as the join', + TWO_JUMPS_INTO_ONE_STEP_SRC, + [ + '', + '', + ], + [ + ' if (a) {\n goto X\n }\n user B\n if (b) {\n goto X\n }\n user C\n end Done\n user X\n throw message("M")\n}\n', + ], + ], + [ + // The host is reached only through the link catch; a handler walked + // before that chain would pull the host into its own body. + 'a handler jumping back to its host keeps the host where the link catch reaches it', + HANDLER_JUMPING_TO_ITS_HOST_SRC, + [ + '', + ], + [ + ' await link("Skip")\n receive Hold14\n on Hold14: message("Cancelled") {\n goto Hold14\n }\n}\n', + ], + ], + [ + 'a handler jumping to a step the link catch reaches keeps the step, and the process end, outside the handler', + HANDLER_JUMPING_TO_A_LINKED_STEP_SRC, + [ + '', + '', + ], + [ + ' await link("Retry")\n call Call(process: "payment-run")\n on Check6: condition(order.name != items[0]) {\n goto Call\n }\n}\n', + ], + ], ] as const)('%s', async (_title, source, xmlContains, dslContains) => { const { xml1, dsl1, xml2, dsl2 } = await roundTripTwice(source); @@ -579,4 +734,61 @@ describe('two-pass round trip: printed expression text stays engine-runnable and expect(xml2).toBe(xml1); expect(dsl2).toBe(dsl1); }); + + // The pass that walks a node first owns it in the print, and the chains + // the container's entries reach are walked before the handler blocks. The + // owned chain then ends at the process end rather than the handler's, so + // the model changes where the print is stable; what it keeps is which + // steps reach which. + it("an entry chain jumping into a handler body owns the body's chain, and the handler degrades to a goto", async () => { + const source = [ + 'process p {', + ' var order: any', + ' step Host', + ' emit link("L")', + ' await link("L")', + ' step B', + ' goto A', + ' on Host: condition(order.paid) {', + ' step A', + ' }', + '}', + '', + ].join('\n'); + const { dsl1, dsl2, xml1, xml2 } = await roundTripTwice(source); + + expect(dsl1).toBe( + [ + 'process p {', + ' var order: any', + ' step Host', + ' emit link("L")', + ' await link("L")', + ' step B', + ' step A', + ' on Host: condition(order.paid) {', + ' goto A', + ' }', + '}', + '', + ].join('\n'), + ); + const { diagnostics } = await validate(dsl1); + expect(diagnostics.map((d) => d.message)).toEqual([]); + expect(dsl2).toBe(dsl1); + + const [ir1, ir2] = await Promise.all( + [xml1, xml2].map(async (xml) => (await xmlToIr(xml)).ir), + ); + const owned = (id: string): string => + id === 'EndEvent_Boundary_Host_condition' ? 'EndEvent_p' : id; + expect(ir2.flowElements.map((el) => el.id).sort()).toEqual( + ir1.flowElements.map((el) => owned(el.id)).sort(), + ); + expect(realNodeReachability(ir2)).toEqual( + realNodeReachability(ir1).map((pair) => + pair.split('->').map(owned).join('->'), + ), + ); + }); }); From da7c07c215472aa87d4b6fbc4745f766507fef14 Mon Sep 17 00:00:00 2001 From: Marlon Kranz Date: Sat, 26 Sep 2026 14:17:02 +0200 Subject: [PATCH 04/11] Add hover, outline, navigation, rename and context-aware completion to the editor --- .gitignore | 2 + CONTRIBUTING.md | 3 +- README.md | 3 +- docs/decisions/0008-structured-grammar.md | 1 + .../0011-in-editor-conversion-sidebar.md | 2 +- package-lock.json | 1531 +++++++++++++++-- packages/extension/.vscodeignore | 1 + packages/extension/README.md | 27 +- .../extension/language-configuration.json | 2 + packages/extension/media/icon.png | Bin 0 -> 2595 bytes packages/extension/package.json | 20 +- .../extension/src/extension/conversion.ts | 47 +- packages/extension/src/extension/main.ts | 10 +- packages/extension/test/conversion.test.ts | 213 ++- .../test/language-configuration.test.ts | 37 + packages/language/README.md | 46 +- packages/language/package.json | 4 +- .../language/src/bpmn-script-completion.ts | 682 +++++++- packages/language/src/bpmn-script-hover.ts | 76 + packages/language/src/bpmn-script-linker.ts | 23 +- packages/language/src/bpmn-script-module.ts | 22 + ...mn-script-parser-error-message-provider.ts | 14 + .../language/src/bpmn-script-references.ts | 166 ++ packages/language/src/bpmn-script-symbols.ts | 56 + .../language/src/bpmn-script-validator.ts | 94 +- packages/language/src/paren-items.ts | 5 +- .../language/src/variable-symbol-provider.ts | 145 +- packages/language/src/vocabulary.ts | 51 +- packages/language/test/completion.test.ts | 507 +++++- packages/language/test/hover.test.ts | 99 ++ packages/language/test/navigation.test.ts | 260 +++ packages/language/test/parsing.test.ts | 2 +- packages/language/test/scoping.test.ts | 37 +- .../language/test/semantic-tokens.test.ts | 5 + packages/language/test/symbols.test.ts | 99 ++ packages/language/test/validating.test.ts | 8 +- packages/language/test/variables.test.ts | 25 + .../language/test/vocabulary-sync.test.ts | 64 +- packages/language/textmate-postprocess.mjs | 66 + packages/transform/test/ir-to-dsl.test.ts | 2 +- 40 files changed, 3957 insertions(+), 500 deletions(-) create mode 100644 packages/extension/media/icon.png create mode 100644 packages/extension/test/language-configuration.test.ts create mode 100644 packages/language/src/bpmn-script-hover.ts create mode 100644 packages/language/src/bpmn-script-references.ts create mode 100644 packages/language/src/bpmn-script-symbols.ts create mode 100644 packages/language/test/hover.test.ts create mode 100644 packages/language/test/navigation.test.ts create mode 100644 packages/language/test/symbols.test.ts create mode 100644 packages/language/textmate-postprocess.mjs diff --git a/.gitignore b/.gitignore index 6a09bae..f7205ec 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,8 @@ Thumbs.db # VS Code extension packaging *.vsix +packages/extension/LICENSE +packages/extension/CHANGELOG.md # Environment .env diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9251d08..7f90d44 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,8 @@ SKIP_DOCKER_TESTS=true npm test ### Trying it out in VS Code -After building, press F5 in VS Code. This opens a second VS Code window with the BPMNscript extension loaded. In that window you can create or open `.bpmnscript` files and get syntax highlighting, inline diagnostics, and autocompletion. To compile or decompile a file, use the command palette (`BPMNscript: Compile to BPMN` / `BPMNscript: Decompile to BPMNscript`) or the "Convert" panel in the BPMNscript activity-bar sidebar. The CLI alternative (`npx bpmns build `) still works if you prefer the terminal. +After building, press F5 in VS Code. This opens a second VS Code window with the BPMNscript extension loaded. In that window you can create or open `.bpmnscript` files and get syntax highlighting, inline diagnostics, autocompletion, hover, and rename. To compile or decompile a file, use the command palette (`BPMNscript: Compile to BPMN` / `BPMNscript: Decompile to BPMNscript`) or the "Convert" panel in the BPMNscript activity-bar sidebar. The CLI alternative (`npx bpmns build `) still works if you prefer the terminal. +For a normal install instead of the development host, see [packages/extension/README.md](packages/extension/README.md#installing). ### Editing the grammar diff --git a/README.md b/README.md index 819b342..8050443 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,11 @@ Press F5 from the repo root. VS Code opens a second window with the extension loaded, where `.bpmnscript` files get: - syntax highlighting, including inside a `script` task's fenced body, which is highlighted in its own language -- autocompletion and hover, from the same grammar that drives the compiler +- completion, hover, go to definition, find all references and rename, for steps, codes and variables, plus an outline, from the same grammar that drives the compiler - errors and warnings inline as you type, so a `goto` that can't reach its target, a `string` variable under an ordered comparison, or a `boolean` in arithmetic is flagged before you ever run the compiler - a **Convert** panel in the sidebar: compile the open file, jump to its counterpart when one exists, or pick a `.bpmn` from disk to decompile +For a normal install instead of the development host, see the extension README's [Installing](packages/extension/README.md#installing) section. Compiling and decompiling are the same two operations as `bpmns build` and `bpmns parse` above, without leaving the editor. Both are in the command palette too, under "BPMNscript". See [packages/extension/README.md](packages/extension/README.md) for how the pieces fit together. diff --git a/docs/decisions/0008-structured-grammar.md b/docs/decisions/0008-structured-grammar.md index 2a7b17d..d4b852f 100644 --- a/docs/decisions/0008-structured-grammar.md +++ b/docs/decisions/0008-structured-grammar.md @@ -52,3 +52,4 @@ Neither the conditioned branch nor the multi-branch `await` spent a reserved wor ## More Information The JUEL-subset boundary, meaning what parses natively and what falls back to `"${...}"`, is fixed by the grammar's expression sub-rules in `packages/language/src/bpmn-script.langium` and mirrored by the hand-rolled parser in `packages/transform/src/juel.ts`. +The completion provider offers at a caret only what the validator accepts there, and `packages/language/test/completion.test.ts` holds that with a table that inserts every offered item and validates the result. diff --git a/docs/decisions/0011-in-editor-conversion-sidebar.md b/docs/decisions/0011-in-editor-conversion-sidebar.md index c680e6d..407d120 100644 --- a/docs/decisions/0011-in-editor-conversion-sidebar.md +++ b/docs/decisions/0011-in-editor-conversion-sidebar.md @@ -45,7 +45,7 @@ A webview gives direct control over that layout and leaves room to grow it, for The architecture is a two-layer split: a pure core (`conversion-core.ts`, no `vscode` import) and a thin VS Code adapter (`conversion.ts`). The core contains all interesting logic (severity gating, error classification, unsupported-element handling) and is unit-testable under vitest. -The adapter owns only VS Code I/O: URI resolution, file reads and writes, `DiagnosticCollection` updates, overwrite confirmation, and opening the result. +The adapter owns only VS Code I/O: URI resolution, file reads and writes, focusing the Problems panel, overwrite confirmation, and opening the result. The commands (`bpmnscript.compile`, `bpmnscript.decompile`, and `bpmnscript.openAndDecompile`, which picks a BPMN file and runs the decompile handler on it) are the single execution path. The sidebar and the command palette call the same commands with a URI argument, so behavior is identical across entry points. diff --git a/package-lock.json b/package-lock.json index 3ceb2c3..ac14baa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,185 @@ "typescript": "~6.0.0" } }, + "node_modules/@azure/abort-controller": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz", + "integrity": "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz", + "integrity": "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.1.tgz", + "integrity": "sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-process": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@azure/core-process/-/core-process-1.0.0.tgz", + "integrity": "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", + "integrity": "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.4.0.tgz", + "integrity": "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz", + "integrity": "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.3.tgz", + "integrity": "sha512-zGQPtqvXPgSA8yfV2CkIQ1qirqk0p9AIVpC5uEkdXQYcKl07QHvyaGYRnZOk0AsQUmxNb4wfkcwY5di8Z5xa9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-process": "^1.0.0", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^6.0.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.4.0.tgz", + "integrity": "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.22.0.tgz", + "integrity": "sha512-5kgu9xeEKgGc2JeidxAtU15NJTqiH/CMCRRQAJ4Rac56kB7KVg91vbNmn+z3RO1vNomPN69UvjKG9h1Pghx6dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.14.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.14.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.1.tgz", + "integrity": "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-6.0.1.tgz", + "integrity": "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.14.1", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@balena/dockerignore": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", @@ -930,6 +1109,238 @@ "debug": "^4.1.1" } }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -1184,9 +1595,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1204,9 +1612,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1224,9 +1629,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1244,9 +1646,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1264,9 +1663,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1284,9 +1680,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1354,6 +1747,76 @@ "dev": true, "license": "MIT" }, + "node_modules/@secretlint/core": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", + "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "structured-source": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/profiler": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", + "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-rule-no-dotenv": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", + "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/secretlint-rule-preset-recommend": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz", + "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/source-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz", + "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2", + "istextorbinary": "^9.5.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/types": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", + "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1623,6 +2086,21 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", + "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", @@ -2015,72 +2493,268 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vscode/vsce": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-4.0.0.tgz", + "integrity": "sha512-NImwuLaenMmb5D5Jer9/lzi/F9ZQUBOp8Azhj/BVYcTFgixv8KehFXqEUDjQlD2tAiw2E6dDGyjTuAB//di60A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.13.2", + "@napi-rs/keyring": "^1.3.0", + "@secretlint/core": "^10.2.2", + "@secretlint/secretlint-rule-no-dotenv": "^10.2.2", + "@secretlint/secretlint-rule-preset-recommend": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@vscode/vsce-sign": "^2.1.0", + "azure-devops-node-api": "^12.5.0", + "cockatiel": "^3.2.1", + "commander": "^12.1.0", + "hosted-git-info": "^4.1.0", + "jsonc-parser": "^3.3.1", + "marked": "^18.0.11", + "mime": "^1.6.0", + "minimatch": "^10.2.6", + "parse5": "^8.0.1", + "proper-lockfile": "^4.1.2", + "read": "^1.0.7", + "semver": "^7.8.5", + "tinyglobby": "^0.2.17", + "typed-rest-client": "^1.8.11", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^3.4.0", + "yazl": "^2.5.1" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 22" + } + }, + "node_modules/@vscode/vsce-sign": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.1.0.tgz", + "integrity": "sha512-9AQrqazrBgTgRSuwleLVXUrIUphY02/SFCh2TKYoLV/xifJAdblhdmEmw5gUrYSPQ3sRwNs9iyCMD14sATEE6g==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optionalDependencies": { + "@vscode/vsce-sign-alpine-arm64": "2.0.6", + "@vscode/vsce-sign-alpine-x64": "2.0.6", + "@vscode/vsce-sign-darwin-arm64": "2.0.6", + "@vscode/vsce-sign-darwin-x64": "2.0.6", + "@vscode/vsce-sign-linux-arm": "2.0.6", + "@vscode/vsce-sign-linux-arm64": "2.0.6", + "@vscode/vsce-sign-linux-x64": "2.0.6", + "@vscode/vsce-sign-win32-arm64": "2.0.6", + "@vscode/vsce-sign-win32-x64": "2.0.6" + } + }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", + "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", + "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", + "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", + "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", + "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", + "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", + "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", + "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", + "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "node_modules/@vscode/vsce/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=18" } }, "node_modules/abort-controller": { @@ -2119,6 +2793,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2421,6 +3105,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/azure-devops-node-api": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", + "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, "node_modules/b4a": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", @@ -2574,6 +3269,22 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/binaryextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", + "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -2626,6 +3337,13 @@ "node": ">= 6" } }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/bpmn-auto-layout": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/bpmn-auto-layout/-/bpmn-auto-layout-1.3.0.tgz", @@ -2716,6 +3434,13 @@ "node": ">=8.0.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/buildcheck": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", @@ -2726,6 +3451,22 @@ "node": ">=10.0.0" } }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/byline": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", @@ -2936,6 +3677,16 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3312,6 +4063,36 @@ "dev": true, "license": "MIT" }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -3330,6 +4111,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -3500,6 +4294,33 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/editions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", + "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "version-range": "^4.15.0" + }, + "engines": { + "ecmascript": ">= es5", + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -3517,6 +4338,19 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -4928,6 +5762,60 @@ "node": ">= 0.4" } }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5153,6 +6041,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5209,17 +6113,36 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" }, "engines": { - "node": ">=0.10.0" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-map": { @@ -5430,6 +6353,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -5444,6 +6383,24 @@ "dev": true, "license": "ISC" }, + "node_modules/istextorbinary": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", + "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "binaryextensions": "^6.11.0", + "editions": "^6.21.0", + "textextensions": "^6.11.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -5529,6 +6486,13 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonfile": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", @@ -5552,6 +6516,29 @@ "node": "*" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -5568,6 +6555,29 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5855,9 +6865,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5879,9 +6886,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5903,9 +6907,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5927,9 +6928,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6021,6 +7019,48 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6028,6 +7068,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -6065,6 +7112,19 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.13", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.13.tgz", + "integrity": "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6099,6 +7159,19 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/min-dash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/min-dash/-/min-dash-5.1.0.tgz", @@ -6195,6 +7268,13 @@ "dev": true, "license": "MIT" }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, "node_modules/nan": { "version": "2.27.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", @@ -6443,6 +7523,25 @@ "wrappy": "1" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6541,6 +7640,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6592,6 +7704,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6781,6 +7900,23 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6816,6 +7952,19 @@ "dev": true, "license": "MIT" }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -7060,6 +8209,19 @@ "@rolldown/binding-win32-x64-msvc": "1.2.6" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7177,6 +8339,16 @@ "dev": true, "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/saxen": { "version": "11.1.1", "resolved": "https://registry.npmjs.org/saxen/-/saxen-11.1.1.tgz", @@ -7187,9 +8359,9 @@ } }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7320,15 +8492,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -7751,6 +8923,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -7856,6 +9038,22 @@ "b4a": "^1.6.4" } }, + "node_modules/textextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", + "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -7984,6 +9182,16 @@ "dev": true, "license": "0BSD" }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -8082,6 +9290,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -8115,6 +9335,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, "node_modules/undici": { "version": "8.10.1", "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.1.tgz", @@ -8190,6 +9417,13 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8197,6 +9431,19 @@ "dev": true, "license": "MIT" }, + "node_modules/version-range": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", + "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", + "dev": true, + "license": "Artistic-2.0", + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/vite": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", @@ -8718,6 +9965,46 @@ "dev": true, "license": "ISC" }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -8728,6 +10015,13 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -8818,6 +10112,39 @@ "node": ">=8" } }, + "node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + }, + "node_modules/yazl/node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -8882,6 +10209,7 @@ "packages/extension": { "name": "vscode-bpmnscript", "version": "0.0.1", + "license": "Apache-2.0", "dependencies": { "@bpmn-script/language": "0.0.1", "@bpmn-script/transform": "0.0.1", @@ -8889,12 +10217,13 @@ }, "devDependencies": { "@types/vscode": "~1.134.0", + "@vscode/vsce": "^4.0.0", "concurrently": "^10.0.5", "esbuild": "~0.28.2", "vitest": "^4.1.11" }, "engines": { - "vscode": "^1.100.0" + "vscode": "^1.134.0" } }, "packages/language": { diff --git a/packages/extension/.vscodeignore b/packages/extension/.vscodeignore index e4feff1..0b8c790 100644 --- a/packages/extension/.vscodeignore +++ b/packages/extension/.vscodeignore @@ -2,6 +2,7 @@ .vscode-test/** src/** test/** +injection/** **/*.ts **/*.map **/*.tsbuildinfo diff --git a/packages/extension/README.md b/packages/extension/README.md index 9fd86a3..56a5dab 100644 --- a/packages/extension/README.md +++ b/packages/extension/README.md @@ -1,10 +1,10 @@ # vscode-bpmnscript The VS Code extension for BPMNscript. -It runs the Langium language server for highlighting, autocompletion, hover, and inline diagnostics, and it converts the open file between `.bpmnscript` and `.bpmn` from a sidebar panel or the command palette. +It runs the Langium language server for highlighting, completion, hover, go to definition, references, rename, the outline and inline diagnostics, and it converts the open file between `.bpmnscript` and `.bpmn` from a sidebar panel or the command palette. The language intelligence itself lives in `@bpmn-script/language`; this package loads it in VS Code. -That wiring is mostly Langium scaffold, which is the point of choosing Langium in the first place ([ADR-0002](../../docs/decisions/0002-use-langium-as-language-workbench.md)). +That wiring is mostly Langium scaffold, which is the point of choosing Langium in the first place ([ADR-0002](https://github.com/BPMNscript/bpmnscript/blob/main/docs/decisions/0002-use-langium-as-language-workbench.md)). The conversion layer on top is project-specific: three commands (`bpmnscript.compile`, `bpmnscript.decompile`, and `bpmnscript.openAndDecompile`, which picks a BPMN file from disk and decompiles it) and a sidebar webview that drives them for the active file. ## How it fits together @@ -16,12 +16,15 @@ Opening a `.bpmnscript` file starts the language server and connects to it; the The language server (`src/language/main.ts`) runs in a background process. It imports `createBpmnScriptServices` from `@bpmn-script/language` and answers the editor's LSP requests. +Hover shows a step's kind and label, a code's kind and message, a variable's type, and a `/** ... */` comment written before the statement. +Completion offers only what the validator accepts at that position, never a fixed keyword list. +Rename edits every site of a step, a code or a variable, or refuses when the caret isn't on one. The conversion layer splits along the VS Code boundary. `conversion-core.ts` is a pure, `vscode`-free module driving the `compileDslToBpmn` and `decompileBpmnToDsl` pipelines, and it holds the decisions: severity gating (warnings don't block a compile), error classification, unsupported-element handling. A successful decompile also returns `warnings`, what `xmlToIr` reported on the way in and `irToDsl` on the way out, in one list. Because nothing here touches `vscode`, it's testable under vitest with no editor host. -`conversion.ts` is the adapter around it: it resolves the source URI (an argument, otherwise the active editor), reads the text (preferring an unsaved in-memory document), calls the core, maps validation errors into the Problems panel through a `DiagnosticCollection`, asks before overwriting an existing output file, writes the result next to the source with the extension swapped, and opens it. +`conversion.ts` is the adapter around it: it resolves the source URI (an argument, otherwise the active editor), reads the text (preferring an unsaved in-memory document), calls the core, opens the Problems panel on a validation failure (the language server already publishes the same diagnostics), asks before overwriting an existing output file, writes the result next to the source with the extension swapped, and opens it. A BPMN construct the transform refuses surfaces as an error notification and no file is written; any `warnings` surface as one aggregated notification listing what the two hops reported. `sidebar-view-provider.ts` implements `WebviewViewProvider` for the "Convert" view in the "BPMNscript" activity-bar container. @@ -32,6 +35,7 @@ Conversions open their output, so moving between the two files is one click. ## Syntax highlighting The base TextMate grammar is generated from the `language` package's `.langium` grammar at build time and copied in, so it always tracks the real grammar. +`packages/language/textmate-postprocess.mjs` adjusts it right after generation, so a hyphenated name keeps its color instead of losing it at the first `-`, and so numbers and operators get a scope the generator leaves out. A second, hand-maintained TextMate injection grammar (`injection/bpmn-script.injection.tmLanguage.json`) colors the body of a `script` task the way a markdown fenced code block works: the opening language tag selects the embedded scope (`source.js`, `source.python`, `source.ruby`, `source.groovy`), and `feel` or any other accepted tag without an installed grammar falls back to a plain uncolored block. This is highlighting only, with no autocomplete, hover, or diagnostics inside the fence. @@ -46,11 +50,22 @@ npm run build --workspace packages/extension `esbuild` bundles both entry points into CommonJS under `out/`, which is how VS Code loads extensions. To try it live, press F5 in VS Code from the repo root: a second window opens with the extension loaded, where `.bpmnscript` and `.bpmn` files get language support and the sidebar panel. -See [CONTRIBUTING.md](../../CONTRIBUTING.md#trying-it-out-in-vs-code). +See [CONTRIBUTING.md](https://github.com/BPMNscript/bpmnscript/blob/main/CONTRIBUTING.md#trying-it-out-in-vs-code). Build order matters. The extension bundles `@bpmn-script/language` and `@bpmn-script/transform` from their compiled `out/` directories, so a source edit in either one is invisible until you rebuild it (or run `npm run build` from the repo root). +## Installing + +```bash +npm run build +npm run package -w packages/extension +code --install-extension packages/extension/vscode-bpmnscript-0.0.1.vsix +``` + +VS Code then shows the BPMNscript icon in the activity bar and offers `BPMNscript: Compile to BPMN` in the command palette when a `.bpmnscript` file is open. +F5 from the repo root stays the way to try a change to the extension itself without packaging it. + ## Source layout | Path | Purpose | @@ -64,8 +79,10 @@ The extension bundles `@bpmn-script/language` and `@bpmn-script/transform` from | `media/sidebar.css` | Webview styles built on `--vscode-*` theme variables | | `media/sidebar.js` | Webview script: renders state, posts convert and open messages to the extension host | | `media/sidebar-icon.svg` | Activity-bar icon for the "BPMNscript" view container | +| `media/icon.png` | Marketplace/Extensions-view icon, rasterized from `media/sidebar-icon.svg` | | `package.json` | Registers the language, commands, menus, sidebar, and activation events | -| `language-configuration.json` | Brackets, comments, and auto-closing pairs | +| `language-configuration.json` | Brackets, comments, auto-closing pairs, and the word pattern | | `esbuild.mjs` | Bundles both entry points; adds the `import.meta.url` CJS shim and copies the moddle asset | | `injection/bpmn-script.injection.tmLanguage.json` | Injection grammar for embedded-language highlighting inside a `script` body | | `syntaxes/` | TextMate grammars, both copied in at build time: the base grammar from `language` and the injection grammar from `injection/` | +| `LICENSE`, `CHANGELOG.md` | Copied from the repo root by `build:prepare`, git-ignored, packed into the vsix | diff --git a/packages/extension/language-configuration.json b/packages/extension/language-configuration.json index 7976a56..c66e306 100644 --- a/packages/extension/language-configuration.json +++ b/packages/extension/language-configuration.json @@ -5,6 +5,8 @@ // block comment markers "blockComment": ["/*", "*/"] }, + // the ID terminal in bpmn-script.langium, so a hyphenated name selects whole + "wordPattern": "[_a-zA-Z]\\w*(-\\w+)*", // symbols used as brackets "brackets": [ ["{", "}"], diff --git a/packages/extension/media/icon.png b/packages/extension/media/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..02a4ba0c3ad216ed280cea583c73bcebc4466794 GIT binary patch literal 2595 zcmcJR`8V6y8^@DKEwQ$U(#6_RRT2zUDwI*%NGxS+wKPNkZt)-;3)L7bSX_4=zf57+0IrrTA{BX~?_nhZ>zhBRjjq$iB57U5w zKp=S+XGbp}rhebj5a4tV`b-6aWVqW!N09jYs_ttj27zQIT^#Lw5~ypHNzUFOM+et0 zpF88p!SQIH3gc9p5Lwj}v-?>EOvD)@jIOjP;>YwFRMKH`X36J*&x2jAYhe#w{$`T= z6T{t)hR2@^i4x%h*Y*oDzrIXrxrM)7UpY#O9e5u*ihEy`bo`Zp0a-^W1_P$Zr$GiK zl0c!=pg}uuY8nJ2aR4L<1Iej?4jX_Fn1A)+7*b#FQ1;J8z6L515)uLQi91$ihvjfM z+^t(*s;jk(OLNK;AF3m)tP=OW##>bMnRb4dpO0N&<KC`&^tEUudx;B4=qqi@N8^YZe(eOcG{ zFRQ4S-uw22wD+}RaisV5KQN@(OmCtCW}ZTU*M?lC`K*+i;ZkD`(DkqzP>)ET7T-|il9jab^v)mK|w(v5Xf$^m9$h~bUIz!-^*k= za@n4Kerl06l^){V9kXsOF+QFjWzK~e8SlLtskrOX+S+=RK+r#g2@bv<78cgH<>loC zx3s@>Ni`Btz;^J1qEILe5ow)K=Utzo@+) zz!)%dbIIfjes>-|G~mLPq-@Ru^+*`y4Cw3WVV_oa2{4r&YO1P0u^g$BGc#4}h0Lrh zDJV2l?o`fM(*ABbF5jbJDEEPvU({82OZASb8yYR%-}`2I2~gecaE=;jAK9dXLft#- zkHumyT@s|TyL}A%l6SVg{9VWSH8o37^X+g~PtS8~+lh78TK-W~%2AWzp)AD~I5@70>MmP# zHZ~lKg_?<3EGJXtND@hG1-_Vnlp)vF)>h=Kr$>m6i;G*I=_}j$IUs=NabP59u+sV# zCAw#E5nJhT5yRZrKx7SR^X|CTHTpD`&qpOE_tw-v%$D8`=PWkN`TF`=Sy`2#G&=%< zeSLpxmL%uoWJ_!(r#LxD#+nEphdH>o$ZU>1e7StNKRH{enTsgr^7)<)q>wCyV;6FD zG>D2?5+$yC8}kyJ^hZHm7pT*mVtw3gI|9MvT?F8V-X*fkOyK9K!P(haK)%=wVLL9{ zn(RfR9<27FAxu6d*eo>OZ4TZ}w%wV_J7Rfbw!c!Vx}}BSljx~dsT=i9m{GIDWI|^Y+yessuy2`^mzPh_b@lY*H5gam1HX)X0^#xaUQt7Hvr)EVdf}C8*H&J4{Zhs|bLNbLgTwCvyM%IJ#EOu0X*s`BsNE#zR`CIXuTEH-;V z2$PWkY-aB(0T|59m=zHj$zn3+*48b}&9}dPy-6f0;r?9Mq*AHxD7BkTkUf`7G8vZs zcfF4x0O`#lZ-f5;aukM|k)ZbtG!7j~FC3hhusCs|uDd&+&Wk2kX$cvC!{JVjj)g(_ z6YHwH>;F|zQBl&DmX%e`b_}53+#ey{ya^~@8TG;U&2R2_{AwIwG%+b@H89R&aD8@Q z6JGrO3Lf7>n6=%b=rNA-i~!a)HR0oB>^#j+o|N?W07Qng5Ji{@x!#-yiy{%7_0vvO z)p1?8j>{np4RVrEU41?44ldH$+nb~5w)ycR0FIE55H75jBu+`01jt2MShC@_s+=FK zI&a2dG(UKt$NdsP^C7FFYxxhnPIYsWcef9&KN)R6--APE`+q%xJY`{FA^TMMaly%_ zPo6yM={XWC2N03RW8oorP8qLyx`O_mo73u!e(2K}+;dZ%Xudhsfv-QOJ-@W{Y&%Nl z@A;q-*AgnVS+i8mF!DXGQYaLH!C=-gUVKc9v$OMD%-NGR)eR`>G{BTO)^rAm9dMHI&Bvks>M+>w;;?!V|dB719W_&iPZY^?@b?bwHHrwCd-`V+Px|3j7axFTVD-@Ow zhj%@D7Sk0(AQ0Nr@7r-W9OPOlLXr`R_~iCk{3&ayq@)B(F?Y`|Eq#?<{t9IX`jHQK zFR=5*K_m7ba5Q=}H#c{A`6@5~SV { return answer === 'Overwrite'; } -function toVsDiagnostic(d: ConvDiagnostic): vscode.Diagnostic { - return new vscode.Diagnostic( - new vscode.Range(d.line, d.character, d.endLine, d.endCharacter), - d.message, - d.severity === 1 - ? vscode.DiagnosticSeverity.Error - : vscode.DiagnosticSeverity.Warning, - ); -} - export function compileCommand( - diagnostics: vscode.DiagnosticCollection, extensionVersion: string, ): (uri?: vscode.Uri) => Promise { return async (uri?: vscode.Uri): Promise => { @@ -62,13 +50,19 @@ export function compileCommand( } const sourceFileName = path.basename(sourceUri.fsPath); + + if (path.extname(sourceUri.fsPath).toLowerCase() !== '.bpmnscript') { + await vscode.window.showWarningMessage( + `BPMNscript: "${sourceFileName}" is not a .bpmnscript file; ` + + 'use "Decompile to BPMNscript" for it.', + ); + return undefined; + } + const text = await readText(sourceUri); const result = await compileDslToBpmn(text, extensionVersion); - // Cleared on every outcome; the validation branch repopulates. - diagnostics.delete(sourceUri); - if (result.ok) { const outputPath = swapExtension(sourceUri.fsPath, '.bpmn'); const outputUri = vscode.Uri.file(outputPath); @@ -96,7 +90,11 @@ export function compileCommand( return outputUri; } else if (result.kind === 'validation') { - diagnostics.set(sourceUri, result.diagnostics.map(toVsDiagnostic)); + // The language client already publishes these same diagnostics for + // every workspace .bpmnscript file; showing the source and focusing + // Problems is enough, a second collection would only duplicate them. + await vscode.window.showTextDocument(sourceUri); + await vscode.commands.executeCommand('workbench.action.problems.focus'); await vscode.window.showErrorMessage( `BPMNscript: "${sourceFileName}" has ${result.diagnostics.length} compilation error(s). See the Problems panel.`, ); @@ -110,9 +108,9 @@ export function compileCommand( }; } -export function decompileCommand( - diagnostics: vscode.DiagnosticCollection, -): (uri?: vscode.Uri) => Promise { +export function decompileCommand(): ( + uri?: vscode.Uri, +) => Promise { return async (uri?: vscode.Uri): Promise => { const sourceUri = resolveSourceUri(uri); if (!sourceUri) { @@ -123,12 +121,19 @@ export function decompileCommand( } const sourceFileName = path.basename(sourceUri.fsPath); + + if (path.extname(sourceUri.fsPath).toLowerCase() !== '.bpmn') { + await vscode.window.showWarningMessage( + `BPMNscript: "${sourceFileName}" is not a .bpmn file; ` + + 'use "Compile to BPMN" for it.', + ); + return undefined; + } + const text = await readText(sourceUri); const result = await decompileBpmnToDsl(text); - diagnostics.delete(sourceUri); - if (result.ok) { const outputPath = swapExtension(sourceUri.fsPath, '.bpmnscript'); const outputUri = vscode.Uri.file(outputPath); diff --git a/packages/extension/src/extension/main.ts b/packages/extension/src/extension/main.ts index b560d27..704b324 100644 --- a/packages/extension/src/extension/main.ts +++ b/packages/extension/src/extension/main.ts @@ -17,19 +17,15 @@ let client: LanguageClient; export async function activate( context: vscode.ExtensionContext, ): Promise { - const convDiagnostics = - vscode.languages.createDiagnosticCollection('bpmnscript'); - context.subscriptions.push(convDiagnostics); - const extensionVersion = String( (context.extension.packageJSON as { version?: string }).version ?? '0.0.1', ); - const decompile = decompileCommand(convDiagnostics); + const decompile = decompileCommand(); context.subscriptions.push( vscode.commands.registerCommand( 'bpmnscript.compile', - compileCommand(convDiagnostics, extensionVersion), + compileCommand(extensionVersion), ), vscode.commands.registerCommand('bpmnscript.decompile', decompile), vscode.commands.registerCommand( @@ -92,7 +88,7 @@ async function startLanguageClient( const client = new LanguageClient( 'bpmn-script', - 'BpmnScript', + 'BPMNscript', serverOptions, clientOptions, ); diff --git a/packages/extension/test/conversion.test.ts b/packages/extension/test/conversion.test.ts index d01d7aa..ce573d8 100644 --- a/packages/extension/test/conversion.test.ts +++ b/packages/extension/test/conversion.test.ts @@ -11,54 +11,38 @@ const mocks = vi.hoisted(() => ({ showErrorMessage: vi.fn(), showInformationMessage: vi.fn(), showTextDocument: vi.fn(), + executeCommand: vi.fn(), })); -vi.mock('vscode', () => { - class Range { - constructor( - public startLine: number, - public startCharacter: number, - public endLine: number, - public endCharacter: number, - ) {} - } - class Diagnostic { - constructor( - public range: Range, - public message: string, - public severity: number, - ) {} - } - return { - Range, - Diagnostic, - DiagnosticSeverity: { Error: 0, Warning: 1 }, - Uri: { - file: (fsPath: string) => ({ - fsPath, - toString: () => `file://${fsPath}`, - }), - }, - window: { - activeTextEditor: undefined, - showWarningMessage: mocks.showWarningMessage, - showErrorMessage: mocks.showErrorMessage, - showInformationMessage: mocks.showInformationMessage, - showTextDocument: mocks.showTextDocument, - showOpenDialog: vi.fn(), +vi.mock('vscode', () => ({ + Uri: { + file: (fsPath: string) => ({ + fsPath, + toString: () => `file://${fsPath}`, + }), + }, + window: { + activeTextEditor: undefined, + showWarningMessage: mocks.showWarningMessage, + showErrorMessage: mocks.showErrorMessage, + showInformationMessage: mocks.showInformationMessage, + showTextDocument: mocks.showTextDocument, + showOpenDialog: vi.fn(), + }, + commands: { + executeCommand: mocks.executeCommand, + }, + workspace: { + textDocuments: [], + fs: { + readFile: vi.fn().mockResolvedValue(new Uint8Array()), + writeFile: vi.fn().mockResolvedValue(undefined), + // Rejecting sends confirmOverwrite down its "nothing to overwrite" + // branch, so no row below hits the modal. + stat: vi.fn().mockRejectedValue(new Error('ENOENT')), }, - workspace: { - textDocuments: [], - fs: { - readFile: vi.fn().mockResolvedValue(new Uint8Array()), - writeFile: vi.fn().mockResolvedValue(undefined), - // Rejecting sends confirmOverwrite down its "nothing to overwrite" - // branch, so no row below hits the modal. - stat: vi.fn().mockRejectedValue(new Error('ENOENT')), - }, - }, - }; -}); + }, +})); vi.mock('../src/extension/conversion-core.js', () => ({ compileDslToBpmn: vi.fn(), @@ -81,22 +65,6 @@ import { decompileCommand, } from '../src/extension/conversion.js'; -// `set` is overloaded in the real API, so the spy is declared with the -// two-argument form the adapter uses and the recorded calls stay readable. -function fakeDiagnosticCollection() { - const set = - vi.fn<(uri: vscode.Uri, published: vscode.Diagnostic[]) => void>(); - const cleared = vi.fn<(uri: vscode.Uri) => void>(); - return { - collection: { - set, - delete: cleared, - } as unknown as vscode.DiagnosticCollection, - set, - cleared, - }; -} - function occurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } @@ -112,6 +80,18 @@ function notifications(): Record<'info' | 'warning' | 'error', string[]> { }; } +/** The fsPath of every document the run opened, in call order. */ +function shownDocuments(): string[] { + return mocks.showTextDocument.mock.calls.map( + (call) => (call[0] as vscode.Uri).fsPath, + ); +} + +/** The first argument of every editor command the run executed. */ +function executedCommands(): string[] { + return mocks.executeCommand.mock.calls.map((call) => String(call[0])); +} + // Neither message names its own element: they say "here" and leave the id to // the caller, so unrendered they arrive as one line repeated. const SAME_WORDING = @@ -124,22 +104,22 @@ type Expected = { error?: string[]; /** The uri the command returns, or undefined where it gives up. */ returns: string | undefined; - /** The diagnostics published per `set` call, `[]` where none is expected. */ - published?: vscode.Diagnostic[][]; + /** Every document opened via showTextDocument, in call order. */ + shown?: string[]; + /** Every editor command executed, in call order. */ + executed?: string[]; }; type Row = readonly [ title: string, command: 'compile' | 'decompile', - result: CompileResult | DecompileResult, + /** Basename of the source file the row hands the command. */ + sourceName: string, + // undefined means the guard refuses the file before the core ever runs. + result: CompileResult | DecompileResult | undefined, expected: Expected, ]; -const SOURCE_NAME = { - compile: 'example.bpmnscript', - decompile: 'example.bpmn', -} as const; - describe('conversion commands: what the author is shown', () => { beforeEach(() => { vi.clearAllMocks(); @@ -149,6 +129,7 @@ describe('conversion commands: what the author is shown', () => { [ 'a decompile that dropped something names the file once and the dropped item after it', 'decompile', + 'example.bpmn', { ok: true, output: 'process P { start S end E }', @@ -168,11 +149,13 @@ describe('conversion commands: what the author is shown', () => { "Task1: The 'formHandlerClass' setting on 'Task1' was not imported", ], returns: '/tmp/example.bpmnscript', + shown: ['/tmp/example.bpmnscript'], }, ], [ 'two same-worded warnings are told apart by the element each is about', 'decompile', + 'example.bpmn', { ok: true, output: 'process P { start S end E }', @@ -196,11 +179,13 @@ describe('conversion commands: what the author is shown', () => { `CheckStock: ${SAME_WORDING}; ReserveGoods: ${SAME_WORDING}`, ], returns: '/tmp/example.bpmnscript', + shown: ['/tmp/example.bpmnscript'], }, ], [ 'a refused construct is reported as an error naming the file once, and nothing is written', 'decompile', + 'example.bpmn', { ok: false, kind: 'unsupported', @@ -215,8 +200,9 @@ describe('conversion commands: what the author is shown', () => { }, ], [ - 'a validation failure reports the count and publishes the diagnostics to the Problems panel', + 'a validation failure focuses the Problems panel and reports the count', 'compile', + 'example.bpmnscript', { ok: false, kind: 'validation', @@ -237,52 +223,89 @@ describe('conversion commands: what the author is shown', () => { 'BPMNscript: "example.bpmnscript" has 1 compilation error(s). See the Problems panel.', ], returns: undefined, - published: [ - [ - new vscode.Diagnostic( - new vscode.Range(0, 0, 0, 1), - 'bad', - vscode.DiagnosticSeverity.Error, - ), - ], - ], + shown: ['/tmp/example.bpmnscript'], + executed: ['workbench.action.problems.focus'], }, ], [ 'an unexpected failure is reported as an error naming the file once', 'compile', + 'example.bpmnscript', { ok: false, kind: 'error', message: 'boom' }, { error: ['BPMNscript: Failed to compile "example.bpmnscript": boom'], returns: undefined, }, ], - ])('%s', async (_title, command, result, expected) => { - const diagnostics = fakeDiagnosticCollection(); + [ + 'compile refuses a file that is not a script', + 'compile', + 'example.bpmn', + undefined, + { + warning: [ + 'BPMNscript: "example.bpmn" is not a .bpmnscript file; ' + + 'use "Decompile to BPMNscript" for it.', + ], + returns: undefined, + }, + ], + [ + 'decompile refuses a file that is not BPMN', + 'decompile', + 'example.bpmnscript', + undefined, + { + warning: [ + 'BPMNscript: "example.bpmnscript" is not a .bpmn file; ' + + 'use "Compile to BPMN" for it.', + ], + returns: undefined, + }, + ], + [ + 'compile takes an upper-case extension', + 'compile', + 'ORDER.BPMNSCRIPT', + { ok: true, output: 'process P { start S end E }' }, + { + info: ['BPMNscript: Compiled "ORDER.BPMNSCRIPT" -> "ORDER.bpmn"'], + returns: '/tmp/ORDER.bpmn', + shown: ['/tmp/ORDER.bpmn'], + }, + ], + ])('%s', async (_title, command, sourceName, result, expected) => { let handler: (uri?: vscode.Uri) => Promise; if (command === 'compile') { - vi.mocked(compileDslToBpmn).mockResolvedValue(result as CompileResult); - handler = compileCommand(diagnostics.collection, '0.0.1'); + if (result) { + vi.mocked(compileDslToBpmn).mockResolvedValue(result as CompileResult); + } + handler = compileCommand('0.0.1'); } else { - vi.mocked(decompileBpmnToDsl).mockResolvedValue( - result as DecompileResult, - ); - handler = decompileCommand(diagnostics.collection); + if (result) { + vi.mocked(decompileBpmnToDsl).mockResolvedValue( + result as DecompileResult, + ); + } + handler = decompileCommand(); } - const sourceName = SOURCE_NAME[command]; const returned = await handler(vscode.Uri.file(`/tmp/${sourceName}`)); - const { info = [], warning = [], error = [] } = expected; + const { + info = [], + warning = [], + error = [], + shown = [], + executed = [], + } = expected; expect(returned?.fsPath).toBe(expected.returns); expect(notifications()).toEqual({ info, warning, error }); + expect(shownDocuments()).toEqual(shown); + expect(executedCommands()).toEqual(executed); - // Stale diagnostics go on every outcome; only the validation branch fills - // the panel again. - expect(diagnostics.cleared).toHaveBeenCalledTimes(1); - expect( - diagnostics.set.mock.calls.map(([, published]) => published), - ).toEqual(expected.published ?? []); + const core = command === 'compile' ? compileDslToBpmn : decompileBpmnToDsl; + expect(core).toHaveBeenCalledTimes(result === undefined ? 0 : 1); // The file is named once per line. The success line is the exception: it // names the file it read and the file it wrote. diff --git a/packages/extension/test/language-configuration.test.ts b/packages/extension/test/language-configuration.test.ts new file mode 100644 index 0000000..cc48433 --- /dev/null +++ b/packages/extension/test/language-configuration.test.ts @@ -0,0 +1,37 @@ +// language-configuration.json is JSONC (line comments), which JSON.parse +// rejects. Stripping whole comment lines, not a trailing `//`, keeps the +// "lineComment": "//" string value intact. + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { EmptyFileSystem, GrammarAST, GrammarUtils } from 'langium'; +import { createBpmnScriptServices } from '@bpmn-script/language'; + +const EXTENSION_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); + +function readWordPattern(): unknown { + const raw = readFileSync( + path.join(EXTENSION_DIR, 'language-configuration.json'), + 'utf8', + ); + return JSON.parse(raw.replace(/^\s*\/\/.*$/gm, '')).wordPattern; +} + +describe('language configuration', () => { + it("the editor's word pattern is the grammar's ID terminal", () => { + const { Grammar } = createBpmnScriptServices(EmptyFileSystem).BpmnScript; + const idRule = Grammar.rules.find( + (rule) => GrammarAST.isTerminalRule(rule) && rule.name === 'ID', + ) as GrammarAST.TerminalRule | undefined; + if (!idRule) { + throw new Error("grammar has no 'ID' terminal rule"); + } + + expect(readWordPattern()).toBe(GrammarUtils.terminalRegex(idRule).source); + }); +}); diff --git a/packages/language/README.md b/packages/language/README.md index 714b2e6..9ed31c5 100644 --- a/packages/language/README.md +++ b/packages/language/README.md @@ -4,13 +4,13 @@ The Langium package: grammar, parser, AST, validator, and language server for BP It's the reference for what counts as valid syntax, and the piece that turns source text into the tree the rest of the toolchain works on. The language's rules are written once, in `src/bpmn-script.langium`. -[Langium](https://langium.org/) reads that grammar and generates a parser, the AST types as TypeScript interfaces, and a language server that powers highlighting, autocompletion, and inline diagnostics. +[Langium](https://langium.org/) reads that grammar and generates a parser, the AST types as TypeScript interfaces, and a language server that powers highlighting, autocompletion, hover, navigation, rename, and inline diagnostics. None of the generated code is checked in; `npm run build` regenerates it. The hand-written part is the validator (`src/bpmn-script-validator.ts`), which covers the structural rules a parser can't express, such as "a service task needs exactly one binding attribute" or "a variable reference must match a declared `var` type". Langium already reports syntax errors and unresolved references, so the validator adds the BPMN-specific and type-system rules on top. -The package also owns the base VS Code TextMate grammar (`syntaxes/bpmn-script.tmLanguage.json`, generated by `langium generate` and git-ignored). +The package also owns the base VS Code TextMate grammar (`syntaxes/bpmn-script.tmLanguage.json`, generated by `langium generate`, adjusted by `textmate-postprocess.mjs`, and git-ignored). `packages/extension` layers a hand-maintained injection grammar over it for embedded-language highlighting inside a `script` body; see [`packages/extension/README.md`](../extension/README.md). ## The language @@ -905,22 +905,26 @@ npm test ## Source layout -| Path | Purpose | -| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `src/bpmn-script.langium` | Grammar definition | -| `src/vocabulary.ts` | The trigger words, listener events, and attribute keys each element owns | -| `src/bpmn-script-validator.ts` | AST-level validator; source of truth for the diagnostics | -| `src/bpmn-script-document-validator.ts` | Drops the link failure of a variable reference, since only a code position has a scope to resolve in | -| `src/paren-items.ts` | Readers over an element's parens: the payload, the keyed settings, and the flags | -| `src/bpmn-script-scope-provider.ts` | Resolves `goto` targets and a hosted handler's `host` within the enclosing container only | -| `src/bpmn-script-linker.ts` | Replaces an unresolved `goto` or `host` message with a boundary explanation | -| `src/bpmn-script-parser-error-message-provider.ts` | Guidance for a reserved word used as an identifier | -| `src/bpmn-script-completion.ts` | Snippet completions for the structural keywords and for the settings an element holds | -| `src/bpmn-script-semantic-tokens.ts` | Highlights the soft words (trigger, attribute key, parameter direction, listener event) | -| `src/bpmn-script-value-converter.ts` | Reads a raw template, `"${...}"` or `"#{...}"`, with the string escapes a literal gets | -| `src/bpmn-script-module.ts` | Langium dependency injection wiring | -| `src/expression-render.ts` | `renderExpression(astNode): string`, serializing a parsed expression AST to `${...}` text | -| `src/variable-symbol-provider.ts` | `VariableSymbolProvider`, collecting `var` declarations and the names a repetition brings into scope | -| `src/index.ts` | Package re-exports | -| `src/generated/` | Langium-generated files (git-ignored, rebuilt by `npm run build`) | -| `syntaxes/` | TextMate grammar (git-ignored, generated by `langium generate`) | +| Path | Purpose | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `src/bpmn-script.langium` | Grammar definition | +| `src/vocabulary.ts` | The trigger words, listener events, and attribute keys each element owns | +| `src/bpmn-script-validator.ts` | AST-level validator; source of truth for the diagnostics | +| `src/bpmn-script-document-validator.ts` | Drops the link failure of a variable reference, since only a code position has a scope to resolve in | +| `src/paren-items.ts` | Readers over an element's parens: the payload, the keyed settings, and the flags | +| `src/bpmn-script-scope-provider.ts` | Resolves `goto` targets and a hosted handler's `host` within the enclosing container only | +| `src/bpmn-script-linker.ts` | Replaces an unresolved `goto` or `host` message with a boundary explanation | +| `src/bpmn-script-parser-error-message-provider.ts` | Guidance for a reserved word used as an identifier | +| `src/bpmn-script-completion.ts` | Snippet completions for the structural keywords and for the settings an element holds | +| `src/bpmn-script-semantic-tokens.ts` | Highlights the soft words (trigger, attribute key, parameter direction, listener event) | +| `src/bpmn-script-hover.ts` | Hover content for a named statement, a code declaration and a variable, merged with Langium's own doc-comment hover | +| `src/bpmn-script-symbols.ts` | Outline symbol kinds per AST type, and the header text an `on` handler shows since it carries no `name` | +| `src/bpmn-script-value-converter.ts` | Reads a raw template, `"${...}"` or `"#{...}"`, with the string escapes a literal gets | +| `src/bpmn-script-module.ts` | Langium dependency injection wiring | +| `src/expression-render.ts` | `renderExpression(astNode): string`, serializing a parsed expression AST to `${...}` text | +| `src/variable-symbol-provider.ts` | `VariableSymbolProvider`, collecting the declaring sites (a header `var`, a form field, a catch binding, an `input`/`output` parameter, a repetition's element) that `bpmn-script-references.ts` reads | +| `src/bpmn-script-references.ts` | Definition, references, rename and highlight for a variable, resolved through those declaring sites since no single AST node stands for one | +| `src/index.ts` | Package re-exports | +| `src/generated/` | Langium-generated files (git-ignored, rebuilt by `npm run build`) | +| `textmate-postprocess.mjs` | Runs after `langium generate`: gives a hyphenated name its color back and adds a scope for numbers and operators | +| `syntaxes/` | TextMate grammar (git-ignored, generated by `langium generate` and adjusted by `textmate-postprocess.mjs`) | diff --git a/packages/language/package.json b/packages/language/package.json index 277fc0e..fd2d8be 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -28,9 +28,9 @@ }, "scripts": { "clean": "shx rm -fr *.tsbuildinfo out", - "build": "langium generate && tsc -p tsconfig.src.json", + "build": "npm run langium:generate && tsc -p tsconfig.src.json", "build:clean": "npm run clean && npm run build", - "langium:generate": "langium generate", + "langium:generate": "langium generate && node textmate-postprocess.mjs", "langium:watch": "langium generate --watch", "typecheck": "tsc -b tsconfig.test.json", "test": "vitest run" diff --git a/packages/language/src/bpmn-script-completion.ts b/packages/language/src/bpmn-script-completion.ts index 6947f84..603d7be 100644 --- a/packages/language/src/bpmn-script-completion.ts +++ b/packages/language/src/bpmn-script-completion.ts @@ -1,4 +1,15 @@ -import { AstUtils, GrammarAST, type AstNode, type MaybePromise } from 'langium'; +import { + AstUtils, + GrammarAST, + isCompositeCstNode, + isLeafCstNode, + type AstNode, + type AstNodeDescription, + type CstNode, + type MaybePromise, + type ReferenceInfo, + type Stream, +} from 'langium'; import { DefaultCompletionProvider, type CompletionAcceptor, @@ -9,10 +20,47 @@ import { CompletionItemKind, InsertTextFormat, } from 'vscode-languageserver-types'; -import { isOnHandler } from './generated/ast.js'; -import { isRepeated } from './variable-symbol-provider.js'; +import { + isBlock, + isCodeDecl, + isEmitStatement, + isFormField, + isListener, + isOnHandler, + isParenValue, + isProcess, + isSetting, + isStatement, + isSubProcess, + isThrowStatement, + isVarRef, + type Block, + type OnHandler, + type ParenItem, + type VarRef, +} from './generated/ast.js'; +import type { BpmnScriptServices } from './bpmn-script-module.js'; +import { + isActivityStatement, + isAttemptBlock, + isEscalationLegalHost, + isVariableUse, + NON_VARIABLE_ATTR_KEYS, +} from './bpmn-script-validator.js'; +import { + isStructuralParenKey, + payloadItemOf, + settingsOf, + timerParticleOf, + triggerWordOf, +} from './paren-items.js'; +import { + isRepeated, + type VariableSymbolProvider, +} from './variable-symbol-provider.js'; import { attributeBlockRuleOf, + BOOLEAN_ENGINE_KEYS, CALL_BINDING_VALUES, CATCH_TRIGGERS, DECLARED_CODE_TRIGGERS, @@ -20,15 +68,20 @@ import { EMIT_TRIGGERS, END_TRIGGERS, ENGINE_KEYS, + engineSpellings, ERROR_MAPPING_HEAD, ERROR_MAPPING_WHEN, - EVENT_BINDING_FIELDS, + eventBindingFieldsFor, EXECUTION_LISTENER_EVENTS, + EXTERNAL_BINDING_KEY, + FIELD_BINDING_KEYS, FIELD_DIRECTION, FORM_FIELD_SETTING_KEYS, + FORM_FIELD_TYPES, gatewayStatementRuleOf, JOIN_ENGINE_KEYS, joinSettingKey, + JUEL_LITERAL_WORDS, LISTENER_BINDING_KEYS, listenerEventsFor, namesACode, @@ -41,11 +94,13 @@ import { runSettingKey, SCRIPT_FORMAT_ALIASES, START_TRIGGERS, + THROW_BINDING_TRIGGER, THROW_TRIGGERS, TIMER_PARTICLE_BY_KIND, TRIGGER_PAYLOAD, TYPE_BINDING_KEY, TYPE_BINDING_VALUES, + type EngineKey, } from './vocabulary.js'; interface StructureForm { @@ -184,7 +239,7 @@ const STRUCTURE_SNIPPETS: Readonly< }; /** The value each engine setting scaffolds, under whichever spelling of the key. */ -const ENGINE_VALUE_SNIPPETS: Readonly> = { +const ENGINE_VALUE_SNIPPETS: Readonly> = { asyncBefore: '${1|true,false|}', asyncAfter: '${1|true,false|}', exclusive: '${1|false,true|}', @@ -195,7 +250,7 @@ const ENGINE_VALUE_SNIPPETS: Readonly> = { /** @param keys The engine keys to spell through `keyOf`; a carrier may take fewer than all. */ const engineSnippets = ( keyOf: (key: string) => string, - keys: readonly string[] = ENGINE_KEYS, + keys: readonly EngineKey[] = ENGINE_KEYS, ): Record => Object.fromEntries( keys.map((key) => [ @@ -232,7 +287,7 @@ const SETTING_SNIPPETS: Readonly> = { delegate: 'delegate: "${1:\\${beanName}}"', mapper: 'mapper: "${1:com.example.CallMapper}"', mapperDelegate: 'mapperDelegate: "${1:\\${callMapperBean}}"', - topic: 'topic: "${1:topic-name}"', + [EXTERNAL_BINDING_KEY]: `${EXTERNAL_BINDING_KEY}: "\${1:topic-name}"`, [TYPE_BINDING_KEY]: `${TYPE_BINDING_KEY}: "\${1|${TYPE_BINDING_VALUES.join(',')}|}"`, taskPriority: 'taskPriority: ${1:50}', decision: 'decision: "${1:decision-key}"', @@ -272,8 +327,36 @@ const CATCH_BINDING_SNIPPETS: Readonly> = { message: 'message: ${1:message}', }; -const settingForms = (keys: readonly string[]): StructureForm[] => - keys.map((key) => ({ label: key, insertText: SETTING_SNIPPETS[key] })); +/** + * A declaration's fields hold the text the event is made of, so both are + * quoted where a handler's binding of the same name is a bare variable. + */ +const DECLARATION_FIELD_SNIPPETS: Readonly> = { + code: 'code: "${1:code}"', + message: 'message: "${1:message}"', +}; + +const settingForms = ( + keys: readonly string[], + snippets: Readonly> = SETTING_SNIPPETS, +): StructureForm[] => + keys.map((key) => ({ label: key, insertText: snippets[key] })); + +/** The words a setting's value slot offers, keyed by the setting; a quoted choice inserts its quotes. */ +const SETTING_VALUE_CHOICES: Readonly> = { + [TYPE_BINDING_KEY]: TYPE_BINDING_VALUES.map((value) => `"${value}"`), + binding: CALL_BINDING_VALUES, + mapDecisionResult: DECISION_RESULT_MAPPINGS, +}; + +const BOOLEAN_SETTING_KEYS: ReadonlySet = new Set( + BOOLEAN_ENGINE_KEYS.flatMap(engineSpellings), +); + +const LITERAL_WORDS: ReadonlySet = new Set(JUEL_LITERAL_WORDS); + +/** The grammar rule of the two boolean words; `null` has one of its own. */ +const BOOLEAN_LITERAL_RULE = 'LiteralBool'; /** * The keys naming a timer's date and cycle. A duration is the bare payload the @@ -286,7 +369,7 @@ const TIMER_KEY_SNIPPETS: Readonly> = { /** The keys a timer trigger takes, wherever one is written. */ function timerKeyForms(node: AstNode): StructureForm[] { - if (!('trigger' in node) || node.trigger !== 'timer') return []; + if (triggerWordOf(node) !== 'timer') return []; return Object.entries(TIMER_KEY_SNIPPETS).map(([label, insertText]) => ({ label, insertText, @@ -300,10 +383,78 @@ function catchBindingForms(node: AstNode): StructureForm[] { TRIGGER_PAYLOAD[node.trigger]?.parens !== 'bindings' ) return []; - return EVENT_BINDING_FIELDS.map((field) => ({ - label: field, - insertText: CATCH_BINDING_SNIPPETS[field], - })); + return settingForms( + eventBindingFieldsFor(node.trigger), + CATCH_BINDING_SNIPPETS, + ); +} + +/** The items written in `owner`'s parens; a kind without parens has none. */ +const itemsOf = (owner: AstNode): ParenItem[] => + (owner as { items?: ParenItem[] }).items ?? []; + +/** + * Whether the unkeyed slot of `owner`'s parens has to be filled before a + * setting is legal there: a subscription name, a timer clause, a condition, + * and the code a throw or emit names, which is required even where a handler + * may catch every code. + */ +function payloadRequired(owner: AstNode): boolean { + const trigger = triggerWordOf(owner); + if (trigger === undefined) return false; + const rule = TRIGGER_PAYLOAD[trigger]; + return ( + rule !== undefined && + (rule.code === 'required' || + rule.timer || + rule.parens === 'condition' || + ((isThrowStatement(owner) || isEmitStatement(owner)) && + namesACode(trigger))) + ); +} + +/** + * Whether the unkeyed slot is written before the caret's token, bare or as a + * timer's keyed clause. A word still being typed there is that slot, not + * something after it. + */ +function payloadWritten(owner: AstNode, context: CompletionContext): boolean { + const items = itemsOf(owner); + const payload = payloadItemOf(items) ?? timerParticleOf(items)?.node; + return ( + payload?.$cstNode !== undefined && + payload.$cstNode.end <= context.tokenOffset + ); +} + +const payloadOpen = (owner: AstNode, context: CompletionContext): boolean => + payloadRequired(owner) && !payloadWritten(owner, context); + +/** + * Whether the caret opens the condition an event's parens take, the one item + * start an expression belongs at. + */ +function conditionSlotOpen(context: CompletionContext): boolean { + const owner = owningElement(context); + const trigger = owner === undefined ? undefined : triggerWordOf(owner); + return ( + owner !== undefined && + trigger !== undefined && + TRIGGER_PAYLOAD[trigger]?.parens === 'condition' && + !payloadWritten(owner, context) + ); +} + +/** + * The bare words `node`'s parens take. The one flag any rule lists is the + * handler's `alongside`, so the list is offered whole where the trigger's + * rule lets a handler run alongside its host. + */ +function flagWordsFor(node: AstNode): readonly string[] { + const trigger = triggerWordOf(node); + return trigger !== undefined && TRIGGER_PAYLOAD[trigger]?.alongside + ? (attributeBlockRuleOf(node)?.flags ?? []) + : []; } /** @@ -324,6 +475,12 @@ function settingFormsFor(node: AstNode): StructureForm[] | undefined { if (node.$type === 'FormField') { return settingForms(FORM_FIELD_SETTING_KEYS); } + if (isCodeDecl(node)) { + return settingForms( + eventBindingFieldsFor(node.kind), + DECLARATION_FIELD_SNIPPETS, + ); + } const gateway = gatewayStatementRuleOf(node); if (gateway) { return settingForms([ @@ -332,34 +489,170 @@ function settingFormsFor(node: AstNode): StructureForm[] | undefined { ]); } const rule = attributeBlockRuleOf(node); - return ( - rule && [ - ...timerKeyForms(node), - ...catchBindingForms(node), - ...settingForms([ - ...rule.own, - ...ENGINE_KEYS, - ...(rule.repeats && isRepeated(node) ? RUN_ENGINE_KEYS : []), - ]), - ] - ); + if (!rule) return undefined; + // A throw or emit owns the implementation bindings, which only a thrown + // message carries. + const own = + (isThrowStatement(node) || isEmitStatement(node)) && + node.trigger !== THROW_BINDING_TRIGGER + ? [] + : rule.own; + return [ + ...catchBindingForms(node), + ...settingForms([ + ...own, + ...ENGINE_KEYS, + ...(rule.repeats && isRepeated(node) ? RUN_ENGINE_KEYS : []), + ]), + ]; } +const writtenKeysOf = (owner: AstNode): ReadonlySet => + new Set(settingsOf(itemsOf(owner)).map((setting) => setting.key)); + /** - * The directions a member of `node`'s block is written with. A listener's block - * holds injected fields alone, for the reason + * Whether the parens name a binding the engine injects a field into. A + * listener's fenced script binds it in place of its settings and takes none. + */ +function bindsAField(owner: AstNode): boolean { + if (isListener(owner) && owner.script !== undefined) return false; + const keys = writtenKeysOf(owner); + return FIELD_BINDING_KEYS.some((key) => keys.has(key)); +} + +/** A property line and a mapping ride the topic binding alone; its worker reads them. */ +const bindsATopic = (owner: AstNode): boolean => + writtenKeysOf(owner).has(EXTERNAL_BINDING_KEY); + +const mappingOffered = (owner: AstNode): boolean => + (attributeBlockRuleOf(owner)?.externalExtras ?? false) && bindsATopic(owner); + +/** + * The directions a member of `owner`'s block is written with. A listener's + * block holds injected fields alone, for the reason * `BpmnScriptValidator.checkListenerFields` states, and a form field's holds - * properties alone, so neither has a row to read. + * properties alone, so neither has a row to read. A field and a property each + * ride one binding, so they are offered once that binding is written. */ -function parameterDirectionsOf(node: AstNode): readonly string[] { - if (node.$type === 'Listener') { - return [FIELD_DIRECTION]; +function parameterDirectionsOf(owner: AstNode): readonly string[] { + if (isListener(owner)) { + return bindsAField(owner) ? [FIELD_DIRECTION] : []; } - if (node.$type === 'FormField') { + if (isFormField(owner)) { return [PROPERTY_DIRECTION]; } - const rule = attributeBlockRuleOf(node); - return rule ? parameterDirectionsFor(rule) : []; + const rule = attributeBlockRuleOf(owner); + if (!rule) return []; + return parameterDirectionsFor(rule).filter((direction) => + direction === FIELD_DIRECTION + ? bindsAField(owner) + : direction === PROPERTY_DIRECTION + ? bindsATopic(owner) + : true, + ); +} + +/** + * Whether the caret sits in `owner`'s body rather than in its member block. + * The two open with the same brace and the parser reads the first as the body, + * so a member offered there would land in the body, where it belongs to + * nothing. + */ +const inBodyOf = (owner: AstNode, context: CompletionContext): boolean => + AstUtils.getContainerOfType(context.node, isBlock)?.$container === owner; + +/** + * Whether a bare name at the value slot of `key` on `owner` reads a variable. + * The validator's own predicate cannot be asked: with nothing typed after the + * colon the recovered tree holds the key as a bare value and no setting node. + * A text key takes its value as written, a boolean its two words, a form + * field's constraints a number, a timer clause a time, a catch binding a name + * it declares, and a declaration's fields text. + */ +function valueSlotReadsVariable(owner: AstNode, key: string): boolean { + return ( + !NON_VARIABLE_ATTR_KEYS.has(key) && + !BOOLEAN_SETTING_KEYS.has(key) && + !isFormField(owner) && + !isStructuralParenKey(owner, key) && + !isCodeDecl(owner) + ); +} + +/** The stand-in for a reference not yet typed, held by the caret's node as the default builds it. */ +const referenceStandInAt = (node: AstNode): VarRef => ({ + $type: 'VarRef', + $container: node as VarRef['$container'], + $containerProperty: 'ref', + ref: { $refText: '', ref: undefined }, + accessors: [], +}); + +/** + * The word written before the token at the caret. A mapping with nothing after + * its head word does not parse and leaves no node, so the head is read off the + * text. + */ +const wordBefore = (context: CompletionContext): string | undefined => + /(\w+)\s*$/.exec( + context.textDocument.getText().slice(0, context.tokenOffset), + )?.[1]; + +/** Whether both braces are written: a brace the recovery inserted leaves no leaf. */ +function closesWithBrace(cst: CstNode): boolean { + const last = isCompositeCstNode(cst) ? cst.content.at(-1) : undefined; + return last !== undefined && isLeafCstNode(last) && last.text === '}'; +} + +/** + * The blocks whose braces hold the caret, outermost first, read off the CST + * rather than off the caret's node: a handler with its trigger still unwritten + * does not parse, and the recovery closes the body before it and hangs the + * handler on the process, so its containers say nothing about where it was + * typed. A block the recovery closed stays open to a caret past its end. + */ +function openBlocksAt(context: CompletionContext): Block[] { + return AstUtils.streamAst(context.document.parseResult.value) + .filter(isBlock) + .filter((block) => { + const cst = block.$cstNode; + return ( + cst !== undefined && + cst.offset < context.offset && + (cst.end > context.offset || !closesWithBrace(cst)) + ); + }) + .toArray(); +} + +/** + * The words a handler at the caret catches. Host-less it is an event + * sub-process, and an undo block belongs directly in the subprocess whose work + * it undoes; hosted it is a boundary event, which Operaton attaches as a + * cancel to an attempt block alone and as an escalation to the hosts + * `isEscalationLegalHost` names. An unresolved host keeps the whole list. + */ +function handlerTriggerWords( + handler: OnHandler, + words: readonly string[], + context: CompletionContext, +): readonly string[] { + if (handler.host === undefined) { + const inSubProcess = isSubProcess(openBlocksAt(context).at(-1)?.$container); + return words.filter( + (word) => + TRIGGER_PAYLOAD[word]?.hostless && + (word !== 'compensation' || inSubProcess), + ); + } + const host = handler.host.ref; + if (host === undefined) return words; + return words.filter( + (word) => + TRIGGER_PAYLOAD[word]?.boundary && + (word !== 'cancel' || isAttemptBlock(host)) && + (word !== 'escalation' || isEscalationLegalHost(host)), + ); } /** @@ -374,8 +667,37 @@ function ruleNameOf(node: AstNode): string | undefined { /** A `MapKey` in a parameter value assigns `key` too, and takes the author's own keys. */ const SETTING_KEY_RULE = 'ParenKey'; -/** The rules whose keywords are a setting key or a flag, never a construct. */ -const SETTING_WORD_RULES = [SETTING_KEY_RULE, 'FlagWord']; +/** The rule of the bare words an element's parens take. */ +const FLAG_WORD_RULE = 'FlagWord'; + +/** Whether the caret opens a new item in an element's parens, where a key may start. */ +const atParenItemStart = (context: CompletionContext): boolean => + context.features.some( + (next) => ruleNameOf(next.feature) === SETTING_KEY_RULE, + ); + +/** + * The key whose value slot holds the caret, `key: |` or `key: pre|`, or + * `undefined` anywhere else. With nothing typed after the colon the parser + * recovers the key as a bare unkeyed value and drops the colon, so that shape + * is told by the colon still standing between the word and the caret. + */ +function settingKeyAt(context: CompletionContext): string | undefined { + const node = context.node; + const setting = AstUtils.getContainerOfType(node, isSetting); + if (setting !== undefined) return setting.key; + if ( + !isVarRef(node) || + !isParenValue(node.$container) || + node.$cstNode === undefined + ) { + return undefined; + } + const between = context.textDocument + .getText() + .slice(node.$cstNode.end, context.offset); + return between.trim() === ':' ? node.ref.$refText : undefined; +} /** * The element whose settings hold the caret. The node at the caret is that @@ -462,20 +784,44 @@ const STATEMENT_TRIGGERS: Readonly< }, }; +/** + * The header declarations opening with a soft word, which no keyword path + * offers. Each names its code; only an error carries a message. + */ +const CODE_DECLARATION_FORMS: readonly (StructureForm & { detail: string })[] = + [...DECLARED_CODE_TRIGGERS].map((kind) => ({ + label: kind, + detail: `declare an ${kind} code`, + insertText: + `${kind} \${1:NAME}` + + (TRIGGER_PAYLOAD[kind]?.message ? '(message: "${2:message}")' : ''), + })); + /** Captions replacing the default one, keyed as {@link STRUCTURE_SNIPPETS} is. */ const STRUCTURE_DETAILS: Readonly> = { call: 'call another process like a function', for: 'how often the preceding step runs', }; +const FLAG_DETAIL = 'BPMNscript flag'; +const FORM_TYPE_DETAIL = 'BPMNscript form type'; +const SETTING_VALUE_DETAIL = 'BPMNscript setting value'; + /** * Snippet completions for the structural keywords and for the soft words the * grammar leaves as plain identifiers; everything else keeps Langium's default. */ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { + private readonly variables: VariableSymbolProvider; + + constructor(services: BpmnScriptServices) { + super(services); + this.variables = services.references.VariableSymbolProvider; + } + /** * Offers items for the soft words at the `trigger`, `particle`, `key`, - * `direction`, and `event` positions, which lex as plain `ID`s. + * `direction`, `event` and `kind` positions, which lex as plain `ID`s. * `OnHandler.host` is absent because it is a real cross-reference, already * offered by the inherited `completionForCrossReference`. */ @@ -493,18 +839,50 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { ruleNameOf(next.feature) === SETTING_KEY_RULE && owner ) { - const forms = settingFormsFor(owner); + // Until a required payload is written only the payload is offered, and + // a timer's date and cycle keys are that payload. + const forms = payloadOpen(owner, context) + ? timerKeyForms(owner) + : settingFormsFor(owner); if (forms) { this.acceptSettingSnippets(context, acceptor, forms); return; } } + // A form field's type slot admits any word, so the grammar's own list of + // variable types is replaced by the form types the validator holds it to. + if ( + next.property === 'type' && + AstUtils.getContainerOfType(context.node, isFormField) + ) { + if (!GrammarAST.isKeyword(next.feature)) { + this.acceptWords(context, acceptor, FORM_FIELD_TYPES, FORM_TYPE_DETAIL); + } + return; + } + if (GrammarAST.isCrossReference(next.feature)) { + if (atParenItemStart(context)) { + // A second unkeyed value is refused, so after the payload no + // reference belongs at the item start. + if (owner && payloadWritten(owner, context)) return; + } else { + const key = settingKeyAt(context); + const choices = + key === undefined ? undefined : SETTING_VALUE_CHOICES[key]; + if (choices) { + this.acceptWords(context, acceptor, choices, SETTING_VALUE_DETAIL); + return; + } + } + } if ( next.property === 'direction' && !GrammarAST.isKeyword(next.feature) && owner ) { - const directions = parameterDirectionsOf(owner); + const directions = inBodyOf(owner, context) + ? [] + : parameterDirectionsOf(owner); if (directions.length > 0) { this.acceptParameterDirections(context, acceptor, directions); return; @@ -514,10 +892,23 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { this.acceptListenerEvents(context, acceptor, owner); return; } - // A mapping is offered where its row takes one; elsewhere the block + if (next.property === 'kind' && next.type === 'CodeDecl') { + for (const form of CODE_DECLARATION_FORMS) { + acceptor(context, { + label: form.label, + kind: CompletionItemKind.Snippet, + detail: form.detail, + insertText: form.insertText, + insertTextFormat: InsertTextFormat.Snippet, + sortText: '1', + }); + } + return; + } + // A mapping is offered where its binding takes one; elsewhere the block // position offers nothing for it, so a user task is never handed one. if (next.property === 'trigger' && next.type === 'ErrorMapping') { - if (owner && attributeBlockRuleOf(owner)?.externalExtras) { + if (owner && mappingOffered(owner)) { acceptor(context, { label: ERROR_MAPPING_HEAD, kind: CompletionItemKind.Snippet, @@ -532,18 +923,184 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { if (next.property === 'trigger' && nodeType) { const triggers = STATEMENT_TRIGGERS[nodeType]; if (triggers) { - this.acceptEventWords( - context, - acceptor, - triggers.words, - triggers.details, - ); + const words = isOnHandler(context.node) + ? handlerTriggerWords(context.node, triggers.words, context) + : triggers.words; + this.acceptEventWords(context, acceptor, words, triggers.details); return; } } return super.completionFor(context, next, acceptor); } + /** + * A host resolves against every named statement, so the validator can say + * why a start or a throw cannot carry a boundary event; the offer stops at + * the activities that can. The recovery of a trigger-less handler hangs it + * on the process, so the scope is read for a stand-in placed in the + * innermost block still open at the caret, where the handler was typed. + * With a body still open, `on Ins` parses as a complete `Listener`, not an + * `OnHandler`, so the guard keys on the property alone: only `OnHandler` + * has `host`. + */ + protected override getReferenceCandidates( + refInfo: ReferenceInfo, + context: CompletionContext, + ): Stream { + if (refInfo.property !== 'host') { + return super.getReferenceCandidates(refInfo, context); + } + const block = openBlocksAt(context).at(-1); + const placed: ReferenceInfo = { + ...refInfo, + container: { + $type: 'OnHandler', + $container: block ?? refInfo.container.$container, + }, + }; + return super + .getReferenceCandidates(placed, context) + .filter( + (candidate) => + isStatement(candidate.node) && isActivityStatement(candidate.node), + ); + } + + protected override completionForCrossReference( + context: CompletionContext, + next: NextFeature, + acceptor: CompletionAcceptor, + ): MaybePromise { + if (next.type === 'ErrorMapping' && next.property === 'code') { + // context.node is the caret's leaf, which after any preceding member + // is that member, not the block's owner; climb to the nearest node + // with an attribute-block rule (the element the mapping belongs to). + let owner = context.node; + while (owner !== undefined && attributeBlockRuleOf(owner) === undefined) { + owner = owner.$container; + } + if ( + owner !== undefined && + mappingOffered(owner) && + wordBefore(context) === ERROR_MAPPING_HEAD + ) { + this.acceptMappingCodes(context, acceptor); + } + return; + } + if ( + next.type === 'VarRef' && + next.property === 'ref' && + this.variablesOffered(context) + ) { + this.acceptVariables(context, acceptor); + return; + } + return super.completionForCrossReference(context, next, acceptor); + } + + /** + * The stand-in the default builds for the code slot carries no trigger word, + * and with nothing after the head word no parsed mapping holds it either, so + * the scope would read no code kind. The parser reaches the slot only after + * the head word, so the stand-in is given it. + */ + private acceptMappingCodes( + context: CompletionContext, + acceptor: CompletionAcceptor, + ): void { + const standIn: AstNode & { trigger: string } = { + $type: 'ErrorMapping', + $container: context.node, + $containerProperty: 'code', + trigger: ERROR_MAPPING_HEAD, + }; + const refInfo: ReferenceInfo = { + reference: { $refText: '', ref: undefined }, + container: standIn, + property: 'code', + }; + for (const candidate of this.getReferenceCandidates(refInfo, context)) { + acceptor( + context, + this.createReferenceCompletionItem(candidate, refInfo, context), + ); + } + } + + /** + * Whether a bare name at the caret reads a variable. In an element's parens + * only an open condition slot does; at a setting's value the key decides; + * elsewhere the validator's predicate answers on the stand-in for the + * reference, whose containers are the caret's. + */ + private variablesOffered(context: CompletionContext): boolean { + if (atParenItemStart(context)) return conditionSlotOpen(context); + const key = settingKeyAt(context); + if (key !== undefined) { + const owner = owningElement(context); + return owner !== undefined && valueSlotReadsVariable(owner, key); + } + return ( + context.node !== undefined && + isVariableUse(referenceStandInAt(context.node)) + ); + } + + /** + * Every variable the process knows, in the order the table seeds them. A + * declared name sorts ahead of a keyword, as a code or a host does. + */ + private acceptVariables( + context: CompletionContext, + acceptor: CompletionAcceptor, + ): void { + const process = AstUtils.getContainerOfType(context.node, isProcess); + if (process === undefined) return; + for (const { name, type } of this.variables.collect(process).values()) { + acceptor(context, { + label: name, + kind: CompletionItemKind.Variable, + detail: type, + sortText: '0', + }); + } + } + + /** A plain keyword item per word. */ + private acceptWords( + context: CompletionContext, + acceptor: CompletionAcceptor, + words: readonly string[], + detail: string, + ): void { + for (const word of words) { + acceptor(context, { + label: word, + kind: CompletionItemKind.Keyword, + detail, + sortText: '1', + }); + } + } + + /** + * Whether a literal word belongs at the caret. In an element's parens it is + * a payload, which an open condition slot alone takes and only as a boolean; + * at a setting's value it fits a boolean key alone; an expression position + * takes it as any operand. + */ + private literalWordOffered( + context: CompletionContext, + boolean: boolean, + ): boolean { + if (atParenItemStart(context)) { + return boolean && conditionSlotOpen(context); + } + const key = settingKeyAt(context); + return key === undefined || (boolean && BOOLEAN_SETTING_KEYS.has(key)); + } + /** A plain keyword item per word, or a snippet where the word carries a payload. */ private acceptEventWords( context: CompletionContext, @@ -671,10 +1228,37 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { }); return; } + // The grammar admits a form block on every element with a member block; + // the rule says which take one. + if (keyword.value === 'form') { + const owner = owningElement(context); + if (owner && attributeBlockRuleOf(owner)?.forms) { + void super.completionForKeyword(context, keyword, acceptor); + } + return; + } + const rule = ruleNameOf(keyword); // A keyword in a setting position names a key or a flag, never the start of // a construct. Which of them belongs to the element is answered from its own - // vocabulary above, so the grammar's raw alternatives are dropped here. - if (SETTING_WORD_RULES.includes(ruleNameOf(keyword) ?? '')) { + // vocabulary, so the grammar's raw alternatives are dropped here. + if (rule === FLAG_WORD_RULE) { + const owner = owningElement(context); + if ( + owner && + !payloadOpen(owner, context) && + flagWordsFor(owner).includes(keyword.value) + ) { + this.acceptWords(context, acceptor, [keyword.value], FLAG_DETAIL); + } + return; + } + if (rule === SETTING_KEY_RULE) { + return; + } + if ( + LITERAL_WORDS.has(keyword.value) && + !this.literalWordOffered(context, rule === BOOLEAN_LITERAL_RULE) + ) { return; } const snippet = STRUCTURE_SNIPPETS[keyword.value]; diff --git a/packages/language/src/bpmn-script-hover.ts b/packages/language/src/bpmn-script-hover.ts new file mode 100644 index 0000000..7ad3d73 --- /dev/null +++ b/packages/language/src/bpmn-script-hover.ts @@ -0,0 +1,76 @@ +/** + * Hover content for the three shapes a name can declare: a named statement, a + * declared error or escalation code, and a variable. + * + * Langium's hover resolves the caret through `References.findDeclarations` + * before this method ever runs: a `goto` target, a handler's host, a thrown + * or caught code, and every site of a variable all resolve to the declaration + * they reference, so the same line answers there too, with no case for any of + * them here. + */ + +import type { AstNode } from 'langium'; +import { MultilineCommentHoverProvider } from 'langium/lsp'; +import { + isCodeDecl, + isLiteralString, + type CodeDecl, + type ParenItem, +} from './generated/ast.js'; +import { + isNamedStatement, + type NamedStatement, +} from './bpmn-script-scope-provider.js'; +import { attributeBlockRuleOf } from './vocabulary.js'; +import { isVariableSymbolNode } from './bpmn-script-references.js'; +import { declaredCodeOf, settingsOf } from './paren-items.js'; + +/** The literal-string value of a written setting, `undefined` where absent or not quoted text. */ +function literalSetting(items: ParenItem[], key: string): string | undefined { + const setting = settingsOf(items).find((item) => item.key === key); + return setting && isLiteralString(setting.value) + ? setting.value.value + : undefined; +} + +function namedStatementHover(node: NamedStatement): string { + const description = attributeBlockRuleOf(node)?.description; + const label = literalSetting(node.items, 'label'); + const head = `${description} '${node.name}'`; + return label === undefined ? head : `${head}: ${label}`; +} + +function codeDeclHover(decl: CodeDecl): string { + const article = /^[aeiou]/i.test(decl.kind) ? 'an' : 'a'; + let line = `${article} ${decl.kind} '${decl.name}'`; + // `declaredCodeOf` falls back to the declaration's own name when no `code` + // setting is written; that fallback is the code the engine uses, not a + // value the source spells, so hover only quotes one that was actually + // written. + const hasCodeSetting = settingsOf(decl.items).some( + (item) => item.key === 'code', + ); + const code = hasCodeSetting ? declaredCodeOf(decl) : undefined; + if (code !== undefined) line += ` with code "${code}"`; + const message = literalSetting(decl.items, 'message'); + if (message !== undefined) line += `: ${message}`; + return line; +} + +function ownHoverContent(node: AstNode): string | undefined { + if (isNamedStatement(node)) return namedStatementHover(node); + if (isCodeDecl(node)) return codeDeclHover(node); + if (isVariableSymbolNode(node)) return `${node.name}: ${node.type}`; + return undefined; +} + +export class BpmnScriptHoverProvider extends MultilineCommentHoverProvider { + protected override async getAstNodeHoverContent( + node: AstNode, + ): Promise { + const own = ownHoverContent(node); + const doc = await super.getAstNodeHoverContent(node); + if (own !== undefined && doc !== undefined) return `${own}\n\n${doc}`; + return own ?? doc; + } +} diff --git a/packages/language/src/bpmn-script-linker.ts b/packages/language/src/bpmn-script-linker.ts index 78941ce..ceff13b 100644 --- a/packages/language/src/bpmn-script-linker.ts +++ b/packages/language/src/bpmn-script-linker.ts @@ -1,9 +1,11 @@ /** - * Boundary-explanation linker for `goto` and for an `on` handler's host: where - * the name exists elsewhere in the process, Langium's stock "Could not resolve - * reference" is replaced by a message naming the boundary crossed. A validator - * could not do the job: it only ever sees a `goto` that already resolved. An - * undeclared code is reworded here for the same reason. + * Boundary-explanation linker for `goto` and for an `on` handler's host: + * Langium's stock resolution failure, which names the grammar type + * (`Statement`) rather than anything the author wrote, is replaced by a + * message naming the boundary crossed where the name exists elsewhere in the + * process, or by a plain "no step named" message where it exists nowhere at + * all. A validator could not do the job: it only ever sees a `goto` that + * already resolved. An undeclared code is reworded here for the same reason. * * Rewording is all a linker can do. `doLink` stores the error on the reference * and lists the reference either way, so whether the error becomes a diagnostic @@ -145,10 +147,15 @@ export class BpmnScriptLinker extends DefaultLinker { const isGotoTarget = isGotoStatement(source) && refInfo.property === 'target'; if (isHost || isGotoTarget) { + const name = refInfo.reference.$refText; const process = AstUtils.getContainerOfType(source, isProcess); - const target = process - ? findNamedStatement(process, refInfo.reference.$refText) - : undefined; + const target = process ? findNamedStatement(process, name) : undefined; + if (process && !target) { + const message = isHost + ? `No step named '${name}' in this process to attach to.` + : `No step named '${name}' in this process.`; + return { info: refInfo, message }; + } const sourceContainer = enclosingFlowContainer(source); const located = target && sourceContainer diff --git a/packages/language/src/bpmn-script-module.ts b/packages/language/src/bpmn-script-module.ts index 581e555..d4f8e37 100644 --- a/packages/language/src/bpmn-script-module.ts +++ b/packages/language/src/bpmn-script-module.ts @@ -6,6 +6,7 @@ import { type LangiumServices, type LangiumSharedServices, type PartialLangiumServices, + type PartialLangiumSharedServices, } from 'langium/lsp'; import { BpmnScriptGeneratedModule, @@ -26,6 +27,12 @@ import { BpmnScriptLinker } from './bpmn-script-linker.js'; import { BpmnScriptParserErrorMessageProvider } from './bpmn-script-parser-error-message-provider.js'; import { BpmnScriptSemanticTokenProvider } from './bpmn-script-semantic-tokens.js'; import { BpmnScriptValueConverter } from './bpmn-script-value-converter.js'; +import { BpmnScriptHoverProvider } from './bpmn-script-hover.js'; +import { BpmnScriptReferences } from './bpmn-script-references.js'; +import { + BpmnScriptDocumentSymbolProvider, + BpmnScriptNodeKindProvider, +} from './bpmn-script-symbols.js'; /** {@link VariableSymbolProvider} sits in `references`: resolving identifiers is its concern. */ export type BpmnScriptAddedServices = { @@ -50,6 +57,7 @@ export const BpmnScriptModule: Module< }, references: { VariableSymbolProvider: () => new DefaultVariableSymbolProvider(), + References: (services) => new BpmnScriptReferences(services), ScopeProvider: (services) => new BpmnScriptScopeProvider(services), Linker: (services) => new BpmnScriptLinker(services), }, @@ -62,6 +70,19 @@ export const BpmnScriptModule: Module< new BpmnScriptCompletionProvider(services), SemanticTokenProvider: (services) => new BpmnScriptSemanticTokenProvider(services), + HoverProvider: (services) => new BpmnScriptHoverProvider(services), + DocumentSymbolProvider: (services) => + new BpmnScriptDocumentSymbolProvider(services), + }, +}; + +/** `NodeKindProvider` lives on `LangiumSharedServices`, not the language module, so overriding it takes a second module here. */ +export const BpmnScriptSharedModule: Module< + LangiumSharedServices, + PartialLangiumSharedServices +> = { + lsp: { + NodeKindProvider: () => new BpmnScriptNodeKindProvider(), }, }; @@ -72,6 +93,7 @@ export function createBpmnScriptServices(context: DefaultSharedModuleContext): { const shared = inject( createDefaultSharedModule(context), BpmnScriptGeneratedSharedModule, + BpmnScriptSharedModule, ); const BpmnScript = inject( createDefaultModule({ shared }), diff --git a/packages/language/src/bpmn-script-parser-error-message-provider.ts b/packages/language/src/bpmn-script-parser-error-message-provider.ts index aaeaf56..cb74320 100644 --- a/packages/language/src/bpmn-script-parser-error-message-provider.ts +++ b/packages/language/src/bpmn-script-parser-error-message-provider.ts @@ -37,6 +37,13 @@ const VAR_KEYWORD_TOKEN_NAME = 'var'; /** After a statement that takes no repeat clause, the same "expecting `}`". */ const FOR_KEYWORD_TOKEN_NAME = 'for'; +/** + * Chevrotain names the end-of-input token `EOF`; its image is the empty + * string, so the stock message reads "found ``" where an unclosed block ran + * out of source before its closing brace. + */ +const EOF_TOKEN_NAME = 'EOF'; + /** * A code declaration is the one header declaration opening with a plain `ID`, * so a mistyped statement keyword in the header region is parsed as one and @@ -85,6 +92,9 @@ export class BpmnScriptParserErrorMessageProvider extends LangiumParserErrorMess if (actual.tokenType.name === FOR_KEYWORD_TOKEN_NAME) { return this.repeatClausePlacementMessage(); } + if (actual.tokenType.name === EOF_TOKEN_NAME) { + return this.unclosedBlockMessage(); + } } if (expected.name === ID_TOKEN_NAME) { if (this.isReservedWord(actual.tokenType.name)) { @@ -117,6 +127,10 @@ export class BpmnScriptParserErrorMessageProvider extends LangiumParserErrorMess ); } + private unclosedBlockMessage(): string { + return "Expected '}' before the end of the file: a block is still open."; + } + private declarationOrStepMessage(word: string): string { return ( `'${word}' is neither a known declaration nor a step keyword. ` + diff --git a/packages/language/src/bpmn-script-references.ts b/packages/language/src/bpmn-script-references.ts new file mode 100644 index 0000000..748f09b --- /dev/null +++ b/packages/language/src/bpmn-script-references.ts @@ -0,0 +1,166 @@ +/** + * Definition, references, rename, document highlight and hover for a + * variable, in one service. + * + * `VarRef.ref` is a cross-reference to `CodeDecl` that the scope provider + * resolves in a code position alone, so Langium's providers, which key on the + * resolved reference and on a `name` node, know nothing of `if (amount > 1)` + * and rename `var amount` on its own. All five resolve the caret through + * `References.findDeclarations` and walk the sites through + * `References.findReferences`, so one override serves them all. + * + * The identity a rename keys on is the variable, not a node: up to five node + * kinds declare one, and a repeated statement declares its element beside its + * own name, so no node can stand for the variable it declares. Each request + * builds a fresh symbol node from `declaringSites` instead. That walk is three + * passes over the AST and answers in single-digit milliseconds on a 3,000-line + * file, so a document-highlight request per cursor move is affordable without + * a cache. + */ + +import { + AstUtils, + CstUtils, + DefaultReferences, + GrammarUtils, + stream, + type AstNode, + type CstNode, + type FindReferencesOptions, + type LangiumDocument, + type ReferenceDescription, + type Stream, +} from 'langium'; +import { + isProcess, + isVarRef, + type Process, + type VarType, +} from './generated/ast.js'; +import type { BpmnScriptServices } from './bpmn-script-module.js'; +import { isVariableUse } from './bpmn-script-validator.js'; +import type { + DeclaringSite, + VariableSymbolProvider, +} from './variable-symbol-provider.js'; + +/** The node a variable's sites all resolve to. Its `$cstNode` is the first site's name leaf. */ +export interface VariableSymbolNode extends AstNode { + readonly $type: 'VariableSymbolNode'; + readonly $container: Process; + readonly name: string; + readonly type: VarType; +} + +export function isVariableSymbolNode( + node: AstNode, +): node is VariableSymbolNode { + return node.$type === 'VariableSymbolNode'; +} + +function nameLeafOf(site: DeclaringSite): CstNode | undefined { + return GrammarUtils.findNodeForProperty(site.node.$cstNode, site.property); +} + +export class BpmnScriptReferences extends DefaultReferences { + private readonly symbols: VariableSymbolProvider; + + constructor(services: BpmnScriptServices) { + super(services); + this.symbols = services.references.VariableSymbolProvider; + } + + override findDeclarations(cst: CstNode): AstNode[] { + const symbol = this.variableAt(cst); + return symbol ? [symbol] : super.findDeclarations(cst); + } + + override findDeclarationNodes(cst: CstNode): CstNode[] { + const symbol = this.variableAt(cst); + if (symbol === undefined) return super.findDeclarationNodes(cst); + return this.sitesOf(symbol) + .map(nameLeafOf) + .filter((leaf) => leaf !== undefined); + } + + override findReferences( + node: AstNode, + options: FindReferencesOptions, + ): Stream { + if (!isVariableSymbolNode(node)) return super.findReferences(node, options); + const process = node.$container; + const document = AstUtils.getDocument(process); + const sites = this.sitesOf(node); + const leaves: CstNode[] = []; + if (options.includeDeclaration) { + leaves.push( + ...sites.map(nameLeafOf).filter((leaf) => leaf !== undefined), + ); + } + // A catch binding is a `VarRef` too, and it is listed above as a site. + const declaring = new Set(sites.map((site) => site.node)); + for (const ref of AstUtils.streamAst(process).filter(isVarRef)) { + if (declaring.has(ref)) continue; + // The `ref` leaf alone, so `order.total` renames `order` and keeps + // `.total`. + const leaf = ref.ref.$refNode; + if (leaf && ref.ref.$refText === node.name && isVariableUse(ref)) { + leaves.push(leaf); + } + } + const targetPath = this.nodeLocator.getAstNodePath(sites[0].node); + return stream(leaves).map((leaf) => + this.describe(document, leaf, targetPath), + ); + } + + /** + * The variable the leaf under the caret names: a declaring site's name, or a + * variable use whose name has at least one site. A name with no site (a loop + * counter, an undeclared name) falls through to the default, which finds no + * resolved reference and answers nothing, so it is not renameable. + */ + private variableAt(cst: CstNode): VariableSymbolNode | undefined { + const node = cst.astNode; + const feature = GrammarUtils.findAssignment(cst)?.feature; + const process = AstUtils.getContainerOfType(node, isProcess); + if (feature === undefined || process === undefined) return undefined; + const sites = this.symbols.declaringSites(process); + const site = sites.find((s) => s.node === node && s.property === feature); + const name = + site?.name ?? + (feature === 'ref' && isVarRef(node) && isVariableUse(node) + ? node.ref.$refText + : undefined); + const first = sites.find((s) => s.name === name); + if (name === undefined || first === undefined) return undefined; + return { + $type: 'VariableSymbolNode', + $container: process, + $cstNode: nameLeafOf(first), + name, + type: first.type, + }; + } + + private sitesOf(symbol: VariableSymbolNode): DeclaringSite[] { + return this.symbols + .declaringSites(symbol.$container) + .filter((site) => site.name === symbol.name); + } + + private describe( + document: LangiumDocument, + leaf: CstNode, + targetPath: string, + ): ReferenceDescription { + return { + sourceUri: document.uri, + sourcePath: this.nodeLocator.getAstNodePath(leaf.astNode), + targetUri: document.uri, + targetPath, + segment: CstUtils.toDocumentSegment(leaf), + local: true, + }; + } +} diff --git a/packages/language/src/bpmn-script-symbols.ts b/packages/language/src/bpmn-script-symbols.ts new file mode 100644 index 0000000..f48b2ec --- /dev/null +++ b/packages/language/src/bpmn-script-symbols.ts @@ -0,0 +1,56 @@ +/** Outline kinds; the default provider skips an `on` handler (no `name`) and flattens its steps into the process. */ + +import type { AstNode, AstNodeDescription, LangiumDocument } from 'langium'; +import { GrammarUtils } from 'langium'; +import { + DefaultDocumentSymbolProvider, + DefaultNodeKindProvider, +} from 'langium/lsp'; +import { SymbolKind, type DocumentSymbol } from 'vscode-languageserver-types'; +import { isOnHandler } from './generated/ast.js'; + +/** A dense table, hand-formatted rather than one property per Prettier's default. */ +// prettier-ignore +const SYMBOL_KIND_BY_TYPE: Readonly> = { + Process: SymbolKind.Module, SubProcess: SymbolKind.Namespace, + UserTask: SymbolKind.Function, ServiceTask: SymbolKind.Function, ScriptTask: SymbolKind.Function, + GenericTask: SymbolKind.Function, SendTask: SymbolKind.Function, ReceiveTask: SymbolKind.Function, + BusinessRuleTask: SymbolKind.Function, CallActivity: SymbolKind.Function, + StartEvent: SymbolKind.Event, EndEvent: SymbolKind.Event, ThrowStatement: SymbolKind.Event, + EmitStatement: SymbolKind.Event, IntermediateCatchEvent: SymbolKind.Event, OnHandler: SymbolKind.Event, + CodeDecl: SymbolKind.Constant, VarDecl: SymbolKind.Variable, +}; + +export class BpmnScriptNodeKindProvider extends DefaultNodeKindProvider { + override getSymbolKind(node: AstNode | AstNodeDescription): SymbolKind { + const type = 'type' in node ? node.type : node.$type; + return SYMBOL_KIND_BY_TYPE[type] ?? super.getSymbolKind(node); + } +} + +/** Named by its own header (`alongside` included: it tells two handlers on one host apart). */ +export class BpmnScriptDocumentSymbolProvider extends DefaultDocumentSymbolProvider { + protected override getSymbol( + document: LangiumDocument, + astNode: AstNode, + ): DocumentSymbol[] { + if (!isOnHandler(astNode) || !astNode.$cstNode) { + return super.getSymbol(document, astNode); + } + const text = astNode.$cstNode.text; + const headerEnd = text.indexOf('{'); + const name = (headerEnd === -1 ? text : text.slice(0, headerEnd)).trim(); + const selection = + GrammarUtils.findNodeForProperty(astNode.$cstNode, 'trigger') ?? + astNode.$cstNode; + return [ + { + kind: this.nodeKindProvider.getSymbolKind(astNode), + name, + range: astNode.$cstNode.range, + selectionRange: selection.range, + children: this.getChildSymbols(document, astNode), + }, + ]; + } +} diff --git a/packages/language/src/bpmn-script-validator.ts b/packages/language/src/bpmn-script-validator.ts index 2b4e3d6..9e6c073 100644 --- a/packages/language/src/bpmn-script-validator.ts +++ b/packages/language/src/bpmn-script-validator.ts @@ -128,6 +128,7 @@ import { ASYNC_FLAG_KEYS, ATTRIBUTE_BLOCK_RULES, attributeBlockRuleOf, + BOOLEAN_ENGINE_KEYS, BUILTIN_FIELD_NAMES, BUILTIN_FIELD_VALIDATOR, BUILTIN_REQUIRED_FIELDS, @@ -141,11 +142,13 @@ import { EMIT_TRIGGERS, END_TRIGGERS, ENGINE_KEYS, + engineSpellings, ERROR_MAPPING_HEAD, ERROR_MAPPING_WHEN, EVENT_BINDING_FIELDS, EXECUTION_LISTENER_EVENTS, EXPRESSION_OPEN, + EXTERNAL_BINDING_KEY, EXTERNAL_TASK_EL_NAME, FIELD_BINDING_KEYS, FIELD_DIRECTION, @@ -182,6 +185,7 @@ import { TASK_LISTENER_EVENTS, TASK_PRIORITY_KEY, THROW_BINDING_KEYS, + THROW_BINDING_TRIGGER, THROW_TRIGGERS, TIMER_JOB_KEYS, TIMER_PARTICLES, @@ -252,13 +256,6 @@ type GatewayStatement = | ParallelStatement | RaceStatement; -/** An engine key under each spelling a parens carries it, the value shape being the same under all three. */ -const engineSpellings = (key: string): string[] => [ - key, - joinSettingKey(key), - runSettingKey(key), -]; - /** * Keys whose value names something outside process-variable scope, so a * bareword there must not warn about an undeclared variable, nor be scanned @@ -274,7 +271,7 @@ const engineSpellings = (key: string): string[] => [ * broken; {@link BpmnScriptValidator.checkAttributeValues} asks for a quoted * date instead. */ -const NON_VARIABLE_ATTR_KEYS: ReadonlySet = new Set([ +export const NON_VARIABLE_ATTR_KEYS: ReadonlySet = new Set([ 'label', 'documentation', 'class', @@ -282,7 +279,7 @@ const NON_VARIABLE_ATTR_KEYS: ReadonlySet = new Set([ 'formRef', 'expression', 'delegate', - 'topic', + EXTERNAL_BINDING_KEY, TYPE_BINDING_KEY, 'process', 'binding', @@ -305,7 +302,7 @@ const NON_VARIABLE_ATTR_KEYS: ReadonlySet = new Set([ ]); const BOOLEAN_ATTR_KEYS: ReadonlySet = new Set( - ['asyncBefore', 'asyncAfter', 'exclusive'].flatMap(engineSpellings), + BOOLEAN_ENGINE_KEYS.flatMap(engineSpellings), ); /** Each timer job key is dead on an element with no job, so written alone it draws a warning. */ @@ -1718,7 +1715,7 @@ function previousFlowStatement( * refused under a message of its own (a gateway head, a form field, a code * declaration). */ -function settingsOnlyOwnerDescription( +export function settingsOnlyOwnerDescription( owner: ParenValue['$container'], ): string | undefined { if (isProcess(owner)) return 'a process header'; @@ -2346,39 +2343,20 @@ export class BpmnScriptValidator { return; } - // Only the direct value position is exempt; a nested VarRef is checked. const container = expr.$container; - const isNonVariableAttrValue = - isSetting(container) && NON_VARIABLE_ATTR_KEYS.has(container.key); - // A code position is exempt for the same reason `NON_VARIABLE_ATTR_KEYS` - // is: the word names something other than a variable there, and an - // undeclared code already has a diagnostic of its own from the linker. - // A declaration's parens hold the text a code and its message are made of, - // so a bare word there is a missing pair of quotes or an item that does not - // belong, both of which {@link BpmnScriptValidator.checkCodeDecls} reports. - const isDeclarationItem = isCodeDecl(container.$container); - // A gateway head's parens take settings alone, so a bare word there is - // already an error of its own; a variable warning on top is a red herring. - const isGatewayHeadValue = - isParenValue(container) && - gatewayStatementRuleOf(container.$container) !== undefined; if (isVarRef(expr)) { // A name position is exempt from the warning for the same reason a code // position is: the word names something other than a variable there, and // the message below is the one the author needs. const nameTrigger = nameTriggerOf(expr); + const variableUse = isVariableUse(expr); if (nameTrigger !== undefined) { accept( 'error', barewordNameMessage(nameTrigger, renderExpressionInner(expr)), { node: expr, property: 'ref' }, ); - } else if ( - !isNonVariableAttrValue && - !isDeclarationItem && - !isGatewayHeadValue && - !isCodePosition(expr) - ) { + } else if (variableUse || readsExternalTask(expr)) { // A bare collection name is written as it is and looked up as a // variable (`operaton:collection`), so JUEL never scans it; a bare // `in`/`out` mapping source is exempt the same way: `lowerCallMapping` @@ -2393,7 +2371,7 @@ export class BpmnScriptValidator { if (!isBareLookupName) { checkRenderedNames(expr, accept); } - if (!readsExternalTask(expr) && !symbols.has(expr.ref.$refText)) { + if (variableUse && !symbols.has(expr.ref.$refText)) { accept( 'warning', `Variable '${expr.ref.$refText}' is not declared. Add 'var ${expr.ref.$refText}: ' to the process.`, @@ -2815,7 +2793,7 @@ export class BpmnScriptValidator { return; } const unread = settings.find( - (a) => a.key === 'topic' || a.key === TYPE_BINDING_KEY, + (a) => a.key === EXTERNAL_BINDING_KEY || a.key === TYPE_BINDING_KEY, ); if (unread !== undefined) { accept('warning', resultVariableUnreadMessage(unread.key), target); @@ -4535,7 +4513,10 @@ export class BpmnScriptValidator { accept: ValidationAcceptor, ): void { for (const binding of caughtBindingsOf(handler.items)) { - if (binding.field === 'message' && handler.trigger === 'escalation') { + if ( + binding.field === 'message' && + !TRIGGER_PAYLOAD[handler.trigger]!.message + ) { accept('error', ESCALATION_NO_MESSAGE_MESSAGE, { node: binding.node, property: 'key', @@ -4925,7 +4906,7 @@ export class BpmnScriptValidator { for (const setting of settings) { if (!EVENT_BINDING_FIELD_SET.has(setting.key)) continue; - if (setting.key === 'message' && decl.kind === 'escalation') { + if (setting.key === 'message' && !TRIGGER_PAYLOAD[decl.kind]!.message) { accept('error', ESCALATION_NO_MESSAGE_MESSAGE, { node: setting, property: 'key', @@ -5182,7 +5163,7 @@ function catchTriggerMessage(word: string): string { * taking the events away leaves the activities. An intermediate catch event * is no activity either: `BpmnParse.parseBoundaryEvents` attaches only to one. */ -function isActivityStatement(stmt: Statement): boolean { +export function isActivityStatement(stmt: Statement): boolean { return ( isNamedStatement(stmt) && !isStartEvent(stmt) && @@ -5198,12 +5179,12 @@ function isActivityStatement(stmt: Statement): boolean { * activity, or a user task (`BpmnParse.parseBoundaryEvents`); both the * `subprocess` and the `attempt` head are subprocess scopes. */ -function isEscalationLegalHost(stmt: Statement): boolean { +export function isEscalationLegalHost(stmt: Statement): boolean { return isSubProcess(stmt) || isCallActivity(stmt) || isUserTask(stmt); } /** A block written with the `attempt` head: the only one a cancel may give up. */ -function isAttemptBlock(node: AstNode | undefined): node is SubProcess { +export function isAttemptBlock(node: AstNode | undefined): node is SubProcess { return node !== undefined && isSubProcess(node) && node.transactional; } @@ -5435,11 +5416,40 @@ const isFieldParameter = (param: IoParameter): boolean => /** Admitted inside a mapping alone, for the reason {@link EXTERNAL_TASK_EL_NAME} gives. */ function readsExternalTask(ref: VarRef): boolean { return ( - ref.ref.$refText === EXTERNAL_TASK_EL_NAME && + ref.ref?.$refText === EXTERNAL_TASK_EL_NAME && AstUtils.getContainerOfType(ref, isErrorMapping) !== undefined ); } +/** + * Whether `ref` reads a process variable, which is what the undeclared-variable + * warning, a definition lookup and a rename all key on. + * Every other position names something else: an `out` mapping's source is + * evaluated in the called process's scope; the value of a + * {@link NON_VARIABLE_ATTR_KEYS} setting is plain text (the direct value + * alone, a nested reference is a variable); a declaration's parens hold the + * text a code and its message are made of; a gateway head's parens take + * settings alone; a code or a name position names an event; and + * `externalTask` inside a mapping is the engine's own object. The answer is + * read off the container chain alone, so it also holds for the stand-in + * completion builds for a reference not yet typed, whose `ref` is empty. + */ +export function isVariableUse(ref: VarRef): boolean { + const container = ref.$container; + return ( + AstUtils.getContainerOfType(ref, isVariableMapping)?.direction !== 'out' && + !(isSetting(container) && NON_VARIABLE_ATTR_KEYS.has(container.key)) && + !isCodeDecl(container?.$container) && + !( + isParenValue(container) && + gatewayStatementRuleOf(container.$container) !== undefined + ) && + !isCodePosition(ref) && + nameTriggerOf(ref) === undefined && + !readsExternalTask(ref) + ); +} + /** An integer, bare or quoted, or a value lowering to `${...}`: a bare name or a raw template. */ function isPriorityValue(value: Expr): boolean { return ( @@ -5519,7 +5529,7 @@ function topicRefusalOf( settings: readonly Setting[], item: string, ): string | undefined { - return settings.some((attr) => attr.key === 'topic') + return settings.some((attr) => attr.key === EXTERNAL_BINDING_KEY) ? undefined : topicBindingMessage( capitalize(rule.description), @@ -5586,7 +5596,7 @@ function checkThrowEmitBinding( const written = bindingKeysOf(settingsOf(stmt.items), THROW_BINDING_KEYS); if (written.length === 0) return; - if (stmt.trigger !== 'message') { + if (stmt.trigger !== THROW_BINDING_TRIGGER) { for (const attr of settingsOf(stmt.items)) { if (!written.includes(attr.key)) continue; accept( diff --git a/packages/language/src/paren-items.ts b/packages/language/src/paren-items.ts index d06e57c..c44e1f0 100644 --- a/packages/language/src/paren-items.ts +++ b/packages/language/src/paren-items.ts @@ -85,7 +85,7 @@ export function caughtBindingsOf(items: ParenItem[]): CaughtBinding[] { * message, and there {@link caughtBindingsOf} has already read it; only a timer * names a date or a cycle. Anywhere else those words are ordinary unknown keys. */ -function isStructuralParenKey(owner: AstNode, key: string): boolean { +export function isStructuralParenKey(owner: AstNode, key: string): boolean { if (isOnHandler(owner) && EVENT_BINDING_FIELD_SET.has(key)) return true; return 'trigger' in owner && TIMER_PARTICLE_KEYS.has(key); } @@ -134,7 +134,8 @@ function payloadTriggerOf(node: AstNode): string | undefined { : undefined; } -function triggerWordOf(owner: AstNode): string | undefined { +/** The soft trigger word of an event statement, or `undefined` on any other node. */ +export function triggerWordOf(owner: AstNode): string | undefined { return 'trigger' in owner && typeof owner.trigger === 'string' ? owner.trigger : undefined; diff --git a/packages/language/src/variable-symbol-provider.ts b/packages/language/src/variable-symbol-provider.ts index a213b96..2348151 100644 --- a/packages/language/src/variable-symbol-provider.ts +++ b/packages/language/src/variable-symbol-provider.ts @@ -27,9 +27,22 @@ export interface VariableSymbol { export type VariableTable = Map; +/** + * One place a variable is declared: the AST node and the property holding + * its name, so a rename can find the name's own leaf. A catch binding's site + * is the `VarRef` its setting carries, under `ref`. + */ +export interface DeclaringSite { + node: AstNode; + property: string; + name: string; + type: VarType; +} + /** Injected as `references.VariableSymbolProvider`. */ export interface VariableSymbolProvider { collect(process: Process): VariableTable; + declaringSites(process: Process): DeclaringSite[]; } /** The repeat-clause slots read here, off whichever statement carries them. */ @@ -51,68 +64,92 @@ export function isRepeated(node: AstNode): node is AstNode & RepeatSlots { ); } -export class DefaultVariableSymbolProvider implements VariableSymbolProvider { - collect(process: Process): VariableTable { - const table: VariableTable = new Map(); - // Precedence, held by the `has` guards below: a header `var` beats a form - // field, which beats a catch binding. - for (const decl of process.decls) { - if (isVarDecl(decl)) { - table.set(decl.name, { name: decl.name, type: decl.type }); - } +/** + * Every declaring site of the process, in precedence order: a header `var`, + * then a form field, then a catch binding, then an `input`/`output` + * parameter and a repeated statement's element in source order. A type + * disagreement between two sites of one name is the validator's job. + */ +export function declaringSites(process: Process): DeclaringSite[] { + const sites: DeclaringSite[] = []; + for (const decl of process.decls) { + if (isVarDecl(decl)) { + sites.push({ + node: decl, + property: 'name', + name: decl.name, + type: decl.type, + }); } - // A type disagreement between two declarations is the validator's job. - for (const node of AstUtils.streamAst(process)) { - if (!isStartEvent(node) && !isUserTask(node)) continue; - for (const form of node.forms) { - for (const field of form.fields) { - const type = formFieldVariableType(field.type); - if (type !== undefined && !table.has(field.id)) { - table.set(field.id, { name: field.id, type }); - } + } + for (const node of AstUtils.streamAst(process)) { + if (!isStartEvent(node) && !isUserTask(node)) continue; + for (const form of node.forms) { + for (const field of form.fields) { + const type = formFieldVariableType(field.type); + if (type !== undefined) { + sites.push({ node: field, property: 'id', name: field.id, type }); } } } - // A catch binding declares a `string`: the code or message text it caught. - for (const node of AstUtils.streamAst(process)) { - if (!isOnHandler(node)) continue; - for (const binding of caughtBindingsOf(node.items)) { - // A setting whose value is not a plain name reads a variable rather - // than declaring one, so it seeds nothing. - const { variable } = binding; - if (variable === undefined) continue; - if (!table.has(variable)) { - table.set(variable, { name: variable, type: 'string' }); - } - } + } + // A catch binding declares a `string`: the code or message text it caught. + for (const node of AstUtils.streamAst(process)) { + if (!isOnHandler(node)) continue; + for (const { variable, node: setting } of caughtBindingsOf(node.items)) { + // A setting whose value is not a plain name reads a variable rather + // than declaring one, so it seeds nothing. + if (variable === undefined) continue; + sites.push({ + node: setting.value, + property: 'ref', + name: variable, + type: 'string', + }); } - // Seeded last, so an author who declares one of these names keeps its - // type. Both hold whatever was mapped or collected, so their type is open. - const seedOpen = (name: string | undefined): void => { - if (name !== undefined && !table.has(name)) { - table.set(name, { name, type: 'any' }); + } + // Both hold whatever was mapped or collected, so their type is open. + for (const node of AstUtils.streamAst(process)) { + if (isIoParameter(node)) { + // A field names a property of the delegate the element binds, set as + // that object is built, and a property is text handed to Tasklist or + // a worker, so neither declares anything the process can read. + if ( + node.direction !== FIELD_DIRECTION && + node.direction !== PROPERTY_DIRECTION + ) { + sites.push({ node, property: 'name', name: node.name, type: 'any' }); } - }; - let repeats = false; - for (const node of AstUtils.streamAst(process)) { - if (isIoParameter(node)) { - // A field names a property of the delegate the element binds, set as - // that object is built, and a property is text handed to Tasklist or - // a worker, so neither declares anything the process can read. - if ( - node.direction === FIELD_DIRECTION || - node.direction === PROPERTY_DIRECTION - ) { - continue; - } - seedOpen(node.name); - continue; + } else if (isRepeated(node) && node.element !== undefined) { + sites.push({ + node, + property: 'element', + name: node.element, + type: 'any', + }); + } + } + return sites; +} + +export class DefaultVariableSymbolProvider implements VariableSymbolProvider { + declaringSites(process: Process): DeclaringSite[] { + return declaringSites(process); + } + + collect(process: Process): VariableTable { + const table: VariableTable = new Map(); + // The first site of a name wins, so the table's type follows the + // precedence `declaringSites` lists in. + for (const site of this.declaringSites(process)) { + if (!table.has(site.name)) { + table.set(site.name, { name: site.name, type: site.type }); } - if (!isRepeated(node)) continue; - repeats = true; - seedOpen(node.element); } - if (repeats) { + // Seeded last, so an author who declares one of these names keeps its + // type. A count alone (`for 3`) sets them too, so this is not read off + // the sites. + if (AstUtils.streamAst(process).some(isRepeated)) { for (const name of LOOP_VARIABLES) { if (!table.has(name)) { table.set(name, { name, type: 'number' }); diff --git a/packages/language/src/vocabulary.ts b/packages/language/src/vocabulary.ts index a4225f3..eeb983d 100644 --- a/packages/language/src/vocabulary.ts +++ b/packages/language/src/vocabulary.ts @@ -86,6 +86,18 @@ export const TIMER_JOB_KEYS = [ ] as const; export type TimerJobKey = (typeof TIMER_JOB_KEYS)[number]; +export type EngineKey = (typeof ASYNC_FLAG_KEYS)[number] | TimerJobKey; + +/** + * The engine keys `BpmnParse.isAsyncBefore`, `isAsyncAfter` and `isExclusive` + * read with a case-sensitive `"true"` comparison, so any other text deploys + * as the setting turned off. + */ +export const BOOLEAN_ENGINE_KEYS: readonly EngineKey[] = [ + ...ASYNC_FLAG_KEYS, + 'exclusive', +]; + /** * The engine execution settings Operaton reads off any flow node, gateways * included: `BpmnParse.parseExclusiveGateway`, `parseInclusiveGateway`, @@ -95,7 +107,7 @@ export type TimerJobKey = (typeof TIMER_JOB_KEYS)[number]; * `DefaultFailedJobParseListener.parseActivity` reads the retry cycle on the * same four. */ -export const ENGINE_KEYS: readonly string[] = [ +export const ENGINE_KEYS: readonly EngineKey[] = [ ...ASYNC_FLAG_KEYS, ...TIMER_JOB_KEYS, ]; @@ -136,6 +148,13 @@ export function runSettingKey(key: string): string { return prefixedSettingKey('run', key); } +/** An engine key under each spelling a parens carries it, the value shape being the same under all three. */ +export const engineSpellings = (key: string): string[] => [ + key, + joinSettingKey(key), + runSettingKey(key), +]; + /** * The four settings a repetition writes on its `multiInstanceLoopCharacteristics` * element rather than on the repeated activity itself. @@ -158,6 +177,13 @@ export const RUN_ENGINE_KEYS: readonly string[] = ENGINE_KEYS.filter( */ export const TYPE_BINDING_KEY = 'type'; +/** + * The binding handing the step to an external worker, and so the one the + * external extras ride: `taskPriority` in the parens, a `property` line and an + * `error ... when` mapping in the block. + */ +export const EXTERNAL_BINDING_KEY = 'topic'; + /** * Exactly one of these binds a service task. `topic` delegates to an external * worker polling the engine rather than the engine invoking the binding, and @@ -169,7 +195,7 @@ export const SERVICE_TASK_BINDING_KEYS: readonly string[] = [ 'class', 'expression', 'delegate', - 'topic', + EXTERNAL_BINDING_KEY, TYPE_BINDING_KEY, ]; @@ -180,6 +206,9 @@ export const SERVICE_TASK_BINDING_KEYS: readonly string[] = [ export const THROW_BINDING_KEYS: readonly string[] = SERVICE_TASK_BINDING_KEYS.filter((key) => key !== TYPE_BINDING_KEY); +/** The one trigger whose throw or emit carries an implementation binding. */ +export const THROW_BINDING_TRIGGER = 'message'; + /** The two `operaton:type` values `parseServiceTaskLike` routes to a built-in behaviour. */ export const TYPE_BINDING_VALUES = ['mail', 'shell'] as const; @@ -399,6 +428,13 @@ export const TIMER_PARTICLES: readonly string[] = Object.values( export const EVENT_BINDING_FIELDS: readonly string[] = ['code', 'message']; +/** The fields the `trigger`'s event carries: a code always, a message where its rule says so. */ +export function eventBindingFieldsFor(trigger: string): readonly string[] { + return EVENT_BINDING_FIELDS.filter( + (field) => field !== 'message' || TRIGGER_PAYLOAD[trigger]?.message, + ); +} + /** Legal on every element with a member block, since each lowers to a flow node. */ export const EXECUTION_LISTENER_EVENTS = ['start', 'end'] as const; @@ -639,6 +675,8 @@ export interface TriggerPayloadRule { /** Whether the `particle`/`time` clause is required. */ readonly timer: boolean; readonly parens: 'bindings' | 'condition' | 'forbidden'; + /** Whether the event carries a message text beside its code. */ + readonly message: boolean; /** Whether a non-interrupting `alongside` handler is legal. */ readonly alongside: boolean; /** @@ -665,6 +703,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { code: 'optional', timer: false, parens: 'bindings', + message: true, alongside: false, boundary: true, hostless: true, @@ -673,6 +712,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { code: 'optional', timer: false, parens: 'bindings', + message: false, alongside: true, boundary: true, hostless: true, @@ -681,6 +721,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { code: 'required', timer: false, parens: 'forbidden', + message: false, alongside: true, boundary: true, hostless: true, @@ -689,6 +730,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { code: 'required', timer: false, parens: 'forbidden', + message: false, alongside: true, boundary: true, hostless: true, @@ -697,6 +739,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { code: 'forbidden', timer: true, parens: 'forbidden', + message: false, alongside: true, boundary: true, hostless: true, @@ -705,6 +748,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { code: 'forbidden', timer: false, parens: 'condition', + message: false, alongside: true, boundary: true, hostless: true, @@ -713,6 +757,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { code: 'forbidden', timer: false, parens: 'forbidden', + message: false, alongside: false, boundary: false, hostless: true, @@ -721,6 +766,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { code: 'forbidden', timer: false, parens: 'forbidden', + message: false, alongside: false, boundary: true, hostless: false, @@ -729,6 +775,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { code: 'required', timer: false, parens: 'forbidden', + message: false, alongside: false, boundary: false, hostless: false, diff --git a/packages/language/test/completion.test.ts b/packages/language/test/completion.test.ts index 13e53db..0533979 100644 --- a/packages/language/test/completion.test.ts +++ b/packages/language/test/completion.test.ts @@ -22,9 +22,13 @@ import { ATTEMPT_BLOCK_RULE, ATTRIBUTE_BLOCK_RULES, type BpmnScriptServices, + CALL_BINDING_VALUES, createBpmnScriptServices, + DECISION_RESULT_MAPPINGS, ENGINE_KEYS, FORM_CONSTRAINT_TYPES, + FORM_FIELD_TYPES, + TRIGGER_PAYLOAD, } from '@bpmn-script/language'; import { BLOCK_HOSTS, caretInSlot } from './helpers/block-hosts.js'; import { withTextMessages } from './helpers/diagnostics.js'; @@ -170,10 +174,7 @@ const LISTENER_EVENT = 'BPMNscript listener event'; /** Langium's own caption for a keyword it completes with no help from us. */ const KEYWORD = 'Keyword'; -/** - * The words every settings position also offers: an unkeyed item there is an - * event payload or a condition, which is an expression like any other. - */ +/** The words an expression position offers on its own. */ const LITERALS: Item[] = [ ['true', KEYWORD, 'true'], ['false', KEYWORD, 'false'], @@ -255,10 +256,23 @@ const HEADER_DECLS: Item[] = [ CONSTRUCT, 'var ${1:name}: ${2|string,number,boolean,date,json,any|}', ], + [ + 'error', + 'declare an error code', + 'error ${1:NAME}(message: "${2:message}")', + ], + ['escalation', 'declare an escalation code', 'escalation ${1:NAME}'], ]; const PROCESS_BODY: Item[] = [...HEADER_DECLS, ...STATEMENTS]; +/** Two declared variables, and the items completion offers for them. */ +const VARS = 'var amount: number\n var ok: boolean'; +const VARIABLES: Item[] = [ + ['amount', 'number', 'amount', CompletionItemKind.Variable], + ['ok', 'boolean', 'ok', CompletionItemKind.Variable], +]; + const REPEAT_FORMS: Item[] = [ [ 'for each', @@ -268,6 +282,11 @@ const REPEAT_FORMS: Item[] = [ ['for', 'how often the preceding step runs', 'for ${1:3}'], ]; +/** + * The words a handler at process level catches, host-less or on a user task. + * `compensation` joins them only directly inside a subprocess body, `cancel` + * only on an attempt block as host, and a service task drops `escalation`. + */ const ON_TRIGGERS: Item[] = [ ['error', EVENT_WORD, 'error'], ['escalation', EVENT_WORD, 'escalation'], @@ -275,8 +294,6 @@ const ON_TRIGGERS: Item[] = [ ['signal', EVENT_WORD, 'signal'], TIMER, CONDITION, - ['compensation', 'the undo block of this subprocess', 'compensation'], - ['cancel', EVENT_WORD, 'cancel'], ]; const RACE_TRIGGERS: Item[] = [ @@ -328,20 +345,15 @@ const RUN_SETTINGS: Item[] = [ ]; /** The head of a statement with a join: the split's keys, then the join's. */ -const SPLIT_AND_JOIN_PARENS: Item[] = [ - ...ENGINE_SETTINGS, - ...JOIN_SETTINGS, - ...LITERALS, -]; +const SPLIT_AND_JOIN_PARENS: Item[] = [...ENGINE_SETTINGS, ...JOIN_SETTINGS]; /** A loop has one gateway, so its parens take the bare keys alone. */ -const LOOP_PARENS: Item[] = [...ENGINE_SETTINGS, ...LITERALS]; +const LOOP_PARENS: Item[] = ENGINE_SETTINGS; /** An await block's head leaves out the one key the validator refuses there. */ const AWAIT_PARENS: Item[] = [ ...ENGINE_SETTINGS.filter(([label]) => label !== 'asyncAfter'), ...JOIN_SETTINGS, - ...LITERALS, ]; const PARAMETERS: Item[] = [ @@ -395,11 +407,12 @@ const ERROR_MAPPING: Item = [ /** What the brace block of an element holds, its settings having moved out. */ const BLOCK_MEMBERS: Item[] = [FORM_KEYWORD, ...PARAMETERS, LISTENER_KEYWORD]; -/** The same block on a service task, whose binding injects fields or hands the work to an external worker. */ -const FIELD_BLOCK_MEMBERS: Item[] = [ - FORM_KEYWORD, +/** The block of a service task bound to a class, which the engine injects fields into. */ +const CLASS_BLOCK_MEMBERS: Item[] = [...PARAMETERS, FIELD, LISTENER_KEYWORD]; + +/** The block of a service task handed to an external worker by topic. */ +const TOPIC_BLOCK_MEMBERS: Item[] = [ ...PARAMETERS, - FIELD, PROPERTY, LISTENER_KEYWORD, ERROR_MAPPING, @@ -437,10 +450,9 @@ const USER_PARENS: Item[] = [ ['followUpDate', SETTING, 'followUpDate: "${1:\\${dateTime().plusDays(1)}}"'], ['priority', SETTING, 'priority: ${1:50}'], ...ENGINE_SETTINGS, - ...LITERALS, ]; -const SERVICE_SETTINGS: Item[] = [ +const SERVICE_PARENS: Item[] = [ LABEL, DOCUMENTATION, ...BINDINGS, @@ -451,14 +463,8 @@ const SERVICE_SETTINGS: Item[] = [ ...ENGINE_SETTINGS, ]; -const SERVICE_PARENS: Item[] = [...SERVICE_SETTINGS, ...LITERALS]; - /** A repeated statement's parens offer the run keys after the engine ones. */ -const REPEATED_SERVICE_PARENS: Item[] = [ - ...SERVICE_SETTINGS, - ...RUN_SETTINGS, - ...LITERALS, -]; +const REPEATED_SERVICE_PARENS: Item[] = [...SERVICE_PARENS, ...RUN_SETTINGS]; const CALL_PARENS: Item[] = [ LABEL, @@ -474,7 +480,6 @@ const CALL_PARENS: Item[] = [ ['mapper', SETTING, 'mapper: "${1:com.example.CallMapper}"'], ['mapperDelegate', SETTING, 'mapperDelegate: "${1:\\${callMapperBean}}"'], ...ENGINE_SETTINGS, - ...LITERALS, ]; const RECEIVE_PARENS: Item[] = [ @@ -482,7 +487,6 @@ const RECEIVE_PARENS: Item[] = [ DOCUMENTATION, ['message', SETTING, 'message: "${1:MessageName}"'], ...ENGINE_SETTINGS, - ...LITERALS, ]; const DECIDE_PARENS: Item[] = [ @@ -502,15 +506,38 @@ const DECIDE_PARENS: Item[] = [ RESULT_VARIABLE, TASK_PRIORITY, ...ENGINE_SETTINGS, - ...LITERALS, ]; -/** A handler catching an error or an escalation binds what the event carries. */ +const CODE_BINDING: Item = ['code', SETTING, 'code: ${1:code}']; + +/** A handler catching an error binds what the event carries. */ const HANDLER_PARENS: Item[] = [ - ['code', SETTING, 'code: ${1:code}'], + CODE_BINDING, ['message', SETTING, 'message: ${1:message}'], ...ENGINE_SETTINGS, - ...LITERALS, +]; + +const ALONGSIDE: Item = ['alongside', 'BPMNscript flag', 'alongside']; + +/** An escalation carries no message, and its handler may run alongside the host. */ +const ESCALATION_HANDLER_PARENS: Item[] = [ + CODE_BINDING, + ...ENGINE_SETTINGS, + ALONGSIDE, +]; + +/** A declaration's fields carry the event's text, so both are quoted. */ +const DECLARATION_FIELDS: Item[] = [ + ['code', SETTING, 'code: "${1:code}"'], + ['message', SETTING, 'message: "${1:message}"'], +]; + +const SETTING_VALUE = 'BPMNscript setting value'; + +/** The two built-in behaviours, quoted as the `type` setting reads them. */ +const TYPE_VALUES: Item[] = [ + ['"mail"', SETTING_VALUE, '"mail"'], + ['"shell"', SETTING_VALUE, '"shell"'], ]; const PROCESS_PARENS: Item[] = [ @@ -528,7 +555,6 @@ const PROCESS_PARENS: Item[] = [ SETTING, 'candidateStarterGroups: "${1:adjusters}"', ], - ...LITERALS, ]; const START_PARENS: Item[] = [ @@ -536,10 +562,9 @@ const START_PARENS: Item[] = [ DOCUMENTATION, ['initiator', SETTING, 'initiator: "${1:starter}"'], ...ENGINE_SETTINGS, - ...LITERALS, ]; -const LISTENER_PARENS: Item[] = [...BINDINGS, ...LITERALS]; +const LISTENER_PARENS: Item[] = BINDINGS; const listenerEvent = (event: string): Item => [ event, @@ -631,9 +656,24 @@ describe('the completions offered at a caret', () => { HANDLER_PARENS, ], [ - 'the parens of a timer handler offer the keys naming a date and a cycle', + 'an escalation handler offers the code binding and the flag after its code', + 'process p {\n escalation X\n start S\n on escalation(X, |) {\n end Failed\n }\n}', + ESCALATION_HANDLER_PARENS, + ], + [ + 'the parens of a timer handler offer only the keys naming a date and a cycle until a time is written', 'process p {\n on timer(|) {\n end Late\n }\n}', - [...TIMER_KEYS, ...ENGINE_SETTINGS, ...LITERALS], + TIMER_KEYS, + ], + [ + "an error declaration's parens offer its two fields, quoted", + 'process p {\n error E(|)\n user A\n}', + DECLARATION_FIELDS, + ], + [ + "a service task's type slot offers the two built-in behaviours, quoted", + 'process p {\n service S(type: |)\n}', + TYPE_VALUES, ], [ 'the `await` trigger position offers only the triggers something can fire', @@ -786,14 +826,41 @@ describe('the completions offered at a caret', () => { BLOCK_MEMBERS, ], [ - 'a service block offers `field` and `property` alongside the two io directions', + 'a class-bound service block offers `field` alongside the two io directions', 'process p {\n service S(class: "com.example.D") {\n |\n }\n}', - FIELD_BLOCK_MEMBERS, + CLASS_BLOCK_MEMBERS, ], [ - 'a service block offers its whole member set after a property line', + 'a topic-bound service block offers its whole member set after a property line', 'process p {\n service S(topic: "t") {\n property k = "v"\n |\n }\n}', - FIELD_BLOCK_MEMBERS, + TOPIC_BLOCK_MEMBERS, + ], + // The same brace opens the body, and a member written there belongs to + // nothing, so the body position offers the statements alone. + [ + 'a subprocess body offers the statements and no member', + 'process p {\n subprocess S {\n |\n }\n}', + STATEMENTS, + ], + [ + 'an attempt body offers the statements and no member', + 'process p {\n attempt A {\n |\n }\n}', + STATEMENTS, + ], + [ + 'a subprocess body offers the statements alone after a step too', + 'process p {\n subprocess S {\n user A\n |\n }\n}', + [...REPEAT_FORMS, ...STATEMENTS], + ], + [ + 'a branch body offers the statements', + 'process p {\n var a: boolean\n if (a) {\n |\n }\n}', + STATEMENTS, + ], + [ + 'an unclosed user block still offers the task listener events', + 'process p {\n user T {\n on |', + TASK_EVENTS, ], [ "a listener's block holds an injected field and nothing else", @@ -885,6 +952,94 @@ describe('the completions offered at a caret', () => { ], ], ], + // With nothing after the head word the mapping does not parse, so the + // slot is told by the word alone. + [ + "a mapping's code slot offers the declared errors before the rest of the line is written", + 'process p {\n error PAYMENT_DECLINED(message: "x")\n escalation PAYMENT_REVIEW\n service S(topic: "t") {\n error |\n }\n}', + [ + [ + 'PAYMENT_DECLINED', + 'CodeDecl', + 'PAYMENT_DECLINED', + CompletionItemKind.Reference, + ], + ], + ], + // The caret's own node is the preceding member here, not the service + // task, so the owner has to be climbed rather than read off directly. + [ + "a mapping's code slot offers the declared errors after a preceding member too", + 'process p {\n error PAYMENT_DECLINED(message: "x")\n escalation PAYMENT_REVIEW\n service S(topic: "t") {\n property k = "v"\n error |\n }\n}', + [ + [ + 'PAYMENT_DECLINED', + 'CodeDecl', + 'PAYMENT_DECLINED', + CompletionItemKind.Reference, + ], + ], + ], + [ + 'a class-bound service block offers no code at the head word, since it maps nothing', + 'process p {\n error PAYMENT_DECLINED(message: "x")\n service S(class: "c") {\n error |\n }\n}', + [], + ], + // A bare variable is the prefix of the expression the author goes on to + // write, so these rows pin the offer and not its validity; the two + // variable items sort ahead of the literal words in the editor. + [ + 'a condition offers the declared variables beside the literal words', + `process p {\n ${VARS}\n if (|) {\n user A\n }\n}`, + [...LITERALS, ...VARIABLES], + ], + [ + 'a typed prefix narrows the offer to the variables matching it', + `process p {\n ${VARS}\n if (am|) {\n user A\n }\n}`, + [VARIABLES[0]!], + ], + // The parameter's own name is a variable too, seeded after the declared ones. + [ + "an io parameter's value offers the variables", + `process p {\n ${VARS}\n user T {\n input total = |\n }\n}`, + [ + ...LITERALS, + ...VARIABLES, + ['total', 'any', 'total', CompletionItemKind.Variable], + ], + ], + [ + "a condition handler's open payload offers the variables", + `process p {\n ${VARS}\n start S\n on condition(|) {\n end E\n }\n}`, + [LITERALS[0]!, LITERALS[1]!, ...VARIABLES], + ], + [ + 'a number setting offers the variables alone', + `process p {\n ${VARS}\n user T(jobPriority: |)\n}`, + VARIABLES, + ], + [ + 'a repeat clause offers the variables as its collection', + `process p {\n ${VARS}\n user T for each item in |\n}`, + [...LITERALS, ...VARIABLES], + ], + [ + "a call's in mapping offers the variables as its source", + `process p {\n ${VARS}\n call C(process: "q") {\n in x = |\n }\n}`, + [...LITERALS, ...VARIABLES], + ], + // An out mapping's source is read in the called process, whose variables + // this one does not know. + [ + "a call's out mapping offers no variable", + `process p {\n ${VARS}\n call C(process: "q") {\n out x = |\n }\n}`, + LITERALS, + ], + [ + 'a message name slot offers no variable', + `process p {\n ${VARS}\n user A\n throw message(|)\n}`, + [], + ], [ 'a map key inside a parameter value is left to the default completion', 'process p {\n user T {\n input x = {\n |\n }\n }\n}', @@ -893,7 +1048,7 @@ describe('the completions offered at a caret', () => { [ "a form field's parens offer its settings after a preceding one", 'process p {\n start S {\n form {\n amount: number (required: true, |)\n }\n }\n}', - [...FORM_FIELD_PARENS, ...LITERALS], + FORM_FIELD_PARENS, ], [ "a form field's block offers the property direction after a value line", @@ -1017,11 +1172,6 @@ describe('a scaffold parses and validates once accepted', () => { USER_PARENS.filter(namesAForm), (setting) => `process p {\n user T(${setting}, binding: latest)\n}`, ), - ...scaffolds( - 'the parens of a receive task', - RECEIVE_PARENS, - (setting) => `process p {\n receive R(${setting})\n}`, - ), // `taskPriority` rides a `topic` binding alone, so the host binds one. ...scaffolds( 'the parens of a service task', @@ -1069,23 +1219,11 @@ describe('a scaffold parses and validates once accepted', () => { CALL_PARENS.filter(binds), (binding) => `process p {\n call C(${binding})\n}`, ), - ...scaffolds( - 'the parens of a handler', - HANDLER_PARENS, - (setting) => - `process p {\n start S\n on error(${setting}) {\n user Caught\n }\n}`, - ), ...scaffolds( 'the parens of a process', PROCESS_PARENS, (setting) => `process p(${setting}) {\n user U\n}`, ), - ...scaffolds( - 'the parens of an if statement', - SPLIT_AND_JOIN_PARENS, - (setting) => - `process p {\n var a: boolean\n if (a) (${setting}) {\n user A\n }\n}`, - ), ...scaffolds( 'a user block', BLOCK_MEMBERS, @@ -1171,17 +1309,262 @@ const RULE_BY_DESCRIPTION = new Map( describe('an element offers exactly the settings it takes', () => { test.each(BLOCK_HOSTS)( 'the parens of %s offer the keys the validator accepts there', - async (_kind, description, _members, settings) => { + async (kind, description, _members, settings) => { const rule = RULE_BY_DESCRIPTION.get(description)!; const { text, line, character } = caretInSlot( settings, 'asyncBefore: true, ', ); + // The fixture emits a signal, which carries no implementation binding. expect(await labelsAt(text, line, character)).toEqual([ - ...rule.own, + ...(kind === 'emit' ? [] : rule.own), ...ENGINE_KEYS, - ...LITERALS.map(([label]) => label), + ...rule.flags, ]); }, ); }); + +const labels = (items: readonly Item[]): string[] => + items.map(([label]) => label); + +/** + * The keywords whose grammar rule continues with a mandatory token: each is a + * prefix rather than an offer, so it cannot parse alone. + */ +const PREFIX_WORDS: ReadonlySet = new Set(['goto', 'form']); + +/** + * A row is a program with one slot, so the caret and the spliced item come + * from the same text. A parse error is forgiven for a bare {@link PREFIX_WORDS} + * item and no other. A bare event word is spliced with the name its rule + * requires. Warnings are the editor doing its job on an example value and do + * not count; an error does, and one row reports every refused item with the + * sentence the validator answered. + */ +describe('offered is a subset of accepted', () => { + test.each string, string[]]>([ + [ + 'a thrown error takes engine settings and no implementation binding', + (x) => + `process p {\n error E(message: "m")\n user A\n throw error(E, ${x})\n}`, + [...ENGINE_KEYS], + ], + [ + 'a thrown escalation takes engine settings and no implementation binding', + (x) => + `process p {\n escalation E\n user A\n throw escalation(E, ${x})\n}`, + [...ENGINE_KEYS], + ], + [ + 'a thrown message keeps its bindings', + (x) => `process p {\n user A\n throw message("M", ${x})\n}`, + [...labels(BINDINGS), TOPIC[0], ...ENGINE_KEYS], + ], + [ + 'an emitted signal takes engine settings alone', + (x) => `process p {\n user A\n emit signal("S", ${x})\n}`, + [...ENGINE_KEYS], + ], + [ + 'an error handler binds both fields after its code', + (x) => + `process p {\n error X(message: "m")\n start S\n on error(X, ${x}) {\n end Failed\n }\n}`, + labels(HANDLER_PARENS), + ], + [ + 'an escalation handler binds the code alone and may run alongside', + (x) => + `process p {\n escalation X\n start S\n on escalation(X, ${x}) {\n end Failed\n }\n}`, + labels(ESCALATION_HANDLER_PARENS), + ], + [ + 'a hosted message handler takes the flag after its name', + (x) => + `process p {\n user U\n on U: message("M", ${x}) {\n end Failed\n }\n}`, + [...ENGINE_KEYS, ALONGSIDE[0]], + ], + [ + 'a catch-all error handler offers its bindings at the first slot', + (x) => + `process p {\n start S\n on error(${x}) {\n end Failed\n }\n}`, + labels(HANDLER_PARENS), + ], + [ + 'a timer handler offers only the clause keys until a time is written', + (x) => `process p {\n start S\n on timer(${x}) {\n end Late\n }\n}`, + labels(TIMER_KEYS), + ], + [ + 'a timer handler offers settings and the flag after its duration', + (x) => + `process p {\n start S\n on timer("PT1H", ${x}) {\n end Late\n }\n}`, + [...ENGINE_KEYS, ALONGSIDE[0]], + ], + [ + 'a condition handler offers the boolean words at its open payload', + (x) => + `process p {\n start S\n on condition(${x}) {\n end E\n }\n}`, + ['true', 'false'], + ], + [ + 'a condition handler offers settings and the flag after its condition', + (x) => + `process p {\n var x: number\n start S\n on condition(x > 1, ${x}) {\n end E\n }\n}`, + [...ENGINE_KEYS, ALONGSIDE[0]], + ], + [ + 'a message handler offers nothing before its name is written', + (x) => `process p {\n start S\n on message(${x}) {\n end E\n }\n}`, + [], + ], + [ + 'a thrown error offers only its declared codes before the code is written', + (x) => + `process p {\n error E(message: "m")\n user A\n throw error(${x})\n}`, + ['E'], + ], + [ + 'an error declaration takes its two fields, quoted', + (x) => `process p {\n error E(${x})\n user A\n}`, + labels(DECLARATION_FIELDS), + ], + [ + 'an escalation declaration takes the code alone', + (x) => `process p {\n escalation E(${x})\n user A\n}`, + [DECLARATION_FIELDS[0]![0]], + ], + [ + 'a form field offers the five form types and no variable type', + (x) => + `process p {\n start S {\n form {\n f: ${x}\n }\n }\n}`, + [...FORM_FIELD_TYPES], + ], + [ + "a receive task's parens offer settings and no literal word", + (x) => `process p {\n receive R(${x})\n}`, + labels(RECEIVE_PARENS), + ], + [ + "a gateway head's parens offer settings and no literal word", + (x) => + `process p {\n var a: boolean\n if (a) (${x}) {\n user A\n }\n}`, + labels(SPLIT_AND_JOIN_PARENS), + ], + [ + 'a version pin on a user task offers its two words', + (x) => `process p {\n user T(formRef: "f", binding: ${x})\n}`, + [...CALL_BINDING_VALUES], + ], + [ + 'a version pin on a call offers its two words', + (x) => `process p {\n call C(process: "q", binding: ${x})\n}`, + [...CALL_BINDING_VALUES], + ], + [ + 'a decision result mapping offers its four words', + (x) => + `process p {\n decide D(decision: "d", mapDecisionResult: ${x})\n}`, + [...DECISION_RESULT_MAPPINGS], + ], + [ + 'a boolean setting offers true and false', + (x) => `process p {\n user T(asyncBefore: ${x})\n}`, + ['true', 'false'], + ], + [ + 'a text setting offers no literal word and no variable', + (x) => `process p {\n ${VARS}\n user T(label: ${x})\n}`, + [], + ], + [ + 'a class-bound service block takes an injected field and no property', + (x) => `process p {\n service S(class: "c") {\n ${x}\n }\n}`, + labels(CLASS_BLOCK_MEMBERS), + ], + [ + 'a topic-bound service block takes a property and a mapping and no field', + (x) => + `process p {\n error CODE(message: "m")\n service S(topic: "t") {\n ${x}\n }\n}`, + labels(TOPIC_BLOCK_MEMBERS), + ], + [ + 'an expression-bound service block takes neither', + (x) => + `process p {\n service S(expression: "\${e}") {\n ${x}\n }\n}`, + [...labels(PARAMETERS), LISTENER_KEYWORD[0]], + ], + [ + 'a user block takes a form and no field', + (x) => `process p {\n user T {\n ${x}\n }\n}`, + labels(BLOCK_MEMBERS), + ], + [ + 'a host-less handler at process level catches neither a cancel nor a compensation', + (x) => `process p {\n start S\n on ${x} {\n end Failed\n }\n}`, + labels(ON_TRIGGERS), + ], + [ + 'a host-less handler in a subprocess body catches a compensation too', + (x) => + `process p {\n start S\n subprocess Sub {\n start Inner\n on ${x} {\n end Failed\n }\n }\n}`, + [...labels(ON_TRIGGERS), 'compensation'], + ], + // A host alone is the prefix of `on Host: trigger`, so the row completes it. + [ + 'a host-less handler in a subprocess body offers the hosts of that body and none outside it', + (x) => + `process p {\n user Outside\n subprocess Sub {\n user Inside\n on ${x === 'Inside' ? 'Inside: timer("PT1H")' : x} {\n end Failed\n }\n }\n}`, + ['Inside', ...labels(ON_TRIGGERS), 'compensation'], + ], + [ + 'a partly typed host word inside a subprocess body still offers the hosts of that body', + (x) => + `process p {\n subprocess Sub {\n user Inside\n on ${x === '|' ? 'Ins|' : `${x}: timer("PT1H") {\n end Failed\n }`}\n }\n}`, + ['Inside'], + ], + [ + 'a handler on a user task catches the boundary words and no cancel', + (x) => `process p {\n user U\n on U: ${x} {\n end Failed\n }\n}`, + labels(ON_TRIGGERS), + ], + [ + 'a handler on an attempt block catches a cancel too', + (x) => + `process p {\n attempt A {\n user B\n }\n on A: ${x} {\n end Failed\n }\n}`, + [...labels(ON_TRIGGERS), 'cancel'], + ], + [ + 'a handler on a service task catches no escalation', + (x) => + `process p {\n service U(class: "c")\n on U: ${x} {\n end Failed\n }\n}`, + labels(ON_TRIGGERS).filter((word) => word !== 'escalation'), + ], + ])('%s', async (_title, program, expectedLabels) => { + const { text, line, character } = caretAt(program('|')); + const items = await completionItems(text, line, character); + expect(items.map((i) => i.label)).toEqual(expectedLabels); + const refused: Array<[label: string, messages: string[]]> = []; + for (const item of items) { + const bare = accepted(item, typedInto) === item.label; + const inserted = + bare && TRIGGER_PAYLOAD[item.label]?.code === 'required' + ? `${item.label}("N")` + : accepted(item, typedInto); + if (inserted.includes('undefined')) { + refused.push([item.label, [`inserts '${inserted}'`]]); + continue; + } + const source = program(inserted); + const parse = await parseErrors(source); + const prefix = bare && PREFIX_WORDS.has(item.label); + if (parse.length > 0) { + if (!prefix) refused.push([item.label, parse]); + continue; + } + const errors = await validationErrors(source); + if (errors.length > 0) refused.push([item.label, errors]); + } + expect(refused).toEqual([]); + }); +}); diff --git a/packages/language/test/hover.test.ts b/packages/language/test/hover.test.ts new file mode 100644 index 0000000..8c9949f --- /dev/null +++ b/packages/language/test/hover.test.ts @@ -0,0 +1,99 @@ +/** + * Hover content, driven through the real `HoverProvider` with + * `expectHover` from `langium/test`. + * + * Langium's default hover resolves the caret to a declaration through + * `References.findDeclarations` before `getAstNodeHoverContent` ever runs, so + * a `goto` target, a handler's host, and a thrown or caught code exercise the + * same content function as the name they resolve to, with no row for the + * position itself. + */ + +import { describe, test, beforeAll } from 'vitest'; +import { EmptyFileSystem } from 'langium'; +import { expectHover } from 'langium/test'; +import { createBpmnScriptServices } from '@bpmn-script/language'; + +let hover: ReturnType; + +beforeAll(() => { + hover = expectHover(createBpmnScriptServices(EmptyFileSystem).BpmnScript); +}); + +/** A row of the table: a title, a program with one `<|>` caret, and the expected hover text (`undefined` for none). */ +type Row = readonly [title: string, text: string, expected: string | undefined]; + +const ROWS: Row[] = [ + [ + "a named statement's own name shows its kind, name and label", + 'process p {\n user <|>Review(label: "Review the invoice")\n}', + "a user task 'Review': Review the invoice", + ], + [ + 'a goto resolves to the same content as the name it targets', + 'process p {\n user Review(label: "Review the invoice")\n goto <|>Review\n}', + "a user task 'Review': Review the invoice", + ], + [ + "a handler's host resolves the same way", + 'process p {\n user Review(label: "Review the invoice")\n on <|>Review: timer("PT1H") { end Late }\n}', + "a user task 'Review': Review the invoice", + ], + [ + 'a subprocess names itself a subprocess', + 'process p {\n subprocess <|>Pack(label: "Pack the goods") { }\n}', + "a subprocess 'Pack': Pack the goods", + ], + [ + 'an attempt block names itself an attempt block, not a subprocess', + 'process p {\n attempt <|>Pay(label: "Pay the invoice") { }\n}', + "an attempt block 'Pay': Pay the invoice", + ], + [ + 'a declared error shows its code kind, name and message', + 'process p {\n error <|>E(message: "Payment failed")\n}', + "an error 'E': Payment failed", + ], + [ + 'a thrown code resolves to the same content as its declaration', + 'process p {\n error E(message: "Payment failed")\n throw error(<|>E)\n}', + "an error 'E': Payment failed", + ], + [ + 'a written code setting is quoted; the fallback to the name is not', + 'process p {\n escalation <|>OVERSIZED(code: "big-parcel")\n}', + 'an escalation \'OVERSIZED\' with code "big-parcel"', + ], + [ + 'a preceding doc comment is appended after the element line and a blank line', + 'process p {\n /** doc */\n user <|>Review\n}', + "a user task 'Review'\n\ndoc", + ], + ['a keyword shows nothing', 'process p {\n <|>user Review\n}', undefined], + [ + 'a trigger word shows nothing: it is soft, not a name or reference', + 'process p {\n error E(message: "m")\n on <|>error(E) { end Failed }\n}', + undefined, + ], + [ + 'a var use shows the name and its declared type', + 'process p {\n var amount: number\n if (<|>amount > 1) { end E }\n}', + 'amount: number', + ], + [ + 'a form field use shows the type the field binds', + 'process p {\n user U { form { customer: string } }\n if (<|>customer == "x") { end E }\n}', + 'customer: string', + ], + [ + 'a loop element use shows an open type', + 'process p {\n var items: json\n user U for each item in items { input x = <|>item.price }\n}', + 'item: any', + ], +]; + +describe('hover', () => { + test.each(ROWS)('%s', async (_title, text, expected) => { + await hover({ text, index: 0, hover: expected }); + }); +}); diff --git a/packages/language/test/navigation.test.ts b/packages/language/test/navigation.test.ts new file mode 100644 index 0000000..7b6ae56 --- /dev/null +++ b/packages/language/test/navigation.test.ts @@ -0,0 +1,260 @@ +/** + * Definition, references, rename and document highlight, driven through the + * real LSP providers on the shared services. + * + * Langium's four providers all resolve the caret through + * `References.findDeclarations` and walk the sites through + * `References.findReferences`, so one table per request is enough to pin the + * `References` override: a variable is keyed on the sites that declare it, + * everything else on the resolved cross-reference the default already handles. + * + * `langium/test` has no rename helper, so the rename rows drive + * `RenameProvider` by hand and assert the whole document after the edits: a + * rename that touched the declaration alone cannot pass on the text. + */ + +import { beforeAll, describe, expect, test } from 'vitest'; +import { EmptyFileSystem, TextDocument } from 'langium'; +import { + expectFindReferences, + expectGoToDefinition, + expectHighlight, + parseHelper, + replaceIndices, +} from 'langium/test'; +import { + createBpmnScriptServices, + type BpmnScriptServices, +} from '@bpmn-script/language'; +import { DiagnosticSeverity } from 'vscode-languageserver-types'; +import { withTextMessages } from './helpers/diagnostics.js'; +import { formatParseFailure } from './helpers/parse-failure.js'; + +let services: BpmnScriptServices; +let parse: ReturnType; +let definition: ReturnType; +let references: ReturnType; +let highlight: ReturnType; + +beforeAll(() => { + services = createBpmnScriptServices(EmptyFileSystem).BpmnScript; + parse = parseHelper(services); + definition = expectGoToDefinition(services); + references = expectFindReferences(services); + highlight = expectHighlight(services); +}); + +const REFUSED = 'refused'; + +/** + * The whole document after renaming the name under `<|>` to `newName`, or + * `REFUSED` where `prepareRename` and `rename` both answer `undefined`. + */ +async function renamed(text: string, newName: string): Promise { + const { output, indices } = replaceIndices({ text }); + const document = await parse(output); + const params = { + textDocument: { uri: document.textDocument.uri }, + position: document.textDocument.positionAt(indices[0]!), + newName, + }; + const provider = services.lsp.RenameProvider!; + const range = await provider.prepareRename(document, params); + const edit = await provider.rename(document, params); + if (range === undefined && edit === undefined) return REFUSED; + const edits = edit?.changes?.[document.textDocument.uri] ?? []; + return TextDocument.applyEdits(document.textDocument, edits); +} + +/** A row: a title, a program with one `<|>` caret, the new name, and the whole program afterwards. */ +type RenameRow = readonly [ + title: string, + text: string, + newName: string, + after: string, +]; + +const RENAMES: RenameRow[] = [ + [ + 'a var renamed at its declaration edits the two uses too', + 'process p {\n var <|>amount: number\n user U { input total = amount }\n if (amount > 1) { end E }\n}', + 'sum', + 'process p {\n var sum: number\n user U { input total = sum }\n if (sum > 1) { end E }\n}', + ], + [ + 'a var renamed at a use edits the declaration and the other use', + 'process p {\n var amount: number\n user U { input total = amount }\n if (<|>amount > 1) { end E }\n}', + 'sum', + 'process p {\n var sum: number\n user U { input total = sum }\n if (sum > 1) { end E }\n}', + ], + [ + 'a form field renamed at its id edits the condition reading it', + 'process p {\n user U { form { <|>customer: string } }\n if (customer == "x") { end E }\n}', + 'client', + 'process p {\n user U { form { client: string } }\n if (client == "x") { end E }\n}', + ], + [ + 'a catch binding renamed edits the condition reading it', + 'process p {\n error E\n user U\n on error(E, code: <|>c) { if (c == "X") { end Failed } }\n}', + 'reason', + 'process p {\n error E\n user U\n on error(E, code: reason) { if (reason == "X") { end Failed } }\n}', + ], + [ + 'a loop element renamed edits the accessor head in the body and leaves the collection alone', + 'process p {\n var items: json\n user U for each <|>item in items { input x = item.price }\n}', + 'line', + 'process p {\n var items: json\n user U for each line in items { input x = line.price }\n}', + ], + [ + 'an input parameter renamed edits the later condition', + 'process p {\n user U { input <|>total = 1 }\n if (total > 1) { end E }\n}', + 'sum', + 'process p {\n user U { input sum = 1 }\n if (sum > 1) { end E }\n}', + ], + [ + 'a name declared twice renames both declarations and the use', + 'process p {\n var <|>flag: boolean\n user U { form { flag: boolean } }\n if (flag) { end E }\n}', + 'done', + 'process p {\n var done: boolean\n user U { form { done: boolean } }\n if (done) { end E }\n}', + ], + [ + 'an undeclared name is refused', + 'process p {\n user U\n if (<|>missing) { end E }\n}', + 'found', + REFUSED, + ], + [ + 'a loop counter is refused: the engine sets it, nothing declares it', + 'process p {\n user U for 3\n if (<|>loopCounter > 1) { end E }\n}', + 'n', + REFUSED, + ], + [ + 'an accessor segment is refused', + 'process p {\n var order: json\n if (order.<|>total > 1) { end E }\n}', + 'sum', + REFUSED, + ], + [ + 'an out mapping target is refused', + 'process p {\n call C(process: "shipping") { out <|>trackingCode }\n}', + 'code', + REFUSED, + ], + [ + 'a word under a plain-text setting is refused', + 'process p {\n call C(process: "shipping", binding: <|>latest)\n}', + 'newest', + REFUSED, + ], + [ + 'a step renamed at a goto edits the step, the goto and the host', + 'process p {\n user Review\n goto <|>Review\n on Review: timer("PT1H") { end Late }\n}', + 'Check', + 'process p {\n user Check\n goto Check\n on Check: timer("PT1H") { end Late }\n}', + ], + [ + 'a code renamed at a throw edits the declaration, the throw, the handler and the mapping', + 'process p {\n error E\n service S(topic: "t") { error E when true }\n throw error(<|>E)\n on error(E) { end Failed }\n}', + 'Declined', + 'process p {\n error Declined\n service S(topic: "t") { error Declined when true }\n throw error(Declined)\n on error(Declined) { end Failed }\n}', + ], +]; + +/** A row: a title and a program with one `<|>` caret and the `<|...|>` ranges the request answers with. */ +type Row = readonly [title: string, text: string]; + +const DEFINITIONS: Row[] = [ + [ + 'a var use goes to its declaration', + 'process p {\n var <|amount|>: number\n if (<|>amount > 1) { end E }\n}', + ], + [ + 'a form field use goes to the field', + 'process p {\n user U { form { <|customer|>: string } }\n if (<|>customer == "x") { end E }\n}', + ], + [ + 'a catch binding use goes to the binding', + 'process p {\n error E\n user U\n on error(E, code: <|c|>) { if (<|>c == "X") { end Failed } }\n}', + ], + [ + 'a loop element use goes to the element', + 'process p {\n var items: json\n user U for each <|item|> in items { input x = <|>item.price }\n}', + ], + [ + 'an input parameter use goes to the parameter', + 'process p {\n user U { input <|total|> = 1 }\n if (<|>total > 1) { end E }\n}', + ], + [ + "a goto goes to the step's name", + 'process p {\n user <|Review|>\n goto <|>Review\n}', + ], + [ + "a host goes to the step's name", + 'process p {\n user <|Review|>\n on <|>Review: timer("PT1H") { end Late }\n}', + ], + [ + "a thrown code goes to the declaration's name", + 'process p {\n error <|E|>\n throw error(<|>E)\n}', + ], +]; + +const VAR_SITES = + 'process p {\n var <|amount|>: number\n user U { input total = <|amount|> }\n if (<|><|amount|> > 1) { end E }\n}'; + +const REFERENCES: Row[] = [ + ['a var lists its declaration and both uses', VAR_SITES], + [ + 'a form field lists the field and the use', + 'process p {\n user U { form { <|customer|>: string } }\n if (<|><|customer|> == "x") { end E }\n}', + ], + [ + 'a catch binding lists the binding and the use', + 'process p {\n error E\n user U\n on error(E, code: <|c|>) { if (<|><|c|> == "X") { end Failed } }\n}', + ], + [ + 'a loop element lists the element and the accessor head, not the collection', + 'process p {\n var items: json\n user U for each <|item|> in items { input x = <|><|item|>.price }\n}', + ], + [ + 'an input parameter lists the parameter and the use', + 'process p {\n user U { input <|total|> = 1 }\n if (<|><|total|> > 1) { end E }\n}', + ], + [ + 'a step lists its name, the goto and the host', + 'process p {\n user <|Review|>\n goto <|><|Review|>\n on <|Review|>: timer("PT1H") { end Late }\n}', + ], +]; + +describe('rename', () => { + test.each(RENAMES)('%s', async (_title, text, newName, after) => { + expect(await renamed(text, newName)).toBe(after); + if (after !== REFUSED) { + const document = await parse(after, { validation: true }); + expect(formatParseFailure(document)).toBeUndefined(); + expect( + withTextMessages(document.diagnostics ?? []) + .filter((d) => d.severity === DiagnosticSeverity.Error) + .map((d) => d.message), + ).toEqual([]); + } + }); +}); + +describe('definition', () => { + test.each(DEFINITIONS)('%s', async (_title, text) => { + await definition({ text, index: 0, rangeIndex: 0 }); + }); +}); + +describe('references', () => { + test.each(REFERENCES)('%s', async (_title, text) => { + await references({ text, includeDeclaration: true }); + }); +}); + +describe('document highlight', () => { + test('a var highlights its declaration and both uses in document order', async () => { + await highlight({ text: VAR_SITES, index: 0, rangeIndex: [0, 1, 2] }); + }); +}); diff --git a/packages/language/test/parsing.test.ts b/packages/language/test/parsing.test.ts index 4e1097c..4d8027b 100644 --- a/packages/language/test/parsing.test.ts +++ b/packages/language/test/parsing.test.ts @@ -1288,7 +1288,7 @@ describe('Parsing - sources the parser rejects', () => { `process p { if (a) { user A } else if (b) (asyncBefore: true) { user B } }`, [ "Expecting token of type '{' but found `if`.", - "Expecting token of type '}' but found ``.", + "Expected '}' before the end of the file: a block is still open.", ], ], [ diff --git a/packages/language/test/scoping.test.ts b/packages/language/test/scoping.test.ts index 25bc08c..75b96e3 100644 --- a/packages/language/test/scoping.test.ts +++ b/packages/language/test/scoping.test.ts @@ -5,9 +5,11 @@ * A `goto` resolves only within its nearest enclosing container, the `process`, * `subprocess`, or `on` handler body it directly sits in, and a `subprocess` * statement is itself a target by name. A cross-boundary `goto` fails to - * resolve, and the custom linker replaces the stock "Could not resolve - * reference" message with a boundary explanation rather than adding to it. A - * handler has no name of its own, so it is named in that message by its header. + * resolve, and the custom linker replaces Langium's stock resolution failure + * with a boundary explanation where the name exists elsewhere in the process, + * or with a plain "no step named" message where it exists nowhere at all. A + * handler has no name of its own, so it is named in a boundary message by its + * header. * * A hosted handler lowers inline into its host's container rather than into one * of its own, which makes its body transparent to the container walk: a `goto` @@ -194,9 +196,14 @@ function boundary( return `error: '${name}' is ${where}; ${BOUNDARY_RULE[rule]}`; } -/** The stock message, kept wherever the name exists nowhere in the process. */ -function stockError(name: string): string { - return `error: Could not resolve reference to Statement named '${name}'.`; +/** A `goto` naming a step nowhere in the process. */ +function missingStep(name: string): string { + return `error: No step named '${name}' in this process.`; +} + +/** A handler's host naming a step nowhere in the process. */ +function missingHost(name: string): string { + return `error: No step named '${name}' in this process to attach to.`; } /** Raised on every two-process fixture below, which needs both to ask its question. */ @@ -226,12 +233,12 @@ describe('Scoping - process-scoped goto', () => { [ 'a goto does not reach a step only another process has', `process a { user Foo goto Only } process b { user Only }`, - ['goto Only -> unresolved', stockError('Only'), MULTI_PROCESS], + ['goto Only -> unresolved', missingStep('Only'), MULTI_PROCESS], ], [ 'a goto to a name no process has does not resolve', `process p { user Foo goto Missing }`, - ['goto Missing -> unresolved', stockError('Missing')], + ['goto Missing -> unresolved', missingStep('Missing')], ], [ 'a goto reaches a named await', @@ -283,9 +290,9 @@ describe('Scoping - container-scoped goto (subprocess boundary)', () => { ], ], [ - 'a goto to a name nowhere in the process keeps the stock message', + 'a goto to a name nowhere in the process says so', `process p { subprocess Sub { user Inner } goto Missing }`, - ['goto Missing -> unresolved', stockError('Missing')], + ['goto Missing -> unresolved', missingStep('Missing')], ], ])('%s', checkRow); }); @@ -359,9 +366,9 @@ describe('Scoping - container-scoped goto (event-handler boundary)', () => { ], ], [ - 'a goto to a name nowhere in the process keeps the stock message', + 'a goto to a name nowhere in the process says so', `process p { error PAYMENT_FAILED goto Missing on error(PAYMENT_FAILED) { user Inner } }`, - ['goto Missing -> unresolved', stockError('Missing')], + ['goto Missing -> unresolved', missingStep('Missing')], ], [ 'an `on timer` handler is named by its code-less header', @@ -431,7 +438,7 @@ describe('Scoping - hosted handler host reference', () => { [ 'a host does not reach an activity of another process', `process a { user Review on Only: signal("Cancelled") { } } process b { user Only }`, - ['host Only -> unresolved', stockError('Only'), MULTI_PROCESS], + ['host Only -> unresolved', missingHost('Only'), MULTI_PROCESS], ], [ "a host reads the handler's container, not the handler's own body", @@ -452,9 +459,9 @@ describe('Scoping - hosted handler host reference', () => { ], ], [ - 'a host naming nothing anywhere keeps the stock message', + 'a host naming nothing anywhere says so', `process p { error X user Review on Missing: error(X) { } }`, - ['host Missing -> unresolved', stockError('Missing')], + ['host Missing -> unresolved', missingHost('Missing')], ], ])('%s', checkRow); }); diff --git a/packages/language/test/semantic-tokens.test.ts b/packages/language/test/semantic-tokens.test.ts index 78f2f51..f86e5a5 100644 --- a/packages/language/test/semantic-tokens.test.ts +++ b/packages/language/test/semantic-tokens.test.ts @@ -189,6 +189,11 @@ describe('Semantic tokens - soft event words', () => { [KEYWORD, PLAIN, KEYWORD, PLAIN], ], ['a step named after a soft word stays plain', 'user <|input|>', [PLAIN]], + [ + "a form field's type word is a keyword, its id is not", + 'start S { form { <|amount|>: <|number|> "Weight" } }', + [PLAIN, KEYWORD], + ], ...SOFT_WORDS_AS_VAR_NAME.map((word): Row => [ `\`var ${word}\` carries no token on the name`, `var <|${word}|>: string`, diff --git a/packages/language/test/symbols.test.ts b/packages/language/test/symbols.test.ts new file mode 100644 index 0000000..bd8e6f0 --- /dev/null +++ b/packages/language/test/symbols.test.ts @@ -0,0 +1,99 @@ +/** + * The document outline, driven through the real `DocumentSymbolProvider` with + * `expectSymbols` from `langium/test`, over the frozen + * `boundary-events.bpmnscript` golden. + * + * The whole tree is flattened to one `Kind name` line per symbol, indented by + * depth, and asserted as a single list: an `on` handler with no children of + * its own would otherwise be invisible (its steps would float to the level + * above), which is exactly the regression this pins. + */ + +import { readFileSync } from 'node:fs'; +import { describe, expect, test } from 'vitest'; +import { EmptyFileSystem } from 'langium'; +import { expectSymbols } from 'langium/test'; +import type { DocumentSymbol } from 'vscode-languageserver-types'; +import { SymbolKind } from 'vscode-languageserver-types'; +import { createBpmnScriptServices } from '@bpmn-script/language'; + +const KIND_NAME: Record = {}; +for (const [name, value] of Object.entries(SymbolKind)) { + if (typeof value === 'number') KIND_NAME[value] = name; +} + +function flatten(symbols: DocumentSymbol[], depth: number): string[] { + return symbols.flatMap((symbol) => [ + `${' '.repeat(depth)}${KIND_NAME[symbol.kind]} ${symbol.name}`, + ...flatten(symbol.children ?? [], depth + 1), + ]); +} + +// Generated once against `boundary-events.bpmnscript` and read against the +// golden's narrative before being pinned: three code declarations, every +// named statement kinded and, per handler, one line for its own header with +// its steps nested under it. `BookCarrier`'s `in`/`out` mapping lines are +// absent, a mapping having no name to be a symbol by. +const EXPECTED = [ + 'Module parcel-dispatch', + ' Constant ADDRESS_REJECTED', + ' Constant PAYMENT_DECLINED', + ' Constant OVERSIZED_PARCEL', + ' Event ParcelOrdered', + ' Function CheckAddress', + ' Namespace PackGoods', + ' Event PackStart', + ' Function PickItems', + ' Event Oversized', + ' Function SealParcel', + ' Event PackDone', + ' Function ComputeShipping', + ' Function ChargePostage', + ' Function PrintLabel', + ' Function BookCarrier', + ' Function HandOverParcel', + ' Event ParcelDispatched', + ' Event on CheckAddress: message("AddressVerified")', + ' Function MarkAddressVerified', + ' Event on CheckAddress: timer("PT4H", alongside)', + ' Function SendAddressReminder', + ' Event on PackGoods: error(ADDRESS_REJECTED, code: c, message: m)', + ' Function CorrectAddress', + ' Function NotifyInsuranceDesk', + ' Function RecordAddressFix', + ' Event on PackGoods: escalation(OVERSIZED_PARCEL, code: e, alongside)', + ' Function ArrangeFreight', + ' Event on ComputeShipping: timer("PT1H", alongside)', + ' Function NotifyShippingDelay', + ' Event on ChargePostage: error(PAYMENT_DECLINED)', + ' Function ReviewPayment', + ' Event on PrintLabel: message("ExpediteRequested")', + ' Function ExpediteShipment', + ' Event on BookCarrier: signal("CarrierStrike")', + ' Function BookAlternateCarrier', + ' Event on HandOverParcel: condition(weight > 30, alongside)', + ' Function CallForklift', + ' Event on signal("CarrierStrike", alongside)', + ' Event StrikeNoted', + ' Function RecordCarrierStrike', + ' Event StrikeRecorded', +]; + +describe('document outline', () => { + test('every element kinded, every handler named by its header and holding its steps', async () => { + const text = readFileSync( + new URL( + '../../../tests/golden/boundary-events.bpmnscript', + import.meta.url, + ), + 'utf8', + ); + const symbols = expectSymbols( + createBpmnScriptServices(EmptyFileSystem).BpmnScript, + ); + await symbols({ + text, + assert: (result) => expect(flatten(result, 0)).toEqual(EXPECTED), + }); + }); +}); diff --git a/packages/language/test/validating.test.ts b/packages/language/test/validating.test.ts index 95df107..de5135a 100644 --- a/packages/language/test/validating.test.ts +++ b/packages/language/test/validating.test.ts @@ -487,6 +487,10 @@ const gotoIntoBranch = (target: string, keyword: 'parallel' | 'await') => intoBranch(`goto ${target}`, 'goto', keyword); const unresolvedStatement = (name: string) => `Could not resolve reference to Statement named '${name}'.`; +const missingStep = (name: string) => + `No step named '${name}' in this process.`; +const missingHost = (name: string) => + `No step named '${name}' in this process to attach to.`; const reservedWord = (word: string) => `'${word}' is a reserved word and cannot be used as a plain name here. ` + `To refer to a variable named '${word}', write it as a quoted raw expression: "\${${word}}".`; @@ -2019,7 +2023,7 @@ checks('Validation - goto', [ [ 'an unresolved goto is the linker error alone', `process p { user Foo goto Missing }`, - [unresolvedStatement('Missing')], + [missingStep('Missing')], ], [ 'a goto resolving to a user task is clean', @@ -3971,7 +3975,7 @@ ${FENCE} [ 'an unresolved host reports only the resolution error', `process p { start S on message("M") { user A } on Missing: message("M") { user B } }`, - [unresolvedStatement('Missing')], + [missingHost('Missing')], ], [ 'two host-less handlers of an unknown trigger word draw the unknown-kind error alone', diff --git a/packages/language/test/variables.test.ts b/packages/language/test/variables.test.ts index 953a06a..4220a29 100644 --- a/packages/language/test/variables.test.ts +++ b/packages/language/test/variables.test.ts @@ -83,6 +83,31 @@ process p { ); }); + test('a repeat element enters the table as any beside the loop counters', async () => { + const process = await parseProcess(` +process p { + var items: json + user U for each item in items +} +`); + expect(newProvider().collect(process)).toEqual( + new Map([ + ['items', { name: 'items', type: 'json' }], + ['item', { name: 'item', type: 'any' }], + ['nrOfInstances', { name: 'nrOfInstances', type: 'number' }], + [ + 'nrOfActiveInstances', + { name: 'nrOfActiveInstances', type: 'number' }, + ], + [ + 'nrOfCompletedInstances', + { name: 'nrOfCompletedInstances', type: 'number' }, + ], + ['loopCounter', { name: 'loopCounter', type: 'number' }], + ]), + ); + }); + test('collect() answers membership and type queries via the returned table', async () => { const process = await parseProcess(` process p { diff --git a/packages/language/test/vocabulary-sync.test.ts b/packages/language/test/vocabulary-sync.test.ts index 063ddec..b311334 100644 --- a/packages/language/test/vocabulary-sync.test.ts +++ b/packages/language/test/vocabulary-sync.test.ts @@ -31,8 +31,12 @@ const TEXTMATE_GRAMMAR = fileURLToPath( new URL('../syntaxes/bpmn-script.tmLanguage.json', import.meta.url), ); -/** `\b(a|b|c)\b` from the generated `keyword.control` pattern. */ -const ALTERNATION = /^\\b\((.*)\)\\b$/; +/** + * `(? { }); }); +function grammarPatterns(): { name?: string; match?: string }[] { + const raw = readFileSync(TEXTMATE_GRAMMAR, 'utf8'); + return (JSON.parse(raw) as { patterns: { name?: string; match?: string }[] }) + .patterns; +} + +/** Every match of the named top-level pattern, tried as a JS `RegExp` (both + * engines agree on lookaround and the classes used here, the same assumption + * `injection-grammar.test.ts` makes for the extension's Oniguruma grammar). */ +function tokensOf(line: string, namePrefix: string): string[] { + const pattern = grammarPatterns().find((p) => p.name?.startsWith(namePrefix)); + if (!pattern?.match) { + throw new Error( + `No ${namePrefix} pattern in the generated TextMate grammar.`, + ); + } + return [...line.matchAll(new RegExp(pattern.match, 'g'))].map((m) => m[0]); +} + +describe('the TextMate grammar scopes a line', () => { + test.each<[string, string, string[], string[], string[]]>([ + [ + 'a hyphenated process name colours only the keyword', + 'process invoice-start {', + ['process'], + [], + [], + ], + [ + 'a hyphenated step name colours only the keyword', + ' step end-of-day', + ['step'], + [], + [], + ], + [ + 'a hyphenated setting value colours only the keyword', + ' user for-review(assignee: x)', + ['user'], + [], + [], + ], + [ + 'numbers and operators get their own scope, a hyphenated digit gets none', + ' if (amount == 100 && count-2 > 0.5) {', + ['if'], + ['100', '0.5'], + ['==', '&&', '>'], + ], + ])('%s', (_title, line, keywords, numbers, operators) => { + expect(tokensOf(line, 'keyword.control')).toEqual(keywords); + expect(tokensOf(line, 'constant.numeric')).toEqual(numbers); + expect(tokensOf(line, 'keyword.operator')).toEqual(operators); + }); +}); + describe('splitFencedScript', () => { test('a CRLF-checked-out source normalizes the body to LF', () => { expect( diff --git a/packages/language/textmate-postprocess.mjs b/packages/language/textmate-postprocess.mjs new file mode 100644 index 0000000..fd42270 --- /dev/null +++ b/packages/language/textmate-postprocess.mjs @@ -0,0 +1,66 @@ +// Runs after `langium generate`; `langium:watch` regenerates the unpatched +// file until the next full build, which also goes through this script. +// +// langium-cli's TextMate generator writes the keyword alternation as +// `\b(...)\b`, and `-` is a word boundary in that class, so a hyphenated name +// (`invoice-start`, `end-of-day`) colours only the part that collides with a +// keyword. It also emits no scope for numbers or expression operators. + +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const GRAMMAR_PATH = fileURLToPath( + new URL('./syntaxes/bpmn-script.tmLanguage.json', import.meta.url), +); + +/** The shape langium-cli generates: `\b(...)\b`. */ +const WORD_BOUNDARY = /^\\b\((.*)\)\\b$/; +/** The shape this script leaves behind, so a second run is a no-op. */ +const PATCHED_BOUNDARY = /^\(\?=|<|>|\+|\*|\/|%|!|\?|(? + pattern.name?.startsWith('keyword.control'), +); +if (!keywordPattern?.match) { + throw new Error( + 'No keyword.control pattern in the generated TextMate grammar.', + ); +} +const boundary = keywordPattern.match.match(WORD_BOUNDARY); +if (boundary) { + keywordPattern.match = `(? pattern.name === NUMERIC_PATTERN.name) +) { + grammar.patterns.push(NUMERIC_PATTERN); +} +if ( + !grammar.patterns.some((pattern) => pattern.name === OPERATOR_PATTERN.name) +) { + grammar.patterns.push(OPERATOR_PATTERN); +} + +writeFileSync(GRAMMAR_PATH, `${JSON.stringify(grammar, null, 2)}\n`); diff --git a/packages/transform/test/ir-to-dsl.test.ts b/packages/transform/test/ir-to-dsl.test.ts index 4bf6ae6..4a6a3d4 100644 --- a/packages/transform/test/ir-to-dsl.test.ts +++ b/packages/transform/test/ir-to-dsl.test.ts @@ -159,7 +159,7 @@ const MODEL_REFUSAL = { "A cancel end belongs directly inside an 'attempt' block", undoOutsideBlock: 'An undo block belongs directly inside the', hostOutsideContainer: - "Could not resolve reference to Statement named 'Elsewhere'", + "No step named 'Elsewhere' in this process to attach to.", orphanStep: 'This step can never run', secondDefaultStart: "this is the process's second plain or timer start", deadElse: 'could never run', From 3ae5fa73d030cdced7e5a0b079179c864d99447a Mon Sep 17 00:00:00 2001 From: Marlon Kranz Date: Sat, 26 Sep 2026 14:17:02 +0200 Subject: [PATCH 05/11] Trim comments and tests, derive shared tables from the vocabulary, document BPMN coverage Comments cut to what the code cannot say, tests folded into tables and whole-output pins, importer and writer tables derived from one source, README and ADR claims corrected against the code, and a BPMN coverage table with its limitations. --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- CHANGELOG.md | 3 +- CODE_OF_CONDUCT.md | 3 +- CONTRIBUTING.md | 41 +- README.md | 52 +- docs/bpmn-coverage.md | 156 + ...013-target-users-without-bpmn-knowledge.md | 1 + ...4-event-triggers-on-start-end-and-throw.md | 1 + ...ield-injection-as-a-parameter-direction.md | 2 + ...k-events-for-import-round-trip-symmetry.md | 2 + ...-synthesized-end-only-at-its-block-tail.md | 1 + .../0042-mail-and-shell-as-a-type-binding.md | 2 + ...minted-ids-and-respell-unspellable-ones.md | 1 + ...nthesize-variable-declarations-on-print.md | 3 + ...0052-loop-bodies-with-conditioned-exits.md | 1 + ...es-off-a-step-and-jumps-with-conditions.md | 1 + docs/decisions/README.md | 64 + docs/glossary.md | 10 + examples/README.md | 28 +- examples/spring-boot/README.md | 25 +- .../awaiting-confirmation.bpmnscript | 12 +- .../processes/batch-approval.bpmnscript | 11 +- .../processes/booking-attempt.bpmnscript | 23 +- .../processes/card-charge.bpmnscript | 13 +- .../processes/charge-with-recovery.bpmnscript | 17 +- .../processes/compensating-saga.bpmnscript | 17 +- .../processes/empty-batch.bpmnscript | 10 +- .../processes/external-task.bpmnscript | 4 +- .../processes/invoice-approval.bpmnscript | 8 +- .../processes/loan-approval-kopp.bpmnscript | 9 +- .../processes/loan-approval.bpmnscript | 3 - .../processes/nightly-report.bpmnscript | 16 +- .../processes/order-dispatch.bpmnscript | 23 +- .../processes/order-handling.bpmnscript | 18 - .../processes/order-intake.bpmnscript | 6 +- .../processes/order-recovery.bpmnscript | 11 - .../processes/order-reminder.bpmnscript | 11 - .../processes/order-rework.bpmnscript | 9 +- .../processes/outage-notice.bpmnscript | 7 +- .../processes/parallel-approval.bpmnscript | 9 - .../processes/plan-selection.bpmnscript | 5 +- .../processes/purchasing.bpmnscript | 8 +- .../processes/scheduled-audit.bpmnscript | 5 +- .../processes/script-task.bpmnscript | 4 - .../processes/service-delegate.bpmnscript | 3 +- .../processes/service-expression.bpmnscript | 4 +- .../processes/stock-alert.bpmnscript | 5 +- .../processes/support-ticket.bpmnscript | 4 - .../processes/task-kinds.bpmnscript | 16 +- .../example/demo/FailingChargeDelegate.java | 7 +- .../java/com/example/demo/LogDelegate.java | 10 +- .../main/java/com/example/demo/MarkerLog.java | 17 +- .../demo/RecordingExecutionListener.java | 10 +- .../example/demo/RecordingTaskListener.java | 9 +- .../com/example/invoice/ChargeService.java | 5 +- .../com/example/invoice/PrintLabelWorker.java | 6 +- .../com/example/invoice/ShippingBean.java | 6 +- .../loan/kopp/ExternalRatingS1Delegate.java | 1 - .../loan/kopp/ExternalRatingS2Delegate.java | 1 - .../loan/kopp/InternalRatingDelegate.java | 2 +- .../src/main/resources/application-demo.yml | 1 - .../src/main/resources/application.yml | 7 +- package-lock.json | 47 +- package.json | 10 +- packages/cli/README.md | 4 +- packages/cli/package.json | 6 + packages/cli/src/build.ts | 90 +- packages/cli/src/parse.ts | 155 +- packages/cli/src/util.ts | 89 +- packages/cli/test/build-parse.smoke.test.ts | 81 +- packages/cli/test/build.layout-guard.test.ts | 9 +- .../cli/test/decompile-contract.e2e.test.ts | 6 +- packages/cli/test/helpers/actions.ts | 23 +- packages/cli/test/util.test.ts | 33 +- packages/extension/README.md | 2 +- packages/extension/esbuild.mjs | 60 +- .../src/extension/conversion-core.ts | 17 +- .../extension/src/extension/conversion.ts | 231 +- .../src/extension/sidebar-view-provider.ts | 7 +- .../test/bundled-conversion.e2e.test.ts | 26 +- .../extension/test/conversion-core.test.ts | 43 +- packages/extension/test/conversion.test.ts | 76 +- .../extension/test/injection-grammar.test.ts | 34 +- .../test/language-configuration.test.ts | 19 +- packages/language/README.md | 84 +- packages/language/package.json | 6 + .../language/src/bpmn-script-completion.ts | 398 +-- .../src/bpmn-script-document-validator.ts | 33 +- packages/language/src/bpmn-script-hover.ts | 19 +- packages/language/src/bpmn-script-linker.ts | 35 +- ...mn-script-parser-error-message-provider.ts | 59 +- .../language/src/bpmn-script-references.ts | 36 +- .../src/bpmn-script-scope-provider.ts | 122 +- .../src/bpmn-script-semantic-tokens.ts | 17 +- .../language/src/bpmn-script-validator.ts | 1026 +++---- .../src/bpmn-script-value-converter.ts | 13 +- packages/language/src/bpmn-script.langium | 366 +-- packages/language/src/expression-render.ts | 48 +- packages/language/src/index.ts | 1 + packages/language/src/paren-items.ts | 131 +- .../language/src/variable-symbol-provider.ts | 36 +- packages/language/src/vocabulary.ts | 475 ++-- packages/language/test/completion.test.ts | 187 +- packages/language/test/helpers/block-hosts.ts | 6 +- packages/language/test/hover.test.ts | 13 +- packages/language/test/navigation.test.ts | 15 +- packages/language/test/parsing.test.ts | 297 +- packages/language/test/recovery-fuzz.test.ts | 60 +- packages/language/test/scoping.test.ts | 118 +- .../language/test/semantic-tokens.test.ts | 48 +- packages/language/test/symbols.test.ts | 18 +- packages/language/test/validating.test.ts | 2027 ++++--------- packages/language/test/variables.test.ts | 166 +- .../language/test/vocabulary-sync.test.ts | 36 +- packages/transform/README.md | 182 +- packages/transform/package.json | 6 + packages/transform/src/ast-to-ir.ts | 588 ++-- packages/transform/src/cfg-analysis.ts | 34 +- packages/transform/src/errors.ts | 69 +- packages/transform/src/index.ts | 12 +- packages/transform/src/ir-to-dsl.ts | 944 +++---- packages/transform/src/ir-to-xml.ts | 333 ++- packages/transform/src/ir/types.ts | 197 +- packages/transform/src/juel.ts | 54 +- packages/transform/src/synthesize-ids.ts | 10 +- packages/transform/src/xml-to-ir.ts | 2049 +++++--------- packages/transform/test/ast-to-ir.test.ts | 1115 +++----- packages/transform/test/cfg-analysis.test.ts | 36 +- packages/transform/test/form-fields.test.ts | 47 +- packages/transform/test/humanize.test.ts | 11 +- packages/transform/test/ir-to-dsl.test.ts | 2512 +++++++---------- .../test/ir-to-xml.layout-guard.test.ts | 17 +- packages/transform/test/ir-to-xml.test.ts | 1591 ++++------- packages/transform/test/juel.test.ts | 138 +- .../transform/test/operaton-moddle.test.ts | 558 ++-- .../transform/test/synthesize-ids.test.ts | 183 +- packages/transform/test/xml-to-ir.test.ts | 1345 ++------- tests/boundary-events.round-trip.test.ts | 156 +- tests/branch-and-race.round-trip.test.ts | 60 +- tests/call-activity.round-trip.test.ts | 290 +- tests/compensation.round-trip.test.ts | 25 +- tests/documentation.round-trip.test.ts | 6 +- tests/e2e/booking-attempt.test.ts | 1 - tests/e2e/branch-and-race.test.ts | 16 +- tests/e2e/deploy-sweep.test.ts | 8 +- tests/e2e/engine-extensions.test.ts | 29 +- tests/e2e/event-positions.test.ts | 16 +- tests/e2e/forms-and-external-tasks.test.ts | 1 - tests/e2e/invoice-approval.test.ts | 6 +- tests/e2e/job-settings-and-mail.test.ts | 9 +- tests/e2e/loan-approval-kopp.test.ts | 1 - tests/e2e/loan-approval.test.ts | 2 - tests/e2e/parallel-approval.test.ts | 1 - tests/e2e/repetition.test.ts | 10 - .../service-boundary-and-compensation.test.ts | 5 +- tests/engine-attributes.round-trip.test.ts | 36 +- tests/event-handlers.round-trip.test.ts | 24 +- tests/event-triggers.round-trip.test.ts | 35 +- tests/examples.health.test.ts | 13 +- tests/fixtures/adapters/external-tasks.ts | 5 - tests/fixtures/adapters/standalone.ts | 5 - tests/fixtures/index.test.ts | 16 - tests/fixtures/index.ts | 21 +- tests/fixtures/types.ts | 6 +- tests/forms.round-trip.test.ts | 8 +- tests/gateway-settings.round-trip.test.ts | 53 +- tests/golden/README.md | 2 +- tests/goto-fallback.round-trip.test.ts | 669 ++--- tests/helpers/bpmn-doc.ts | 11 + tests/helpers/di-bounds.ts | 20 +- tests/helpers/e2e-fixture.ts | 5 +- tests/helpers/engine-rest.ts | 200 +- tests/helpers/ir-query.ts | 6 +- tests/helpers/normalize-ir.test.ts | 142 +- tests/helpers/normalize-ir.ts | 71 +- tests/helpers/pipeline.ts | 37 +- tests/helpers/round-trip-fixture.ts | 20 +- tests/helpers/xml-query.ts | 3 - tests/import-contract.round-trip.test.ts | 86 +- tests/input-output.round-trip.test.ts | 66 +- tests/intermediate-catch.round-trip.test.ts | 21 +- tests/invoice-receipt.import.test.ts | 18 +- tests/link-events.round-trip.test.ts | 39 +- tests/listeners.round-trip.test.ts | 28 +- tests/mail-and-shell.round-trip.test.ts | 10 +- tests/nested-subprocess.round-trip.test.ts | 176 +- tests/new-constructs.round-trip.test.ts | 665 ++--- tests/package.json | 9 +- tests/parse-performance.test.ts | 22 +- tests/readme-fences.test.ts | 25 +- tests/repetition.round-trip.test.ts | 193 +- tests/review-loop.round-trip.test.ts | 8 +- tests/round-trip-constructs.test.ts | 243 +- tests/round-trip.test.ts | 185 +- tests/task-kinds.round-trip.test.ts | 101 +- tests/transactions.round-trip.test.ts | 188 +- 196 files changed, 8894 insertions(+), 15006 deletions(-) create mode 100644 docs/bpmn-coverage.md create mode 100644 docs/decisions/README.md delete mode 100644 tests/fixtures/adapters/external-tasks.ts delete mode 100644 tests/fixtures/adapters/standalone.ts delete mode 100644 tests/fixtures/index.test.ts create mode 100644 tests/helpers/bpmn-doc.ts diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 023640c..5546c73 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -16,5 +16,5 @@ Closes # - [ ] I have tested this change manually - [ ] I have added or updated tests as appropriate -- [ ] All checks pass (`npm test`, `npm run lint`, `npm run format:check`) +- [ ] All checks pass (`npm test`, `npm run lint`, `npm run format:check`, `npm run typecheck --workspaces`) - [ ] I have updated documentation as needed diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b4b9d..204e61e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,5 @@ # Changelog -This file follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and -[Semantic Versioning](https://semver.org/spec/v2.0.0.html). +This file follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Nothing has been released yet. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index c24f969..2847e6e 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -20,7 +20,8 @@ Examples of unacceptable behavior: ## Enforcement -Instances of unacceptable behavior may be reported to the project maintainer at dev@marlonkranz.com. All complaints will be reviewed. +Instances of unacceptable behavior may be reported to the project maintainer at dev@marlonkranz.com. +All complaints will be reviewed. ## Attribution diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f90d44..03c732e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,13 +2,15 @@ > This project is being developed as part of a bachelor's thesis. > External contributions are not accepted until after thesis submission. +> Bug reports are welcome now. +> Pull requests are welcome after submission. > The guidelines below will apply once the project opens up. ## Development setup ### Prerequisites -- [Node.js](https://nodejs.org/) >= 22 (the project uses Node 24 via `.nvmrc`; [nvm](https://github.com/nvm-sh/nvm) picks this up automatically) +- [Node.js](https://nodejs.org/) >= 22 (the project uses Node 24 via `.nvmrc`; [nvm](https://github.com/nvm-sh/nvm)'s `nvm use` reads it) - [VS Code](https://code.visualstudio.com/) with the recommended extensions (VS Code will prompt you on first open) - [Docker](https://www.docker.com/) for end-to-end tests (alternatives: [Podman](https://podman.io/), [Rancher Desktop](https://rancherdesktop.io/)) @@ -22,7 +24,9 @@ npm run build npm test ``` -`npm run build` runs the Langium code generator and compiles TypeScript across all packages. `npm test` runs the full test suite including Docker-based end-to-end tests. To skip those (no Docker installed, or just iterating on unit tests): +`npm run build` runs the Langium code generator and compiles TypeScript across all packages. +`npm test` runs the full test suite including Docker-based end-to-end tests. +To skip those (no Docker installed, or just iterating on unit tests): ```sh SKIP_DOCKER_TESTS=true npm test @@ -30,12 +34,17 @@ SKIP_DOCKER_TESTS=true npm test ### Trying it out in VS Code -After building, press F5 in VS Code. This opens a second VS Code window with the BPMNscript extension loaded. In that window you can create or open `.bpmnscript` files and get syntax highlighting, inline diagnostics, autocompletion, hover, and rename. To compile or decompile a file, use the command palette (`BPMNscript: Compile to BPMN` / `BPMNscript: Decompile to BPMNscript`) or the "Convert" panel in the BPMNscript activity-bar sidebar. The CLI alternative (`npx bpmns build `) still works if you prefer the terminal. +After building, press F5 in VS Code. +This opens a second VS Code window with the BPMNscript extension loaded. +In that window you can create or open `.bpmnscript` files and get syntax highlighting, inline diagnostics, autocompletion, hover, and rename. +To compile or decompile a file, use the command palette (`BPMNscript: Compile to BPMN` / `BPMNscript: Decompile to BPMNscript`) or the "Convert" panel in the BPMNscript activity-bar sidebar. +The CLI alternative (`npx bpmns build `) still works if you prefer the terminal. For a normal install instead of the development host, see [packages/extension/README.md](packages/extension/README.md#installing). ### Editing the grammar -The Langium grammar lives at `packages/language/src/bpmn-script.langium`. After editing it, regenerate the AST types before TypeScript can see the changes: +The Langium grammar lives at `packages/language/src/bpmn-script.langium`. +After editing it, regenerate the AST types before TypeScript can see the changes: ```sh npm run langium:generate @@ -49,6 +58,9 @@ npm run langium:watch Both commands run from the repo root. +The generated AST types land under `src/generated/`, which is git-ignored, so there is nothing to commit after regenerating. +CI's build job still runs `git diff --exit-code` right after `npm run build`; that step fails if the build leaves any tracked file changed, not because of the generated output. + ### Running tests ```sh @@ -59,19 +71,23 @@ npm test --workspace packages/cli # CLI package only npm test --workspace packages/extension # Extension package only ``` -The extension tests require `@bpmn-script/language` and `@bpmn-script/transform` to be built first (they are consumed as compiled `out/` directories). Run `npm run build` from the repo root before running the extension suite in isolation. +The extension tests require `@bpmn-script/language` and `@bpmn-script/transform` to be built first (they are consumed as compiled `out/` directories). +Run `npm run build` from the repo root before running the extension suite in isolation. ### Code style -The project uses [Prettier](https://prettier.io/) for formatting and [ESLint](https://eslint.org/) (via `eslint-plugin-bpmn-io`) for linting. Both run from the repo root: +The project uses [Prettier](https://prettier.io/) for formatting and [ESLint](https://eslint.org/) (via `eslint-plugin-bpmn-io`) for linting. +Both run from the repo root: ```sh -npm run format # Auto-format all source files -npm run format:check # Check formatting without writing (used in CI) -npm run lint # Run ESLint +npm run format # Auto-format all source files +npm run format:check # Check formatting without writing (used in CI) +npm run lint # Run ESLint +npm run typecheck --workspaces # Typecheck test sources; vitest does not (used in CI) ``` -Formatting happens on save if you use VS Code with the recommended Prettier extension. The `.editorconfig` handles basics (indentation, line endings) for other editors. +Formatting happens on save if you use VS Code with the recommended Prettier extension. +The `.editorconfig` handles basics (indentation, line endings) for other editors. ## Guidelines (post-thesis) @@ -87,9 +103,10 @@ Use the [feature request template](https://github.com/BPMNscript/bpmnscript/issu - Reference the related issue with `Closes #...` - Include a brief description of what changed and why -- Make sure `npm test`, `npm run lint`, and `npm run format:check` all pass +- Make sure `npm test`, `npm run lint`, `npm run format:check`, and `npm run typecheck --workspaces` all pass - Update `CHANGELOG.md` if the change is user-facing ### Architectural decisions -Non-trivial technical decisions are documented as [Markdown ADRs](docs/decisions/) using [MADR 4.0.0](https://adr.github.io/madr/). If your change involves an architectural decision, include a new ADR. +Non-trivial technical decisions are documented as [Markdown ADRs](docs/decisions/) using [MADR 4.0.0](https://adr.github.io/madr/). +If your change involves an architectural decision, include a new ADR. diff --git a/README.md b/README.md index 8050443..c48945c 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,13 @@ process invoice-approval { } ``` -Compiling this gives you a valid BPMN 2.0 file that visualizes the workflow and deploys to Operaton. +Compiling this gives you a valid BPMN 2.0 file that a graphical modeler opens as a diagram and Operaton deploys. ## Who it's for -Developers who'd rather work in a text editor than a diagram canvas, and who want processes to integrate in their existing code workflows like git repositories and diffs, autocompletion and compile-time error checking. +Developers who'd rather work in a text editor than a diagram canvas, and who want processes to fit into their existing code workflow: git and diffs, autocompletion, and compile-time error checking. -You don't need to know BPMN to read or write it. +You don't need to know BPMN to read or write BPMNscript. The keywords say what a step does rather than mirroring the BPMN element behind them. The diagram comes for free: describe a valid workflow and the tool draws it, so there are no lines to route, nothing to align, and no redoing a layout because one changed step spoiled it. @@ -63,6 +63,16 @@ npm test The test run includes end-to-end tests that boot a real Operaton engine in Docker via [testcontainers](https://testcontainers.com/), so it needs a running Docker daemon. Without one, set `SKIP_DOCKER_TESTS=true` to skip them (CI does this automatically). +## Documentation + +- [packages/language/README.md](packages/language/README.md): the language reference, every statement and the keys it takes +- [packages/transform/README.md](packages/transform/README.md#the-import-contract): the import contract, what a decompile refuses, drops, or warns about +- [docs/bpmn-coverage.md](docs/bpmn-coverage.md): the BPMN coverage table, every element kind against the statement that writes it and what the importer does with it +- [packages/cli/README.md](packages/cli/README.md): the `bpmns` command, its flags and exit codes +- [packages/extension/README.md](packages/extension/README.md): the VS Code extension, how it is built, installed, and packaged +- [docs/decisions/](docs/decisions/): the architectural decision records, why things are the way they are +- [docs/glossary.md](docs/glossary.md): the shared vocabulary + ## Using the CLI The `bpmns` command has two subcommands. @@ -73,23 +83,24 @@ After a build, run it with `npx`: npx bpmns build examples/spring-boot/processes/invoice-approval.bpmnscript # Choose the output path +cd examples/spring-boot/processes npx bpmns build invoice-approval.bpmnscript -o out/invoice-approval.bpmn # Decompile BPMN XML back to the DSL -npx bpmns parse invoice-approval.bpmn -o invoice-approval.bpmnscript +npx bpmns parse out/invoice-approval.bpmn -o out/invoice-approval.bpmnscript ``` Exit codes are `0` for success, `1` for validation or parse errors, `2` for I/O errors. Both directions print non-fatal warnings to stderr without changing the exit code, so an undeclared variable reference on compile, or a dropped lane on import is warned about but still produces a file. `parse` also re-validates the script it wrote and lists every error `build` would draw from it, still with exit `0`. -Both commands read a file from disk, never stdin, and refuse to write to the input path itself, or to overwrite an existing output unless you pass `--force`; see [packages/cli/README.md](packages/cli/README.md) for the exact rules. +Both commands read a file from disk, never stdin, refuse to overwrite an existing output unless you pass `--force`, and refuse the input path itself even then; see [packages/cli/README.md](packages/cli/README.md) for the exact rules. ## Using the VS Code extension Press F5 from the repo root. VS Code opens a second window with the extension loaded, where `.bpmnscript` files get: -- syntax highlighting, including inside a `script` task's fenced body, which is highlighted in its own language +- syntax highlighting, including inside a `script` task's fenced body, which is highlighted in its own language (JS, Groovy, Python and Ruby; no FEEL grammar is wired in, so it stays plain) - completion, hover, go to definition, find all references and rename, for steps, codes and variables, plus an outline, from the same grammar that drives the compiler - errors and warnings inline as you type, so a `goto` that can't reach its target, a `string` variable under an ordered comparison, or a `boolean` in arithmetic is flagged before you ever run the compiler - a **Convert** panel in the sidebar: compile the open file, jump to its counterpart when one exists, or pick a `.bpmn` from disk to decompile @@ -115,7 +126,7 @@ The event statements `on`, `throw`, `emit` and `await` are the exception: their | `start X (...)` | start when an event arrives | start event with a trigger | | `end X terminate` | stop every running path | terminate end event | | `end X cancel` | give up the block of steps around this end | cancel end event | -| `form { x: number "Label" }` | part of start element, pre-fills variables | form fields on the start event | +| `form { x: number "Label" }` | a form on a `start` or a `user` step; on a start it pre-fills variables | form fields on the start event or user task | | `user X(assignee: "...")` | a step a person performs | user task | | `service X(class: "...")` | a step the system performs, in the engine | service task | | `service X(topic: "...")` | a step an external worker picks up by topic | service task, external type | @@ -148,7 +159,7 @@ The `for` row is a modifier rather than a statement: every activity in the table The last three rows are members of an element rather than statements: an engine setting joins the rest in the `( )`, a parameter and a listener go in the `{ }`, and not every element takes every one. The process header's own settings are written with the other process declarations. The listener row reuses `on` instead of opening a new event sub-process; the body block a handler always carries and a listener never does is what tells the two apart ([ADR-0023](docs/decisions/0023-listeners-on-the-attribute-block.md)). -The `attempt` head and the `cancel` end are one construct: the end gives up the block it sits in, `on : cancel` beside the block catches it, and the block's finished steps that carry an undo block are undone in between. +The `attempt` head and the `cancel` end are one construct: the end gives up the block it sits in, `on : cancel` beside the block catches it, and the block's finished steps that carry an `on compensation` block (the undo block) are undone in between. [packages/language/README.md](packages/language/README.md) is the full specification: which keys each element takes, which elements take parameters and listeners, and what a conditioned `parallel` branch compiles to. @@ -202,7 +213,7 @@ process order-handling { `alongside` is the difference between interrupting the step and running beside it. The four-hour timer nudges the reviewer while review stays open; the `AutoApproved` message cancels the review outright and jumps to payment. -The declined-card error kills the payment sub-process and hands the customer to an agent, while the large-payment escalation pulls in a manager without stopping the capture. +The declined-card error kills the payment sub-process and hands the customer to an agent, and the large-payment escalation pulls in a manager without stopping the capture. ### Compensation @@ -266,15 +277,15 @@ A decompile deals with that in three ways. A branch that only rejoins the block's tail may print after the block instead, reached by a `goto`, and the block's join may be replaced by direct edges into that tail; the set of steps and their order along each path is unchanged, so this restructuring prints with no warning at all. -What each hop reports, item by item, is the import contract in [packages/transform/README.md](packages/transform/README.md#the-import-contract) and `PrintWarningCategory` with the warnings built beside it in [packages/transform/src/ir-to-dsl.ts](packages/transform/src/ir-to-dsl.ts). +What each hop reports, item by item, is the import contract in [packages/transform/README.md](packages/transform/README.md#the-import-contract) and the print warnings in [packages/transform/README.md](packages/transform/README.md#print-warnings). [ADR-0009](docs/decisions/0009-dominator-based-restructuring.md) covers which shapes degrade and why. -The engine settings a modeler tunes import warning-free: async continuation, exclusivity, job priority and the retry cycle on any event, activity, or gateway, the same settings minus priority on each run of a repetition, `operaton:inputOutput` on an activity in all four value forms, execution listeners on any event or activity and task listeners on a user task in all four binding forms, a step's repetition in either spelling of its collection and element variable, a call activity's variable mapping in either of the two attributes that name one, a form field's constraints, enum values, date pattern and properties, an external task's priority, properties and error mappings, and a mail or shell task's own fields ([ADR-0022](docs/decisions/0022-engine-attributes-as-named-ir-fields.md), [ADR-0023](docs/decisions/0023-listeners-on-the-attribute-block.md), [ADR-0027](docs/decisions/0027-repetition-on-the-authoring-surface.md), [ADR-0033](docs/decisions/0033-call-activity-variable-mapping.md), [ADR-0037](docs/decisions/0037-form-field-constraints-values-and-properties.md), [ADR-0038](docs/decisions/0038-external-task-extras-ride-the-topic-binding.md), [ADR-0040](docs/decisions/0040-engine-settings-on-synthesized-gateways.md), [ADR-0041](docs/decisions/0041-per-run-job-settings-on-a-repetition.md), [ADR-0042](docs/decisions/0042-mail-and-shell-as-a-type-binding.md)). +Every engine setting a modeler tunes imports warning-free; the IR shape in [packages/transform/README.md](packages/transform/README.md#ir-shape) lists each one. A user task assigned in BPMN's own words, a `bpmn:humanPerformer` or a `bpmn:potentialOwner`, imports onto the same `assignee`, `candidateUsers` and `candidateGroups` the engine reads it into, with a warning naming the rewrite ([ADR-0039](docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md)). A link throw is one exception: each engine setting and listener on one warns, because Operaton creates no activity for a link throw and so never reads them ([ADR-0035](docs/decisions/0035-link-events-for-import-round-trip-symmetry.md)). -`asyncAfter` on an event-based gateway is the other: the import refuses it, as `BpmnParse.parseEventBasedGateway` refuses to deploy it ([ADR-0040](docs/decisions/0040-engine-settings-on-synthesized-gateways.md)). +`asyncAfter` on an event-based gateway is the other: the import refuses it, as Operaton refuses to deploy it ([ADR-0040](docs/decisions/0040-engine-settings-on-synthesized-gateways.md)). What stays out of reach is a setting with nowhere to sit in the text. -That is a listener or an input/output parameter on a synthesized gateway, a setting on the `bpmn:process` element, or a job priority on the repetition element, since `BpmnParse.createActivityOnScope` reads one off the step alone. +That is a listener or an input/output parameter on a synthesized gateway, a setting on the `bpmn:process` element, or a job priority on the repetition element, which the engine reads off the step alone. The reasoning is in [ADR-0014](docs/decisions/0014-honest-bpmn-import-contract.md). The short version: a round trip that changes the model/workflow without warning is worse than one that refuses to run. @@ -327,8 +338,23 @@ examples/ spring-boot/ Operaton + Spring Boot deployment, also the e2e fixture docs/ decisions/ Architectural decision records + glossary.md Shared vocabulary ``` +## Limitations + +The tool writes one executable process per file: a collaboration with pools and message flows imports as that one process, with a warning per pool and per message flow, and a second executable process refuses the import ([coverage table](docs/bpmn-coverage.md#collaboration-and-root-elements)). +Lanes have no textual form, so a lane is dropped with a warning and every step lands in the one process ([coverage table](docs/bpmn-coverage.md#collaboration-and-root-elements)). +Data objects, data stores, and data associations are not authored and are dropped with a warning on import; a process variable is declared with `var` and reaches a step through `input` and `output` parameters instead ([coverage table](docs/bpmn-coverage.md#data)). +Text annotations, associations, and groups go the same way ([coverage table](docs/bpmn-coverage.md#artifacts-and-diagram)). +An ad hoc sub-process and a complex gateway have no statement, so either refuses the import ([activities](docs/bpmn-coverage.md#activities), [gateways](docs/bpmn-coverage.md#gateways)). +A standard loop is dropped with a warning: the activity that carried it still imports, but runs once regardless ([coverage table](docs/bpmn-coverage.md#activities)). +A manual task and a definition-less intermediate throw are not authored; each imports as a plain `step` instead, with a warning naming the rewrite ([activities](docs/bpmn-coverage.md#activities), [intermediate throw events](docs/bpmn-coverage.md#intermediate-throw-events)). +A compensation handler is written on a `subprocess` only, so BPMN's compensation boundary event with its association refuses the import and the message names the rewrite ([coverage table](docs/bpmn-coverage.md#boundary-events)). +Forms are the `operaton:formData` fields on a start or a user task, or a `formKey`/`formRef` naming one deployed elsewhere; a form field typed outside `string`, `long`, `boolean`, `date`, and `enum` refuses the import ([import contract](packages/transform/README.md#refusals)). +A `decide` step names a deployed decision table, and the language has no form for the table itself ([packages/language/README.md](packages/language/README.md#send-receive-and-decision-tasks)). +The diagram is generated on every compile, so hand-placed coordinates in a `.bpmn` file are discarded on import without a warning ([ADR-0003](docs/decisions/0003-auto-layout-for-diagram-interchange.md)). + ## Project status BPMNscript is being built as a bachelor's thesis at [University of Hamburg](https://www.uni-hamburg.de/), supervised by Dr. Oliver Kopp. diff --git a/docs/bpmn-coverage.md b/docs/bpmn-coverage.md new file mode 100644 index 0000000..eb5771f --- /dev/null +++ b/docs/bpmn-coverage.md @@ -0,0 +1,156 @@ +# BPMN coverage + +Which BPMN 2.0 elements BPMNscript writes, and what the importer does with each one it meets in a `.bpmn` file. +The BPMNscript column is the statement that compiles to the element, or "not authored" when the language has no form for it; the statement's keys are in [packages/language/README.md](../packages/language/README.md). +The import column says whether `xmlToIr` imports the element, drops it with a warning (the category in parentheses), or refuses the file (the error class in parentheses); the exact conditions are in the [import contract](../packages/transform/README.md#the-import-contract). +Operaton's own extension elements (`operaton:` settings, input/output parameters, listeners, form fields) are not BPMN elements and are listed with the [IR shape](../packages/transform/README.md#ir-shape) instead. + +## Activities + +| BPMN element | BPMNscript | Import | +| ------------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bpmn:task` | `step X` | imported | +| `bpmn:manualTask` | not authored | imported as `step`, warned (`unmappedConstruct`): history reports `task` where the source wrote `manualTask` | +| `bpmn:userTask` | `user X(...)` | imported; refused on a `bpmn:humanPerformer` beside `operaton:assignee` or a second `bpmn:humanPerformer` (`UnsupportedAssignmentError`) | +| `bpmn:humanPerformer`, `bpmn:potentialOwner` on a user task | `assignee`, `candidateUsers`, `candidateGroups` keys | imported onto those keys, warned (`unmappedConstruct`) naming the rewrite | +| `bpmn:performer`, `bpmn:resourceRole`, a role on any other activity | not authored | dropped, warned (`unmappedConstruct`) | +| `bpmn:serviceTask` | `service X(...)` | imported; refused without a binding the engine reaches, with a `type` outside `external`, `mail`, and `shell`, or with an `operaton:connector` (`UnsupportedServiceTaskFormError`) | +| `bpmn:sendTask` | `send X(...)` | imported, refused as a service task is | +| `bpmn:businessRuleTask` | `decide X(...)` | imported, refused as a service task is, plus on a `decisionRefTenantId` or a `decisionRefBinding` of `versionTag` (`UnsupportedServiceTaskFormError`) | +| `bpmn:receiveTask` | `receive X(...)` | imported | +| `bpmn:scriptTask` | `script X` with a fenced body | imported; refused on an empty `scriptFormat`, on neither a body nor a resource, or on a `operaton:resource` in place of the body (`UnsupportedExtensionFormError`) | +| `bpmn:callActivity` | `call X(process: "...")` | imported; refused on a `caseRef`, a `calledElementTenantId`, a `versionTag` binding, or an `in`/`out` mapping outside the recognized shapes (`UnsupportedCallActivityError`) | +| `bpmn:subProcess` | `subprocess X { }` | imported; refused without a start event (`UnsupportedEventFeatureError`) | +| `bpmn:subProcess` with `triggeredByEvent="true"` | `on { }` | imported as an event handler, see the start event rows | +| `bpmn:transaction` | `attempt X { }` | imported, a missing start event added; `method`, `protocol`, and `triggeredByEvent` dropped, warned (`unmappedConstruct`) | +| `bpmn:adHocSubProcess` | not authored | refused (`UnsupportedElementError`) | +| `bpmn:multiInstanceLoopCharacteristics` | `for each x in c`, `for 3`, with `sequentially` | imported; refused on an event handler, beside an `operaton:outputParameter`, or with a `loopCardinality` body the script cannot spell (`UnsupportedLoopCharacteristicsError`) | +| `bpmn:standardLoopCharacteristics` | not authored | dropped, warned (`unmappedConstruct`): Operaton runs the activity once regardless | +| `startQuantity`, `completionQuantity` other than `1` | not authored | dropped, warned (`unmappedConstruct`): Operaton never reads either | + +## Gateways + +| BPMN element | BPMNscript | Import | +| ------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bpmn:exclusiveGateway` | `if` / `else if` / `else`, `while`, `do ... while` | imported; refused on a shape `BpmnParse.validateExclusiveGateway` fails, such as two unconditioned routes (`UnsupportedGatewayShapeError`) | +| `bpmn:parallelGateway` | `parallel { { } { } }` | imported | +| `bpmn:inclusiveGateway` | `parallel { if (c) { } else { } }` | imported | +| `bpmn:eventBasedGateway` | `await { (...) { } (...) { } }` | imported; refused when a branch leads to anything but a catch event or the gateway carries `asyncAfter` (`UnsupportedEventFeatureError`); `instantiate` and `eventGatewayType` dropped, warned (`unmappedConstruct`) | +| `bpmn:complexGateway` | not authored | refused (`UnsupportedElementError`) | +| `name` on any gateway | not authored | imported and then left out of the script, warned on print (`label`) | +| a listener on any gateway | not authored | dropped, warned (`extensionAttribute`) | + +## Events + +### Start events + +| BPMN element | BPMNscript | Import | +| -------------------------------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `bpmn:startEvent` with no definition | `start X` | imported; a second one on the process refused (`UnsupportedEventFeatureError`) | +| message start | `start X message("Name")` | imported; refused when the name is an expression (`UnsupportedEventFeatureError`) | +| signal start | `start X signal("Name")` | imported | +| timer start | `start X timer("PT1H")`, `at:`, `every:` | imported | +| conditional start | `start X condition(expr)` | imported | +| error, escalation, or compensation start on the process | not authored | refused, since Operaton ignores the trigger (`UnsupportedEventFeatureError`) | +| any other definition on a process start | not authored | refused (`UnsupportedEventDefinitionError`) | +| a start with a definition inside a sub-process or transaction | not authored | refused (`UnsupportedEventFeatureError`) | +| a second start event inside a `bpmn:subProcess` or `bpmn:transaction` | not authored | refused (`UnsupportedEventFeatureError`) | +| an event sub-process start: error, escalation, message, signal, timer, condition | `on (...) { }`, `alongside` when allowed | imported as the handler's trigger; refused with more or fewer than one definition (`UnsupportedEventFeatureError`) | +| an event sub-process start carrying a cancel, link, or terminate definition | not authored | refused (`UnsupportedEventDefinitionError`) | +| an event sub-process start: compensation | `on compensation { }` inside a `subprocess` | imported inside a sub-process or transaction; refused on the process or in another handler (`UnsupportedEventFeatureError`) | + +### End events + +| BPMN element | BPMNscript | Import | +| ---------------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bpmn:endEvent` with no definition | `end X` | imported | +| terminate end | `end X terminate` | imported | +| cancel end | `end X cancel` inside an `attempt` | imported inside a `bpmn:transaction`; refused anywhere else (`UnsupportedEventFeatureError`) | +| error end | `throw error(CODE)` | imported; refused without an `errorRef` or with a root carrying no code (`UnsupportedEventFeatureError`) | +| escalation end | `throw escalation(CODE)` | imported; refused without an `escalationRef` or with a root carrying no code (`UnsupportedEventFeatureError`) | +| message end | `throw message("Name")` | imported; refused with a connector (`UnsupportedEventFeatureError`), or a `type` other than `external`, or `external` with no `topic` (`UnsupportedServiceTaskFormError`); `type="external"` with a `topic` imports as a binding | +| signal end | `throw signal("Name")` | imported | +| compensation end | `throw compensation` | imported; refused with an `activityRef` naming one activity, or `waitForCompletion="false"` (`UnsupportedEventFeatureError`) | +| any other definition on an end | not authored | refused (`UnsupportedEventDefinitionError`) | + +### Intermediate throw events + +| BPMN element | BPMNscript | Import | +| ------------------------------------------------ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bpmn:intermediateThrowEvent` with no definition | not authored | imported as `step`, warned (`unmappedConstruct`): history reports `task` | +| escalation throw | `emit escalation(CODE)` | imported | +| message throw | `emit message("Name")` | imported; refused with a connector (`UnsupportedEventFeatureError`), or a `type` other than `external`, or `external` with no `topic` (`UnsupportedServiceTaskFormError`); `type="external"` with a `topic` imports as a binding, the same rule a message end follows | +| signal throw | `emit signal("Name")` | imported | +| compensation throw | `emit compensation` | imported; refused with an `activityRef` naming one activity, or `waitForCompletion="false"` (`UnsupportedEventFeatureError`) | +| link throw | `emit link("Name")` | imported; refused when it names no link or a sequence flow leaves it (`UnsupportedEventFeatureError`) | +| error throw | not authored | refused, BPMN has no intermediate error throw (`UnsupportedEventFeatureError`) | +| any other definition on an intermediate throw | not authored | refused (`UnsupportedEventDefinitionError`) | + +### Intermediate catch events + +| BPMN element | BPMNscript | Import | +| -------------------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------- | +| message catch | `await message("Name")` | imported | +| signal catch | `await signal("Name")` | imported | +| timer catch | `await timer("PT1H")` | imported | +| conditional catch | `await condition(expr)` | imported | +| link catch | `await link("Name")` | imported; refused when it names no link or a sequence flow enters it (`UnsupportedEventFeatureError`) | +| a catch with no definition or any other kind | not authored | refused (`UnsupportedEventFeatureError`) | +| `parallelMultiple="true"` | not authored | refused (`UnsupportedEventFeatureError`) | + +### Boundary events + +| BPMN element | BPMNscript | Import | +| ---------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| error boundary | `on Host: error(CODE) { }` | imported; refused with `cancelActivity="false"` (`UnsupportedEventFeatureError`) | +| escalation, message, signal, timer, or conditional boundary | `on Host: (...) { }`, `alongside` for `cancelActivity="false"` | imported | +| cancel boundary | `on Block: cancel { }` beside an `attempt` | imported on a `bpmn:transaction` host; refused on any other host, with `cancelActivity="false"`, or as a second cancel boundary (`UnsupportedEventFeatureError`) | +| compensation boundary with its `bpmn:association` and handler activity | not authored; write `on compensation { }` inside a `subprocess` | refused, the message names the rewrite (`UnsupportedEventFeatureError`) | +| any other definition on a boundary | not authored | refused (`UnsupportedEventDefinitionError`) | +| a boundary with no `attachedToRef`, an incoming flow, or no definition | not authored | refused (`UnsupportedEventFeatureError`) | + +## Sequence flow + +| BPMN element | BPMNscript | Import | +| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bpmn:sequenceFlow` | never written: steps run top to bottom, `goto X` for a jump | imported; the printer rebuilds blocks from the flow graph and writes `goto` where no block fits; a route it cannot place is left out, warned on print (`droppedEdge`, `degradedSplit`) | +| `bpmn:conditionExpression`, and a conditional event definition's `bpmn:condition` | the condition of `if`, `while`, `do ... while`, a `parallel` branch, and of `condition(expr)` | imported; refused with a `language` attribute or an `xsi:type` other than `tFormalExpression` (`UnsupportedConditionExpressionError`) | +| `default` on a gateway | `else`, the fallback branch | imported as written (`mapDefaultingGateway`); on an exclusive gateway the shape check above can still refuse the gateway around it | +| `default` on an activity | `else`, the fallback branch | imported when it names one of the activity's own outgoing flows; dropped, warned (`unmappedConstruct`) otherwise | +| `name` on a sequence flow | not authored, derived from the condition | warned (`label`) when it differs from the derived label | +| a flow into or out of an event sub-process | not authored | refused, `BpmnParse.parseSequenceFlow` fails on it (`UnsupportedEventFeatureError`) | + +## Data + +| BPMN element | BPMNscript | Import | +| ------------------------------------------------------------------------ | --------------------------------------------------------------------- | ------------------------------------- | +| `bpmn:dataObject`, `bpmn:dataObjectReference`, `bpmn:dataStoreReference` | not authored; `var x: type` declares a variable and writes no element | dropped, warned (`unmappedConstruct`) | +| `bpmn:dataInputAssociation`, `bpmn:dataOutputAssociation` | not authored; `input` and `output` write `operaton:inputOutput` | dropped, warned (`unmappedConstruct`) | +| `bpmn:ioSpecification`, `bpmn:property` | not authored | dropped, warned (`unmappedConstruct`) | +| `bpmn:auditing`, `bpmn:monitoring` | not authored | dropped, warned (`unmappedConstruct`) | +| `bpmn:dataStore`, `bpmn:itemDefinition` at the root | not authored | dropped, warned (`unmappedConstruct`) | + +## Collaboration and root elements + +| BPMN element | BPMNscript | Import | +| ---------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bpmn:process`, one marked `isExecutable="true"` | `process X { }`, one per file | imported | +| a second executable `bpmn:process` | not authored | refused (`UnsupportedCollaborationError`) | +| a `bpmn:process` not marked executable beside the executable one | not authored | dropped, warned (`unmappedConstruct`) | +| a lone `bpmn:process` not marked executable | `process X { }` | imported as an executable process, warned (`unmappedConstruct`) | +| several `bpmn:process` elements, none marked executable | not authored | refused (`UnsupportedDocumentError`) | +| `bpmn:collaboration`, `bpmn:participant` | not authored | dropped, warned (`unmappedConstruct`), one warning per pool | +| `bpmn:messageFlow` | not authored | dropped, warned (`unmappedConstruct`), one warning per flow | +| `bpmn:laneSet`, `bpmn:lane` | not authored | dropped, warned (`lane`), one warning per lane | +| `bpmn:error`, `bpmn:escalation` | `error CODE(message: "...")`, `escalation CODE` in the process header | imported as declarations; refused on an error root with an `operaton:errorMessage` and no code, or two roots sharing a code with two messages (`UnsupportedEventFeatureError`); a root with no code warned (`unreferencedRoot`) | +| `bpmn:message`, `bpmn:signal` | synthesized from the names the events use | imported by reference; one nothing references warned (`unreferencedRoot`); two signals sharing a name refused (`UnsupportedEventFeatureError`) | +| `bpmn:category`, `bpmn:interface`, any other root | not authored | dropped, warned (`unmappedConstruct`) | +| `bpmn:import` | not authored | refused (`UnsupportedDocumentError`) | + +## Artifacts and diagram + +| BPMN element | BPMNscript | Import | +| ------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `bpmn:textAnnotation`, `bpmn:association`, `bpmn:group` | not authored | dropped, warned (`unmappedConstruct`) | +| `bpmn:documentation` | `documentation: "..."` on the process and on a step | imported at every position that reads a `name`; warned (`documentation`) on an event handler, a typed event, a sequence flow, and the roots | +| `bpmndi:BPMNDiagram` and everything under it | not authored, generated by auto-layout on compile | discarded without a warning | diff --git a/docs/decisions/0013-target-users-without-bpmn-knowledge.md b/docs/decisions/0013-target-users-without-bpmn-knowledge.md index efe4573..ba9eda5 100644 --- a/docs/decisions/0013-target-users-without-bpmn-knowledge.md +++ b/docs/decisions/0013-target-users-without-bpmn-knowledge.md @@ -54,3 +54,4 @@ Implicit sequence flow (ADR-0008) and synthesized structural ids (ADR-0010) alre Related decisions: ADR-0008 (structured, code-like grammar) settled the surface shape. ADR-0010 (deterministic structural ids) is the existing model for how a compiler-supplied default stays round-trippable. +The engine tuning keys (`asyncBefore`, `asyncAfter`, `exclusive`, `jobPriority`, `retryCycle`) and the listener block are engine-side settings outside the no-BPMN-knowledge promise: they name what Operaton does with a step, not a BPMN element, and a reader who never tunes the engine never writes them. diff --git a/docs/decisions/0024-event-triggers-on-start-end-and-throw.md b/docs/decisions/0024-event-triggers-on-start-end-and-throw.md index f550f93..44c0371 100644 --- a/docs/decisions/0024-event-triggers-on-start-end-and-throw.md +++ b/docs/decisions/0024-event-triggers-on-start-end-and-throw.md @@ -141,6 +141,7 @@ That last result is what shows an implementation-free message throw deploys and ## More Information +Superseded by ADR-0026 in the one consequence above recording a thrown message's send implementation as refused on import, which that surface now carries; the rest of this decision stands. ADR-0026 (task kinds on the authoring surface) supersedes the message-throw implementation refusal recorded above: Operaton reads that implementation off the `bpmn:messageEventDefinition`, so the same attributes on the event itself are inert. Amended by ADR-0034, which lifts the one-start-per-container rule for a process body and leaves it standing for a subprocess, a transaction, and an event handler. diff --git a/docs/decisions/0032-field-injection-as-a-parameter-direction.md b/docs/decisions/0032-field-injection-as-a-parameter-direction.md index 97dfab5..0ffb9bc 100644 --- a/docs/decisions/0032-field-injection-as-a-parameter-direction.md +++ b/docs/decisions/0032-field-injection-as-a-parameter-direction.md @@ -156,6 +156,8 @@ The validator errors on both shapes the same way, and the importer refuses both ## More Information +Amends ADR-0023, whose listener statement gains a brace block holding a field beside its bindings; the absence that decision recorded as deliberate covered a form, a parameter, and a nested listener, and a field is none of the three. + Related decisions: ADR-0006 (the IR's vendor-neutral naming, which a field's name and value pair and a form reference's key and binding pair both follow). ADR-0014 (the honest import contract behind every refusal and warning here). ADR-0021 (folding a fourth service-task binding into an existing keyword rather than adding one, the same reuse-over-reservation reasoning field injection follows for `IoParameter`). diff --git a/docs/decisions/0035-link-events-for-import-round-trip-symmetry.md b/docs/decisions/0035-link-events-for-import-round-trip-symmetry.md index 8d442b4..499319e 100644 --- a/docs/decisions/0035-link-events-for-import-round-trip-symmetry.md +++ b/docs/decisions/0035-link-events-for-import-round-trip-symmetry.md @@ -163,6 +163,8 @@ Amends ADR-0020, whose four-trigger scope and "no authored name" hold for the re Amends ADR-0025, whose event-based gateway refusal relied on `mapIntermediateCatchEvent` turning a link away wherever it appeared; that refusal now rests on the importer's rule that no flow enters a link catch. +Amends ADR-0016, whose terminality rule gains the one `emit` that ends its chain, `emit link`; the two printed words decide it, so terminality is still read off the text and never off position. + Related decisions: ADR-0014 (the honest import contract, under which a rewrite of what is on the wire is not an option). ADR-0016 (the terminality rule; `emit link` is the one `emit` that ends its chain, but the two printed words decide it, so terminality is still read off the text and never off position). ADR-0017 (the payload surfaces the link name reuses). diff --git a/docs/decisions/0036-elide-a-synthesized-end-only-at-its-block-tail.md b/docs/decisions/0036-elide-a-synthesized-end-only-at-its-block-tail.md index 132b44d..a4a7a9f 100644 --- a/docs/decisions/0036-elide-a-synthesized-end-only-at-its-block-tail.md +++ b/docs/decisions/0036-elide-a-synthesized-end-only-at-its-block-tail.md @@ -136,6 +136,7 @@ Extend `branchStaysInRegion` so a branch entry that terminates before the join, ## More Information Amends ADR-0014, whose warned-construct list said the statement carrying a synthesized start's or end's label is left out whole; that holds for a start, and for an end only at its block's tail. +Amends ADR-0009, whose guard clause now admits a bare authored terminal as its branch entry when the split's route is its only incoming flow, so the terminal prints inline where it printed as a `goto`; the dropped-jump marker stays as it was. Related decisions: ADR-0009 (the dropped-jump rule this decision leaves in place). ADR-0010 (the reserved-name error that names the fix, and the consequence bullet this decision makes precise). diff --git a/docs/decisions/0042-mail-and-shell-as-a-type-binding.md b/docs/decisions/0042-mail-and-shell-as-a-type-binding.md index ac507ec..9a973af 100644 --- a/docs/decisions/0042-mail-and-shell-as-a-type-binding.md +++ b/docs/decisions/0042-mail-and-shell-as-a-type-binding.md @@ -111,6 +111,8 @@ The IR variant is declared beside `ServiceTaskBinding` in `packages/transform/sr Amends ADR-0021, whose "exactly one of the four" binding count grows to five. Amends ADR-0032, whose field-binding rule grows from `class`/`delegate` to `class`/`delegate`/`type`. Amends ADR-0014, which narrows the `type` refusal to a value outside `external`, `mail`, and `shell`, and whose warned list gains a mail or shell task's shadowed attributes. +Amends ADR-0033, which states ADR-0032's field rule as a rule about the two binding words `class` and `delegate` at seven sites; the rule stays keyed on words alone, and `type` on a task is the third. +Amends ADR-0038, whose three extras stay with the `topic` binding: on a mail or shell `type` binding they refuse in a script and warn on import, since `parseExternalServiceTask` alone reads them. Amended by ADR-0048, under which `resultVariable` beside `type:` draws a warning that nothing writes the variable, rather than being accepted and ignored. diff --git a/docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md b/docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md index fbcb9b6..91835dc 100644 --- a/docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md +++ b/docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md @@ -98,6 +98,7 @@ The golden pair suites pass unchanged. The exact predicates and the minting scheme sit in `packages/transform/src/synthesize-ids.ts`; the name map is built by `printedNames` in `packages/transform/src/ir-to-dsl.ts`; the per-container check is `checkReservedNames` in `packages/language/src/bpmn-script-validator.ts`; the two import refusals go through `refuseUnspellableVariable` in `packages/transform/src/xml-to-ir.ts`. Amends ADR-0010, whose reserved-pattern list narrows to the prefixes above and the exact minted forms. +Amends ADR-0014, whose label bullet names the exact minted ids rather than a prefix, whose warned list gains the `renamedId` respelling, and whose refused list gains a form field id or an input/output parameter name the script cannot spell. Related decisions: ADR-0014 (the honest import contract behind the refusals and the warning). ADR-0036 (where an elided end prints under its id, which this decision leaves as it is). diff --git a/docs/decisions/0051-synthesize-variable-declarations-on-print.md b/docs/decisions/0051-synthesize-variable-declarations-on-print.md index 7366028..f31ae84 100644 --- a/docs/decisions/0051-synthesize-variable-declarations-on-print.md +++ b/docs/decisions/0051-synthesize-variable-declarations-on-print.md @@ -90,5 +90,8 @@ The two-pass table in `tests/round-trip-constructs.test.ts` requires an empty di The walk is `variableDecls` in `packages/transform/src/ir-to-dsl.ts`. The validator's positions are in `checkExpression` in `packages/language/src/bpmn-script-validator.ts` and its symbol table in `packages/language/src/variable-symbol-provider.ts`. +Amends ADR-0014, whose print hop now declares `var : any` for every variable the printed script reads bare and nothing else declares, so a decompiled script builds without an undeclared-variable warning. +Amends ADR-0027, whose collection declaration on print becomes one case of that rule rather than the only one. + Related decisions: ADR-0027 (the collection declaration this generalizes, and the seeded loop counters). ADR-0014 (the honest import contract the clean rebuild belongs to). diff --git a/docs/decisions/0052-loop-bodies-with-conditioned-exits.md b/docs/decisions/0052-loop-bodies-with-conditioned-exits.md index 4ec9c1e..4a4ad69 100644 --- a/docs/decisions/0052-loop-bodies-with-conditioned-exits.md +++ b/docs/decisions/0052-loop-bodies-with-conditioned-exits.md @@ -87,6 +87,7 @@ The predicate is `enclosingContinuation` in `packages/transform/src/ir-to-dsl.ts `emitRoutes` asks a further step between the clean join and the guard clause, `convergence`, which reads where the split's live routes come back together off the model's routes by forward reachability bounded by the stop node, and so answers a split whose branch can end and whose post-dominator therefore lies at the exit. Amends ADR-0009, whose catalog gains the enclosing continuation beside the clean join and the guard clause. +Amends ADR-0014, whose print hop now walks the route that stays inside a loop body inline and prints the leaving routes alone as jumps, for a split inside a loop body whose every route is conditioned. Related decisions: ADR-0009 (the catalog this rule extends). ADR-0036 (the inline terminal in a guard clause, whose region test the chain reuses). diff --git a/docs/decisions/0053-routes-off-a-step-and-jumps-with-conditions.md b/docs/decisions/0053-routes-off-a-step-and-jumps-with-conditions.md index 57d55ae..32e0fab 100644 --- a/docs/decisions/0053-routes-off-a-step-and-jumps-with-conditions.md +++ b/docs/decisions/0053-routes-off-a-step-and-jumps-with-conditions.md @@ -99,6 +99,7 @@ Beside it, one row pins the marker line and the weighed jumps of an inclusive fo The step's routing is `followLinear` in `packages/transform/src/ir-to-dsl.ts`; the fork block is `emitFork`, the jumps `emitJumps`, the empty-split report `emptySplitWarning`, the branch bookkeeping `emitBranch` and the check at the end of `Emitter.emit`, and the guard-clause entry `branchStaysInRegion`. Amends ADR-0009, whose catalog entry for a statement with more than one route out becomes the fork block, and whose degraded split keeps its conditions on the jumps under a marker line. +Amends ADR-0014, whose step-`default` bullet this rule closes by printing the step's routes as the fork block with the `default` as its `else`, and whose print-hop reports gain a degraded split's marker line, an empty gateway, a self-loop, and a jump across a branch border. Related decisions: ADR-0009 (the catalog this rule extends). ADR-0014 (the import contract, whose `default`-on-a-step clause this rule closes). diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 0000000..434facd --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,64 @@ +# Architecture decision records + +Each file in this directory records one design decision behind BPMNscript: the problem, the options weighed, the choice, and what it costs. +Every record follows the MADR shape in [adr-template.md](adr-template.md). +A later decision that changes an earlier one says so with an `Amends ADR-XXXX` line in its "More Information" section, and the earlier one carries the matching `Amended by` line, so the two link both ways. +A `Supersedes ADR-XXXX` line pairs with a `Superseded by` line the same way. + +| Number | Title | Status | +| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -------- | +| [0000](0000-use-markdown-architectural-decision-records.md) | Use Markdown Architectural Decision Records | accepted | +| [0001](0001-vscode-as-primary-ide-target.md) | Use VS Code as Primary IDE Target | accepted | +| [0002](0002-use-langium-as-language-workbench.md) | Use Langium as Language Workbench | accepted | +| [0003](0003-auto-layout-for-diagram-interchange.md) | Use Auto-layout for Diagram Interchange Data | accepted | +| [0004](0004-use-apache-2-license.md) | Use Apache 2.0 License | accepted | +| [0005](0005-use-mono-repo-structure.md) | Use Mono-repo Structure | accepted | +| [0006](0006-intermediate-representation-between-ast-and-bpmn.md) | Use an Intermediate Representation between the AST and BPMN XML | accepted | +| [0007](0007-operaton-moddle-extension-fork.md) | Fork the Camunda Moddle Extension as a Local Operaton Extension | accepted | +| [0008](0008-structured-grammar.md) | Use a Structured, Code-Like Grammar | accepted | +| [0009](0009-dominator-based-restructuring.md) | Use Dominator/Post-Dominator Analysis for IR-to-DSL Restructuring | accepted | +| [0010](0010-deterministic-structural-ids.md) | Use Deterministic Structural Ids for Synthesized BPMN Elements | accepted | +| [0011](0011-in-editor-conversion-sidebar.md) | In-Editor Conversion: Webview Sidebar, Two-Layer Architecture, and CJS Bundling | accepted | +| [0012](0012-embed-java-delegate-code-and-generate-operaton-application.md) | Embed Java Delegate Code and Generate the Operaton Application | rejected | +| [0013](0013-target-users-without-bpmn-knowledge.md) | Target Users Without BPMN Knowledge and Minimize Boilerplate | accepted | +| [0014](0014-honest-bpmn-import-contract.md) | Honest BPMN Import: Refuse Unsupported Constructs, Warn on Non-Semantic Drops | accepted | +| [0015](0015-subprocess-di-expansion-hint.md) | Author a Diagram-Interchange Expansion Hint for Sub-processes | accepted | +| [0016](0016-derived-event-root-elements.md) | Derive Event Root Elements From Usage: Syntactic Throw Terminality, Soft Trigger Words | accepted | +| [0017](0017-event-trigger-payload-surfaces.md) | Event Trigger Payloads: One Paren Slot, Validated Timer Particles, Deferred Conditional Narrowing, Name-Keyed Roots | accepted | +| [0018](0018-compensation-through-event-subprocesses.md) | Compensation Only as a Subprocess Undo Block: One Handler Form, No Targeted Throws | accepted | +| [0019](0019-boundary-events-attached-to-an-activity.md) | Boundary Events Attached to an Activity: Host Syntax, Trigger Scope, and a Self-Contained Body | accepted | +| [0020](0020-intermediate-catch-events.md) | Intermediate Catch Events: the `await` Keyword, a Synthesized Id, and a Four-Trigger Scope | accepted | +| [0021](0021-external-tasks-as-a-service-task-binding.md) | External Tasks as a Service Task Binding, Not a Separate Keyword | accepted | +| [0022](0022-engine-attributes-as-named-ir-fields.md) | Carry Operaton Engine Attributes as Named IR Fields | accepted | +| [0023](0023-listeners-on-the-attribute-block.md) | Listeners Reuse `on` Inside the Attribute Block, Not a Second Keyword | accepted | +| [0024](0024-event-triggers-on-start-end-and-throw.md) | Event Triggers at the Process Boundary: Four Start Kinds, `terminate` on `end`, and the Message End on `throw` | accepted | +| [0025](0025-authoring-inclusive-and-event-based-gateways.md) | Inclusive and Event-Based Gateways: Conditioned `parallel` Branches, a Multi-Branch `await`, and No Gateway Label | accepted | +| [0026](0026-task-kinds-on-the-authoring-surface.md) | Task Kinds on the Authoring Surface: `step`, `send`, `receive`, and `decide` | accepted | +| [0027](0027-repetition-on-the-authoring-surface.md) | Repetition on the Authoring Surface: the `for` Clause | accepted | +| [0028](0028-attempt-blocks-and-the-cancel-trigger.md) | Work That Can Be Given Up: the `attempt` Block and the `cancel` Trigger | accepted | +| [0029](0029-one-bracket-shape-for-every-element.md) | One bracket shape for every element | accepted | +| [0030](0030-error-and-escalation-codes-as-declared-names.md) | Error and escalation codes as declared, referenced names | accepted | +| [0031](0031-carry-bpmn-documentation.md) | Carry bpmn:documentation wherever a name is carried | accepted | +| [0032](0032-field-injection-as-a-parameter-direction.md) | Field Injection as a Third Parameter Direction, Form References on User Tasks Only | accepted | +| [0033](0033-call-activity-variable-mapping.md) | Carry a call activity's variable mapping under its own two setting keys | accepted | +| [0034](0034-multiple-start-events-on-a-process.md) | Multiple start events on a process | accepted | +| [0035](0035-link-events-for-import-round-trip-symmetry.md) | Link events for import round-trip symmetry | accepted | +| [0036](0036-elide-a-synthesized-end-only-at-its-block-tail.md) | Elide a synthesized end only at its block tail | accepted | +| [0037](0037-form-field-constraints-values-and-properties.md) | Form field constraints, values and properties | accepted | +| [0038](0038-external-task-extras-ride-the-topic-binding.md) | External task extras ride the topic binding | accepted | +| [0039](0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md) | Import BPMN resource assignment, and report the quantity attributes | accepted | +| [0040](0040-engine-settings-on-synthesized-gateways.md) | Engine settings on synthesized gateways | accepted | +| [0041](0041-per-run-job-settings-on-a-repetition.md) | Per-run job settings on a repetition | accepted | +| [0042](0042-mail-and-shell-as-a-type-binding.md) | Mail and shell tasks as a `type` binding | accepted | +| [0043](0043-result-variable-beside-an-expression-binding.md) | A result variable rides an expression binding, never a class or delegate one | accepted | +| [0044](0044-raw-templates-read-and-printed-with-string-escapes.md) | Read and print a raw template with the string escapes, either opener kept | accepted | +| [0045](0045-reserve-an-inclusive-fallback-only-where-the-engine-can-take-it.md) | Reserve an inclusive fallback only where the engine can take it | accepted | +| [0046](0046-listener-shapes-the-engine-deploys.md) | Listener shapes the engine deploys | accepted | +| [0047](0047-timer-job-settings-on-the-element-the-engine-reads.md) | Timer job settings on the element the engine reads | accepted | +| [0048](0048-mirror-the-engines-parse-time-rules-in-the-validator.md) | Mirror the engine's parse-time rules in the validator | accepted | +| [0049](0049-import-modeler-shaped-documents.md) | Import Modeler-shaped documents as the engine deploys them | accepted | +| [0050](0050-reserve-the-minted-ids-and-respell-unspellable-ones.md) | Reserve the minted start and end ids exactly, and respell an id the script cannot spell | accepted | +| [0051](0051-synthesize-variable-declarations-on-print.md) | The print declares every variable it reads bare, typed any | accepted | +| [0052](0052-loop-bodies-with-conditioned-exits.md) | A split inside a loop body continues at the loop head when a route stays inside | accepted | +| [0053](0053-routes-off-a-step-and-jumps-with-conditions.md) | A step's routes print as the fork the engine runs, and a degraded split keeps its conditions on its jumps | accepted | +| [0054](0054-import-tiers-follow-the-engine-parse.md) | Import tiers follow the engine's parse | accepted | diff --git a/docs/glossary.md b/docs/glossary.md index b4e083a..4c32099 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -11,6 +11,14 @@ Terms that show up across the READMEs, the ADRs, and the code. A `decide` step names a deployed table by its key and reads back what it returns; the `.dmn` artifact itself is authored and deployed outside this tool. - **Operaton**: the process engine this project targets. It loads a BPMN file and executes the workflow it describes. +- **Token**: BPMN's representation of a thread of execution inside a running process instance. + It sits on one flow node at a time and moves along sequence flows, splitting into several at a parallel gateway fork and merging back at its join. +- **Job**: what Operaton schedules for anything that runs asynchronously, such as an async continuation or a timer. + A job carries a priority and, on failure, works through a retry cycle before the engine gives up on it. +- **Java delegate**: a Java class implementing Operaton's `JavaDelegate` interface. + A `service X(class: "...")` step binds to one, and Operaton calls it synchronously in the engine's JVM. +- **External task and worker**: an external task is a service task the engine does not run itself; a `service X(topic: "...")` step publishes work under a topic instead. + A worker is the separate process that polls Operaton's REST API, fetches work under that topic, and reports it complete or failed. - **DSL**: domain-specific language. A small language built for one narrow purpose. BPMNscript is a DSL for writing BPMN processes as text. @@ -45,6 +53,8 @@ Terms that show up across the READMEs, the ADRs, and the code. - **moddle / bpmn-moddle**: the library that reads and writes BPMN XML as objects. A _moddle extension_ (here, `operaton-moddle.json`) teaches it about the extra `operaton:` attributes, and about the nested types they can carry, such as an input/output value or a listener binding. Declaring those nested shapes is what lets an import warning attribute a dropped element to the specific step that carried it, instead of reporting an anonymous loss ([ADR-0022](decisions/0022-engine-attributes-as-named-ir-fields.md)). +- **Round trip**: compiling a `.bpmnscript` file to BPMN and decompiling the result back to `.bpmnscript`, or the reverse. + Used to check what each hop keeps and what it warns about or drops. ## The repository diff --git a/examples/README.md b/examples/README.md index ae6bf6d..fcbc20d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,16 +2,14 @@ Deployment fixtures for running a compiled `.bpmn` file on Operaton. -One mode exists today: `spring-boot/`, with Operaton embedded in a Spring Boot application. -Two more are planned and have no fixture yet: an Operaton REST engine with external-task workers, and a standalone Operaton engine without Spring Boot. +One mode exists: `spring-boot/`, with Operaton embedded in a Spring Boot application. ## `spring-boot/` -The fixture runs Operaton 2.1.0 embedded in a Spring Boot 4.0.6 application on Java 17, exposing the Operaton REST API on port 8080. -It's packaged as a Docker image so the integration test harness can start and stop it programmatically. +Operaton 2.1.0 embedded in a Spring Boot 4.0.6 application on Java 17, exposing the Operaton REST API on port 8080. +It's packaged as a Docker image so the E2E harness can start and stop it. -Thirty DSL sources live under `spring-boot/processes/`, one per construct or construct combination. -Running `bpmns build` on any of them produces the deployable `.bpmn`. +The DSL sources under `spring-boot/processes/` cover one construct or construct combination each, and `bpmns build` on any of them produces the deployable `.bpmn`. | Source | Covers | | ----------------------- | -------------------------------------------------------------------------- | @@ -50,26 +48,14 @@ Running `bpmns build` on any of them produces the deployable `.bpmn`. ### Testcontainers harness -Sixteen E2E test files in `tests/e2e/` use [testcontainers-node](https://testcontainers.com/) to start the Docker image, deploy compiled BPMN over the Operaton REST API, start instances, and assert engine behavior: `invoice-approval`, `parallel-approval`, `loan-approval`, `loan-approval-kopp`, `boundary-events` (over `order-handling`), `awaiting-confirmation`, `engine-extensions`, `service-boundary-and-compensation` (over `charge-with-recovery` and `compensating-saga` in one container boot), `event-positions` (over `order-intake`, `stock-alert`, `scheduled-audit`, `support-ticket`, and `order-rework` in one container boot), `task-kinds`, `repetition` (over `batch-approval` and `empty-batch` in one container boot), `booking-attempt`, `branch-and-race` (over `order-dispatch`), `forms-and-external-tasks` (over `card-charge` and `plan-selection` in one container boot), `job-settings-and-mail` (over `nightly-report` and `outage-notice` in one container boot), and `deploy-sweep`. +The E2E tests in `tests/e2e/` use [testcontainers-node](https://testcontainers.com/) to start the Docker image, deploy compiled BPMN over the Operaton REST API, start instances, and assert what the engine does with what the compiled document only declares. +Each file names the example it drives, or the examples it deploys in one container boot. `deploy-sweep` deploys every golden under `tests/golden/`, every fixture under `tests/fixtures/`, and every example here in one container boot, once as written and once rebuilt from the tool's own print of it. -The compensation half of `service-boundary-and-compensation` asserts that the `emit compensation` its `on error` handler raises reaches the undo block of the subprocess that completed before the charge failed. -`booking-attempt` asserts the same machinery on the other route into it: when the block is given up, the engine must have run the undo block of the step that had already finished before the cancel handler opens. -`branch-and-race` asserts the two engine behaviors the compiled document cannot show on its own. -An inclusive join waits for exactly the branches the conditions opened, so completing one of two open tasks leaves the token at the join. -The first trigger of a race to fire cancels the wait the other branch was holding: the losing timer job disappears, and the instance's history holds the message catch and the `Handover` task behind it, with no row for the timer catch or `ChaseCarrier`. -`forms-and-external-tasks` asserts what the engine does with what a compiled document only declares: a worker fetching the charge sees its priority and its properties, a failure it reports ends through the declined handler when its message matches the mapping and stays on the topic for a retry when it does not, and the form service refuses a submission that leaves the required enum empty or names a value outside its list. -Its last case runs a user task assigned with `bpmn:humanPerformer` and `bpmn:potentialOwner` through the importer, deploys the re-exported document, and asserts the engine builds the assignee, candidate users, and candidate groups the source declared. -`job-settings-and-mail` asserts what a job setting makes the engine do, which the compiled document only declares. -With the join's job definition suspended, an async parallel join parks one job per arriving branch, and the instance completes once the job executor is let at them. -A repetition of three with `runAsyncBefore` has a job definition on the run and none on the repetition itself, so three jobs park, one per run. -A shell task running `echo` writes its output, newline included, and its exit code into the variables its fields name. -The mail task deploys because the fixture's `pom.xml` supplies the mail library the engine declares `provided`, and a mail task built without a body is refused at deployment with the engine's own message, which is the check the validator mirrors. - Docker tests run by default and are skipped only when `SKIP_DOCKER_TESTS=true`, which is what CI sets. ## Adding a new deployment mode 1. Create a subdirectory with a `README.md` and whatever runtime files it needs, such as a `pom.xml`, `Dockerfile`, or `docker-compose.yml`. 2. Implement the `FixtureAdapter` interface from `tests/fixtures/types.ts` in a new file under `tests/fixtures/adapters/`. -3. Register the mode in `tests/fixtures/index.ts` by extending the `startFixture` switch. +3. Have `startFixture` in `tests/fixtures/index.ts` pick the adapter, for example by a mode argument, since it starts the one adapter today. diff --git a/examples/spring-boot/README.md b/examples/spring-boot/README.md index 7b40774..3137773 100644 --- a/examples/spring-boot/README.md +++ b/examples/spring-boot/README.md @@ -1,6 +1,6 @@ # examples/spring-boot -Operaton 2.1.0 embedded in Spring Boot 4.0.6, the deployment fixture for the BPMNscript E2E test harness. +Operaton 2.1.0 embedded in Spring Boot 4.0.6, the deployment fixture for the BPMNscript end-to-end (E2E) test harness. The engine starts with nothing pre-deployed; the testcontainers harness deploys definitions at test time over REST. It doubles as a hands-on demo. @@ -49,7 +49,7 @@ The deployment and lifecycle calls the fixture adapter makes: | `GET` | `/engine-rest/task?processInstanceId={id}` | List active user tasks for a process instance | | `POST` | `/engine-rest/task/{id}/complete` | Complete a user task, optionally setting variables | -The assertions reach further into the same API through `tests/helpers/engine-rest.ts`: message correlation and signal broadcast, event subscriptions, a process instance's liveness, jobs, and the two history endpoints. +The assertions reach further into the same API through `tests/helpers/engine-rest.ts`. ## Admin credentials @@ -87,6 +87,7 @@ Re-run this whenever you edit or add a `.bpmnscript`. ### 3. Start the engine (demo profile) The `demo` profile turns on classpath auto-deployment and serves the web apps. +It needs a JDK 17 and Maven on `PATH`; the Docker variant below needs neither. From `examples/spring-boot/`, either run it with Maven: ```bash @@ -110,7 +111,7 @@ Every compiled process is deployed, so pick one in Cockpit to watch instances an ### The loan approval walkthrough -The classic WS-BPEL loan approval. +The loan approval example from the [WS-BPEL 2.0 specification](https://docs.oasis-open.org/wsbpel/2.0/wsbpel-v2.0.html). A request arrives with an `amount` and a `creditScore`. Loans under 10,000 are screened by an automated risk assessment, and a low-risk small loan is approved automatically. Everything else, meaning large loans and small loans that aren't low-risk, goes to a human "Approve loan" task. @@ -118,10 +119,7 @@ The outcome is recorded as `decision = ACCEPTED` or `REJECTED`. Its four service tasks are `JavaDelegate`s in [`src/main/java/com/example/loan/`](src/main/java/com/example/loan). 1. Start a request. - In Tasklist, choose Start process, then Loan Approval. - There's no start form, so use "Add a variable" to set two variables and then Start: - - `amount`, type `Long`, for example `5000` - - `creditScore`, type `Long`, for example `750` + In Tasklist, choose Start process, then Loan Approval, and fill in the start form: Loan amount (`amount`), for example `5000`, and Credit score (`creditScore`), for example `750`. 2. Watch the automatic path. With `amount = 5000` and `creditScore = 750` the risk assessment returns low risk, so the loan is approved with no human step and ends with `decision = ACCEPTED`. @@ -130,24 +128,21 @@ Its four service tasks are `JavaDelegate`s in [`src/main/java/com/example/loan/` 3. Watch the human path. Start another request with `amount = 25000`, or a small loan that fails the risk screen (`amount = 5000`, `creditScore = 400`, since the assessment marks scores below 600 as high risk). The instance stops at the "Approve loan" task, which appears under the `demo` user in Tasklist. - Open it and, on Complete, add one variable: - - `approved`, type `Boolean`, `true` to accept and `false` to reject - + Open it, tick "Approve the loan?" to accept or leave it unticked to reject, and Complete. The instance then records `ACCEPTED` or `REJECTED`. - Complete the task without setting `approved` and it defaults to rejected. In Cockpit, open the live diagram of a running instance to watch the token sit on "Approve loan" and move once you complete it. ### Variant: Kopp 2009 -`loan-approval-kopp` is the parallel-rating variant of that process, following Kopp et al. (2009). +`loan-approval-kopp` is the parallel-rating variant of that process, following Kopp, Martin, Wutke, and Leymann's ["The Difference Between Graph-Based and Block-Structured Business Process Modelling Languages"](https://doi.org/10.18417/emisa.4.1.1) (EMISA 4(1), 2009). The request is rated in parallel by two external bureaus and one internal service, and a low internal rating additionally routes through a human "Manual risk assessment". It accepts when both external bureaus rate low, or when the internal rating is low and the assessor agrees (`assessorRes = low`). Its delegates live in `src/main/java/com/example/loan/kopp/`. -Start it the same way, through Start process and Loan Approval (Kopp 2009), with `amount` and `creditScore` as `Long`. -The manual-assessment task edits the pre-seeded `assessorRes` string: set it to `low` to approve that path, leave it `high` to decline. -Some telling inputs: +Start it the same way, through Start process and Loan Approval (Kopp 2009), with `amount` and `creditScore` in the start form. +The manual-assessment task's form shows the pre-seeded `assessorRes`: set it to `low` to approve that path, leave it `high` to decline. +Inputs worth trying: - `creditScore = 750`, `amount = 5000`: all three rate low. Both externals rating low already grants acceptance, but the assessor task still appears because the internal rating is low, so complete it to let the instance finish. diff --git a/examples/spring-boot/processes/awaiting-confirmation.bpmnscript b/examples/spring-boot/processes/awaiting-confirmation.bpmnscript index 7230b68..9a82ce4 100644 --- a/examples/spring-boot/processes/awaiting-confirmation.bpmnscript +++ b/examples/spring-boot/processes/awaiting-confirmation.bpmnscript @@ -1,12 +1,6 @@ -// Awaiting Confirmation: a request goes out to an external system and the -// process blocks at an intermediate message catch until that system -// correlates a confirmation back. There is no user task in between: the -// token simply sits at the `await`, which is the property the deployment -// test drives directly over the REST API. -// -// The service tasks point at the generic delegate -// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java, so this -// process deploys and runs on the demo profile without additional Java classes. +// The token parks at the `await` until an outside system correlates the +// confirmation back. There is no user task in between, so only the message +// releases the instance. process awaiting-confirmation { start RequestSubmitted diff --git a/examples/spring-boot/processes/batch-approval.bpmnscript b/examples/spring-boot/processes/batch-approval.bpmnscript index 6cbef71..1e8d0f4 100644 --- a/examples/spring-boot/processes/batch-approval.bpmnscript +++ b/examples/spring-boot/processes/batch-approval.bpmnscript @@ -1,12 +1,5 @@ -// Batch approval: one process whose steps repeat, laid out so each form of -// repetition is observable over the REST API. Three approvals are offered at -// once, three sign-offs come one after another, a spot check stops as soon as -// two of its three runs are in, the log step runs its count out, and a review -// sub-process runs its task once per line of the list it maps in. -// -// The logging step points at the generic delegate -// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java, so this -// process deploys and runs on the demo profile without additional Java classes. +// Every repetition form in one process, each on a step whose runs are +// observable over the REST API. process batch-approval(label: "Batch approval") { start BatchReceived diff --git a/examples/spring-boot/processes/booking-attempt.bpmnscript b/examples/spring-boot/processes/booking-attempt.bpmnscript index 6b836b9..b2cb5ae 100644 --- a/examples/spring-boot/processes/booking-attempt.bpmnscript +++ b/examples/spring-boot/processes/booking-attempt.bpmnscript @@ -1,21 +1,10 @@ -// Seat booking as a block of work that can be given up. `attempt BookAndPay` -// holds the seat, charges the travel card and issues the ticket; a guard on -// the declined charge ends that path with -// `end BookingAbandoned cancel(label: "Give up the booking")`, and -// `on BookAndPay: cancel` catches it. Started with `chargeDeclined` set, the -// charge is declined and the escape task opens; started without it, the -// block completes and the booking goes to confirmation. +// A block of work that can be given up: started with `chargeDeclined` set, +// the cancel end inside the block fires and `on BookAndPay: cancel` catches it. // -// Giving up the block undoes the steps inside it that had already finished -// and carry an `on compensation` block, and it undoes them before the cancel -// handler opens. `HoldSeat` has one, so the seat goes back on sale. -// `ChargeCard` has none, so it stays completed and nothing reverses it. -// Nothing outside the block is undone either. -// -// Every service task points at the generic delegate -// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java, so -// this process deploys and runs on the demo profile without additional Java -// classes. +// Giving up the block undoes every finished step inside it that carries an +// `on compensation` block, before the cancel handler opens. `HoldSeat` has +// one, so the seat goes back on sale; `ChargeCard` has none, so it stays +// completed. Nothing outside the block is undone. process booking-attempt { var chargeDeclined: boolean diff --git a/examples/spring-boot/processes/card-charge.bpmnscript b/examples/spring-boot/processes/card-charge.bpmnscript index c988adf..44d5db3 100644 --- a/examples/spring-boot/processes/card-charge.bpmnscript +++ b/examples/spring-boot/processes/card-charge.bpmnscript @@ -1,12 +1,9 @@ -// Card charge handed to an external worker that polls the "charge-card" -// topic. The worker fetching it sees the task's priority and its two -// properties beside the topic, and a failure it reports with the message -// "declined" is raised as the declared error, which the boundary handler -// catches and hands to a review task. A failure with any other message -// leaves the task on the topic for a retry. +// A failure the worker reports with the message "declined" is raised as the +// declared error and caught by the boundary handler; a failure with any other +// message leaves the task on the topic for a retry. // -// The fixture's worker polls "print-label" alone, so an instance of this -// process waits until something fetches "charge-card" over the REST API. +// The fixture's worker polls "print-label" alone, so an instance waits until +// something fetches "charge-card" over the REST API. process card-charge { error PAYMENT_DECLINED(message: "The card issuer declined the charge") diff --git a/examples/spring-boot/processes/charge-with-recovery.bpmnscript b/examples/spring-boot/processes/charge-with-recovery.bpmnscript index 21b085b..9fae58b 100644 --- a/examples/spring-boot/processes/charge-with-recovery.bpmnscript +++ b/examples/spring-boot/processes/charge-with-recovery.bpmnscript @@ -1,17 +1,6 @@ -// Card charge with an error-boundary recovery path. The charge is a service -// task bound to a delegate that conditionally fails; an interrupting error -// boundary on that task catches the failure and routes to a review task -// instead of letting the process fail outright. Started with the -// `failCharge` process variable set, the charge fails and the review task -// opens; started without it, the charge succeeds and the process confirms -// normally. -// -// The confirmation step points at the generic delegate -// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java; the -// charge step points at -// examples/spring-boot/src/main/java/com/example/demo/FailingChargeDelegate.java, -// so this process deploys and runs on the demo profile without additional -// Java classes. +// Started with `failCharge` set, the charge delegate throws the declared +// error and the boundary handler opens the review task; started without it, +// the charge goes through. process charge-with-recovery { error CHARGE_FAILED(message: "The payment charge could not be completed") diff --git a/examples/spring-boot/processes/compensating-saga.bpmnscript b/examples/spring-boot/processes/compensating-saga.bpmnscript index 110126c..79a69d6 100644 --- a/examples/spring-boot/processes/compensating-saga.bpmnscript +++ b/examples/spring-boot/processes/compensating-saga.bpmnscript @@ -1,17 +1,6 @@ -// Seat-booking saga with a runnable compensation trigger. A subprocess -// reserves a seat and owns an `on compensation` undo block that releases it; -// once the reservation completes, a service task charges the customer's card -// through the same conditional delegate `charge-with-recovery` uses. Started -// with `failCharge` set, the charge raises the process's declared error, the -// process-level `on error` handler runs as an event-subprocess, and its -// `emit compensation` should unwind the completed reservation. -// -// The service tasks point at the generic delegate -// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java and -// the conditional -// examples/spring-boot/src/main/java/com/example/demo/FailingChargeDelegate.java, -// so this process deploys and runs on the demo profile without additional -// Java classes. +// Started with `failCharge` set, the charge delegate throws the declared +// error, and the `emit compensation` in the process-level handler unwinds the +// reservation subprocess that had already completed. process compensating-saga { error CHARGE_FAILED(message: "The payment charge could not be completed") diff --git a/examples/spring-boot/processes/empty-batch.bpmnscript b/examples/spring-boot/processes/empty-batch.bpmnscript index e446728..9a57502 100644 --- a/examples/spring-boot/processes/empty-batch.bpmnscript +++ b/examples/spring-boot/processes/empty-batch.bpmnscript @@ -1,11 +1,5 @@ -// An empty batch: the one repetition count that is not a count of runs. The -// engine resolves the cardinality on entering the loop, so a count of zero -// leaves the loop without ever running the step inside it and the token walks -// straight to the end. Nothing is logged and no run of the step is recorded. -// -// The logging step points at the generic delegate -// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java, so this -// process deploys and runs on the demo profile without additional Java classes. +// The engine resolves the cardinality on entering the loop, so a count of +// zero walks the token past the step without running it once. process empty-batch(label: "Empty batch") { start BatchOpened diff --git a/examples/spring-boot/processes/external-task.bpmnscript b/examples/spring-boot/processes/external-task.bpmnscript index da31917..6f87322 100644 --- a/examples/spring-boot/processes/external-task.bpmnscript +++ b/examples/spring-boot/processes/external-task.bpmnscript @@ -1,5 +1,5 @@ -// Hand the shipment label print-out off to an external worker that polls the -// "print-label" topic, instead of running the work in the Operaton process. +// The fixture's own worker polls "print-label", so the instance completes +// without an outside process. process shipment-label { start OrderPlaced diff --git a/examples/spring-boot/processes/invoice-approval.bpmnscript b/examples/spring-boot/processes/invoice-approval.bpmnscript index 5492af3..50b09cb 100644 --- a/examples/spring-boot/processes/invoice-approval.bpmnscript +++ b/examples/spring-boot/processes/invoice-approval.bpmnscript @@ -1,10 +1,4 @@ -// Invoice Approval: the canonical BPMNscript example, deployed by the -// spring-boot end-to-end test. This is also the file the README and the docs -// use to show what the language looks like. -// -// The service task's class name below must stay in sync with the Java delegate at -// examples/spring-boot/src/main/java/com/example/invoice/AutoApproveDelegate.java. -// That is the class Operaton loads and runs when the auto-approve branch fires. +// The canonical example: the README shows the language with this file. process invoice-approval { start ReviewStart { diff --git a/examples/spring-boot/processes/loan-approval-kopp.bpmnscript b/examples/spring-boot/processes/loan-approval-kopp.bpmnscript index 8a24a5f..6ce74ce 100644 --- a/examples/spring-boot/processes/loan-approval-kopp.bpmnscript +++ b/examples/spring-boot/processes/loan-approval-kopp.bpmnscript @@ -13,13 +13,10 @@ process loan-approval-kopp(label: "Loan Approval (Kopp 2009)") { } } - // Conservative default: the manual assessment counts as high risk unless the - // assessor explicitly lowers it (and so an undefined assessorRes never reaches - // the final gateway). + // Seeds `assessorRes = high`, so the assessment counts as high risk unless + // the assessor lowers it. service InitRequest(label: "Initialize request", class: "com.example.loan.kopp.InitDelegate") - // The request is rated in parallel by two external bureaus and one internal - // service; the internal low-risk path additionally needs a human assessor. parallel { { service CheckExternalS1(label: "Check external rating S1", class: "com.example.loan.kopp.ExternalRatingS1Delegate") @@ -39,8 +36,6 @@ process loan-approval-kopp(label: "Loan Approval (Kopp 2009)") { } } - // Conservative accept: both external bureaus low, or internal low AND the - // human assessor agrees. if ((extRes1 == "low" && extRes2 == "low") || (intRes == "low" && assessorRes == "low")) { service AcceptLoan(label: "Accept loan", class: "com.example.loan.kopp.AcceptLoanDelegate") } else { diff --git a/examples/spring-boot/processes/loan-approval.bpmnscript b/examples/spring-boot/processes/loan-approval.bpmnscript index 9171cf0..0f58453 100644 --- a/examples/spring-boot/processes/loan-approval.bpmnscript +++ b/examples/spring-boot/processes/loan-approval.bpmnscript @@ -20,13 +20,10 @@ process loan-approval( } } - // Small loans are screened by an automated risk assessment. if (amount < 10000) { service AssessRisk(label: "Assess risk", class: "com.example.loan.RiskAssessmentDelegate") } - // A low-risk small loan is approved automatically; everything else - // (large loans, or small loans that are not low-risk) needs a human decision. if (amount < 10000 && risk == "low") { service AutoApprove(label: "Auto-approve", class: "com.example.loan.AutoApproveDelegate") } else { diff --git a/examples/spring-boot/processes/nightly-report.bpmnscript b/examples/spring-boot/processes/nightly-report.bpmnscript index 76f017e..3113cad 100644 --- a/examples/spring-boot/processes/nightly-report.bpmnscript +++ b/examples/spring-boot/processes/nightly-report.bpmnscript @@ -1,14 +1,8 @@ -// Nightly report: two job settings and a shell task, three things an XML -// comparison cannot show the engine doing. The two gather steps run on parallel branches -// and their join carries `joinAsyncBefore`, so each arriving token parks a job -// at the join until the job executor runs it. The count step repeats three -// times with `runAsyncBefore`, so each run parks a job of its own rather than -// one job around the repetition. The last step is a shell task the engine runs -// itself: `echo report-host`, whose output and exit code land in the two -// variables the fields name. -// -// The shell command is `echo`, which every image the fixture builds on ships, -// so the process runs on the demo profile without additional Java classes. +// Two job settings and a shell task, which compile to XML that shows nothing +// of what the engine does. `joinAsyncBefore` parks one job per token arriving +// at the join; `runAsyncBefore` on a repetition parks one job per run, none +// around the repetition; the shell task writes `echo`'s output and exit code +// into the variables its fields name. process nightly-report(label: "Nightly report") { start NightlyRun diff --git a/examples/spring-boot/processes/order-dispatch.bpmnscript b/examples/spring-boot/processes/order-dispatch.bpmnscript index 002bdc8..4ccb7e2 100644 --- a/examples/spring-boot/processes/order-dispatch.bpmnscript +++ b/examples/spring-boot/processes/order-dispatch.bpmnscript @@ -1,20 +1,9 @@ -// Order dispatch: an inclusive fork whose branches are picked by the order -// amount, then a race between an outside confirmation and a chase timer. -// -// The fork carries three branches. A large order is audited, any order with a -// positive amount is recorded, and the `else` branch catches the orders no -// condition claims. Both conditioned branches can hold at once, so the number -// of tasks the fork opens, and the join's willingness to wait for exactly -// those branches, follow from the amount the instance starts with. Both -// branches are conditioned on purpose: an unconditioned branch of an inclusive -// fork is always taken, which would leave the fallback nothing to pick up. -// -// The `await` block is a race: the engine subscribes to the message and parks -// the timer job at the same moment, and the first of the two to fire cancels -// the other. Exactly one of `Handover` and `ChaseCarrier` can ever be offered. -// -// Every step is a user task, so the process needs no Java class of its own and -// every state it reaches is observable over the REST API. +// An inclusive fork and a race. The join waits for exactly the branches the +// conditions opened, and both branches carry a condition on purpose: an +// unconditioned branch of an inclusive fork is always taken, which would leave +// the `else` nothing to pick up. In the `await`, the first trigger to fire +// cancels the other, so only one of `Handover` and `ChaseCarrier` is ever +// offered. process order-dispatch(label: "Order dispatch") { var amount: number diff --git a/examples/spring-boot/processes/order-handling.bpmnscript b/examples/spring-boot/processes/order-handling.bpmnscript index 2bffcb0..6363f6f 100644 --- a/examples/spring-boot/processes/order-handling.bpmnscript +++ b/examples/spring-boot/processes/order-handling.bpmnscript @@ -1,21 +1,3 @@ -// Order handling with an embedded sub-process. The two payment steps are -// grouped into a `subprocess` that Operaton Cockpit renders as an expanded -// box with its children inside. -// -// Two activities also carry attached boundary events. The review task has -// three: a non-interrupting timer that sends a nudge when review is taking -// too long without blocking it, an interrupting message that lets an external -// system confirm the order was already approved and skip straight to payment, -// and a non-interrupting message that a supervisor sends to ask for a status -// note while the review stays open. The payment subprocess carries an -// interrupting error boundary that hands a declined card to a -// customer-service agent, and a non-interrupting escalation boundary that -// pulls a manager into checking a large payment while capture keeps going. -// -// The service tasks point at the generic delegate -// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java, so this -// process deploys and runs on the demo profile without additional Java classes. - process order-handling { error PAYMENT_DECLINED(message: "The card could not be charged") escalation LARGE_PAYMENT_FLAGGED diff --git a/examples/spring-boot/processes/order-intake.bpmnscript b/examples/spring-boot/processes/order-intake.bpmnscript index 358b836..db77d7b 100644 --- a/examples/spring-boot/processes/order-intake.bpmnscript +++ b/examples/spring-boot/processes/order-intake.bpmnscript @@ -1,7 +1,5 @@ -// The order arrives as a correlated message rather than from a caller, the -// warehouse is told mid-flow, and the acknowledgment ends the path. Neither -// thrown message carries an implementation, so the engine passes the token -// straight through both. +// Neither thrown message carries an implementation, so the engine passes the +// token straight through both. process order-intake { start OrderReceived message("OrderReceived", label: "An order arrives") diff --git a/examples/spring-boot/processes/order-recovery.bpmnscript b/examples/spring-boot/processes/order-recovery.bpmnscript index 5388755..00d8b2b 100644 --- a/examples/spring-boot/processes/order-recovery.bpmnscript +++ b/examples/spring-boot/processes/order-recovery.bpmnscript @@ -1,14 +1,3 @@ -// Order handling with an error handler and an escalation. The payment and -// shipping steps are grouped into a `subprocess`; if a step fails it raises the -// PAYMENT_FAILED error, which the interrupting `on error` handler catches to -// cancel the order. A slow warehouse emits the SLOW_FULFILLMENT escalation, -// which the non-interrupting `alongside` handler picks up; a supervisor is -// notified while the main flow keeps running. -// -// The service tasks point at the generic delegate -// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java, so this -// process deploys and runs on the demo profile without additional Java classes. - process order-recovery { error PAYMENT_FAILED(message: "The card could not be charged") escalation SLOW_FULFILLMENT diff --git a/examples/spring-boot/processes/order-reminder.bpmnscript b/examples/spring-boot/processes/order-reminder.bpmnscript index 56b77e1..b193489 100644 --- a/examples/spring-boot/processes/order-reminder.bpmnscript +++ b/examples/spring-boot/processes/order-reminder.bpmnscript @@ -1,14 +1,3 @@ -// Order handling with a timer reminder and a message handler. The fulfillment -// steps are grouped into a `subprocess`; while they run, a non-interrupting -// `on timer("PT1H", alongside)` handler fires an hour in to chase the -// warehouse without stopping the main flow (the reminder pattern). A process- -// level `on message("OrderCancelled")` handler catches a cancellation the engine -// correlates from outside and rolls the order back. -// -// The service tasks point at the generic delegate -// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java, so this -// process deploys and runs on the demo profile without additional Java classes. - process order-reminder { start OrderPlaced user ReviewOrder(label: "Review order", assignee: "demo") diff --git a/examples/spring-boot/processes/order-rework.bpmnscript b/examples/spring-boot/processes/order-rework.bpmnscript index 4b0fed7..4f71f8c 100644 --- a/examples/spring-boot/processes/order-rework.bpmnscript +++ b/examples/spring-boot/processes/order-rework.bpmnscript @@ -1,10 +1,5 @@ -// Order Rework: a rework loop drawn with BPMN's own link events rather than -// a `goto`. Rejecting the review sends the token off at the throw; it -// reappears at the catch with no flow drawn between the two, then loops back -// into Review by name once the fix is done. -// -// Both steps are user tasks assigned to "demo", so this process deploys and -// runs on the demo profile without additional Java classes. +// The link pair jumps from the throw to the catch with no flow drawn between +// them; the `goto` closes the loop by name. process order-rework { var approved: boolean diff --git a/examples/spring-boot/processes/outage-notice.bpmnscript b/examples/spring-boot/processes/outage-notice.bpmnscript index c81e6bb..458f29f 100644 --- a/examples/spring-boot/processes/outage-notice.bpmnscript +++ b/examples/spring-boot/processes/outage-notice.bpmnscript @@ -1,7 +1,6 @@ -// Outage notice: one mail task the engine sends itself. The harness and the -// demo profile deploy this process and never start it, since sending needs an -// SMTP server neither runs. Deploying is the proof: the engine builds the mail -// behaviour at parse time and refuses a task missing a recipient or a body. +// Deployed and never started: sending needs an SMTP server the fixture does +// not run. Deploying alone proves the engine builds the mail behaviour at +// parse time, where it refuses a task missing a recipient or a body. process outage-notice(label: "Outage notice") { start OutageDetected diff --git a/examples/spring-boot/processes/parallel-approval.bpmnscript b/examples/spring-boot/processes/parallel-approval.bpmnscript index ee42c53..1b31e3b 100644 --- a/examples/spring-boot/processes/parallel-approval.bpmnscript +++ b/examples/spring-boot/processes/parallel-approval.bpmnscript @@ -1,12 +1,3 @@ -// Parallel Approval: a parallel-gateway example deployed by the -// spring-boot engine end-to-end test. Two user tasks run concurrently on -// independent parallel branches; the process ends only after both complete. -// -// This exercises the parallel-gateway desugaring end-to-end: the `parallel -// { { } { } }` construct produces a parallelGateway fork/join pair in the -// BPMN XML, and Operaton's AND-split/AND-join semantics are verified at the -// engine level by `tests/e2e/parallel-approval.test.ts`. - process parallel-approval { start ParallelStart parallel { diff --git a/examples/spring-boot/processes/plan-selection.bpmnscript b/examples/spring-boot/processes/plan-selection.bpmnscript index 97fb7b6..c55e80c 100644 --- a/examples/spring-boot/processes/plan-selection.bpmnscript +++ b/examples/spring-boot/processes/plan-selection.bpmnscript @@ -1,6 +1,5 @@ -// A task form Operaton Tasklist renders from the field list: a required -// enum the engine refuses to submit without a value or with one outside its -// list, and a free-text note bounded in length. +// The engine's form service refuses a submission that leaves the required +// enum empty or names a value outside its list. process plan-selection { start SignedUp diff --git a/examples/spring-boot/processes/purchasing.bpmnscript b/examples/spring-boot/processes/purchasing.bpmnscript index c47e987..85420f7 100644 --- a/examples/spring-boot/processes/purchasing.bpmnscript +++ b/examples/spring-boot/processes/purchasing.bpmnscript @@ -1,9 +1,5 @@ -// Purchasing: starts a purchase request and delegates the invoice sign-off -// to the existing `invoice-approval` process (see invoice-approval.bpmnscript) -// through a `call` activity, passing the order amount in and reading the -// auto-approval flag (set by that process when the invoice clears without a -// senior sign-off) back out. Deploy alongside invoice-approval.bpmnscript so -// the engine can resolve the call by process id. +// `autoApproved` is set by the called process's auto-approve delegate. Deploy +// `invoice-approval` alongside, or the engine cannot resolve the call. process purchasing { start OrderPlaced { diff --git a/examples/spring-boot/processes/scheduled-audit.bpmnscript b/examples/spring-boot/processes/scheduled-audit.bpmnscript index 6c8d9ae..d080555 100644 --- a/examples/spring-boot/processes/scheduled-audit.bpmnscript +++ b/examples/spring-boot/processes/scheduled-audit.bpmnscript @@ -1,6 +1,5 @@ -// The timer definition parks a job at deployment. The date is far enough out -// that the engine's own clock never reaches it, so the test fires the job by -// hand and the assertion never depends on wall time. +// The timer start parks a job at deployment. The date is far enough out that +// the clock never reaches it, so the E2E test fires the job by hand. process scheduled-audit { start AuditWindowOpens timer(at: "2099-01-01T00:00:00", label: "The audit window opens") diff --git a/examples/spring-boot/processes/script-task.bpmnscript b/examples/spring-boot/processes/script-task.bpmnscript index 705df0b..591585e 100644 --- a/examples/spring-boot/processes/script-task.bpmnscript +++ b/examples/spring-boot/processes/script-task.bpmnscript @@ -1,7 +1,3 @@ -// Compute a discount amount inline with a short Groovy snippet: no external -// delegate needed for such a small calculation. The start form supplies the -// order amount when the process is launched from Tasklist. - process order-discount { start OrderPlaced { form { diff --git a/examples/spring-boot/processes/service-delegate.bpmnscript b/examples/spring-boot/processes/service-delegate.bpmnscript index 26ad761..95fe61d 100644 --- a/examples/spring-boot/processes/service-delegate.bpmnscript +++ b/examples/spring-boot/processes/service-delegate.bpmnscript @@ -1,5 +1,4 @@ -// Charge the customer's payment method through a Spring-managed delegate -// bean, referenced by bean name rather than by fully-qualified Java class. +// `chargeService` is the bean name `ChargeService` registers under. process payment-charge { start OrderPlaced diff --git a/examples/spring-boot/processes/service-expression.bpmnscript b/examples/spring-boot/processes/service-expression.bpmnscript index 3ec9c49..db580cc 100644 --- a/examples/spring-boot/processes/service-expression.bpmnscript +++ b/examples/spring-boot/processes/service-expression.bpmnscript @@ -1,6 +1,4 @@ -// Compute a shipping quote through a JUEL expression evaluated by Operaton -// directly, rather than a Java delegate class. The start form supplies the -// order reference passed to the bean. +// `shippingBean` is the bean name `ShippingBean` registers under. process shipping-quote { start OrderPlaced { diff --git a/examples/spring-boot/processes/stock-alert.bpmnscript b/examples/spring-boot/processes/stock-alert.bpmnscript index 3eaa003..d6513b1 100644 --- a/examples/spring-boot/processes/stock-alert.bpmnscript +++ b/examples/spring-boot/processes/stock-alert.bpmnscript @@ -1,6 +1,5 @@ -// A broadcast starts the process and two branches run at once. Finishing the -// escalation reaches a terminate end, which stops every path, including the -// reorder task still sitting in the task list. +// The terminate end stops every path, including the reorder task still +// sitting in the task list. process stock-alert { start StockRunningLow signal("StockRunningLow", label: "Stock is running low") diff --git a/examples/spring-boot/processes/support-ticket.bpmnscript b/examples/spring-boot/processes/support-ticket.bpmnscript index bcd5d1d..6e77297 100644 --- a/examples/spring-boot/processes/support-ticket.bpmnscript +++ b/examples/spring-boot/processes/support-ticket.bpmnscript @@ -1,7 +1,3 @@ -// A ticket enters two ways: an agent opens it directly, or it arrives by a -// correlated message before any instance exists. Both starts lead into the -// same triage step, so the entry chosen never changes what happens next. - process support-ticket { start ByAgent(label: "An agent opens a ticket") start ByEmail message("TicketEmailed", label: "A ticket arrives by email") diff --git a/examples/spring-boot/processes/task-kinds.bpmnscript b/examples/spring-boot/processes/task-kinds.bpmnscript index 9cea48c..9727e02 100644 --- a/examples/spring-boot/processes/task-kinds.bpmnscript +++ b/examples/spring-boot/processes/task-kinds.bpmnscript @@ -1,15 +1,7 @@ -// Task kinds: one process carrying the four activity statements that are not a -// user, service or script task, laid out so each one's engine behavior is -// observable over the REST API. The `step` is a plain activity the engine -// automates nothing for and walks straight past; the `send` and the `decide` -// each invoke their binding and hand the token on; the `receive` is a wait -// state that only a correlated message releases; the `user` task afterwards -// makes the released instance visible. -// -// The send and decision steps point at the generic delegate -// examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java. The -// decision step binds to code rather than a decision table so the process -// deploys and runs on the demo profile with no DMN artifact. +// The engine walks straight past the `step`, runs the `send` and `decide` +// bindings, and parks at the `receive` until a message is correlated; the user +// task after it makes the released instance visible. The `decide` binds to a +// class rather than a decision table, so no DMN artifact is needed. process task-kinds(label: "Task kinds") { start OrderPlaced diff --git a/examples/spring-boot/src/main/java/com/example/demo/FailingChargeDelegate.java b/examples/spring-boot/src/main/java/com/example/demo/FailingChargeDelegate.java index b9b9dec..dd62b27 100644 --- a/examples/spring-boot/src/main/java/com/example/demo/FailingChargeDelegate.java +++ b/examples/spring-boot/src/main/java/com/example/demo/FailingChargeDelegate.java @@ -7,11 +7,8 @@ import org.slf4j.LoggerFactory; /** - * Charges a payment, conditionally failing: when the process variable - * {@code failCharge} is {@code true}, it raises a BPMN error with code - * {@code CHARGE_FAILED} instead of completing; otherwise it logs and - * continues like {@link LogDelegate}. The boolean gate lets one deployment - * drive both a failing run and a clean pass-through run. + * The {@code failCharge} process variable lets one deployment drive both a + * failing and a clean run. */ public class FailingChargeDelegate implements JavaDelegate { diff --git a/examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java b/examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java index 85eeadf..2ec0ed5 100644 --- a/examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java +++ b/examples/spring-boot/src/main/java/com/example/demo/LogDelegate.java @@ -7,12 +7,10 @@ import org.slf4j.LoggerFactory; /** - * Generic delegate for any service task that just needs to log and continue. - * - * A task that injects a {@code marker} field also gets - * {@code :execute:} appended to the {@link MarkerLog}, so - * what the engine set on the delegate is readable from outside the JVM. A task - * that injects none writes no variable at all. + * The generic delegate: logs and continues. The engine injects {@code marker} + * from the task's {@code field}; when set, its value goes into the + * {@link MarkerLog} so what the engine set on the delegate is readable from + * outside the JVM. */ public class LogDelegate implements JavaDelegate { diff --git a/examples/spring-boot/src/main/java/com/example/demo/MarkerLog.java b/examples/spring-boot/src/main/java/com/example/demo/MarkerLog.java index 5abad6b..9947caa 100644 --- a/examples/spring-boot/src/main/java/com/example/demo/MarkerLog.java +++ b/examples/spring-boot/src/main/java/com/example/demo/MarkerLog.java @@ -4,9 +4,8 @@ import org.operaton.bpm.engine.delegate.VariableScope; /** - * The ordered trace of engine callbacks a process instance leaves behind, held - * in one comma separated process variable so a single read shows what the - * engine invoked and in which order. + * The ordered trace of engine callbacks, one comma separated process variable + * so a single read shows what ran and in which order. */ final class MarkerLog { @@ -15,20 +14,16 @@ final class MarkerLog { private MarkerLog() { } - /** Appends one marker, starting the record when nothing has written yet. */ static void append(VariableScope scope, String marker) { Object recorded = scope.getVariable(VARIABLE); scope.setVariable(VARIABLE, recorded == null ? marker : recorded + "," + marker); } /** - * The value of an injected field as a trailing marker segment, empty when - * the binding carried no field. - * - * The field is declared as an {@link Expression} rather than as a - * {@code String} because the engine hands both value forms over the same - * slot: a literal arrives as a fixed value and a {@code ${...}} body as a - * JUEL expression, and a field of any other type fails the deployment. + * The injected field is an {@link Expression} rather than a {@code String} + * because the engine hands a literal and a {@code ${...}} body over the + * same slot, and refuses any other field type when it first instantiates + * the delegate. */ static String injected(Expression field, VariableScope scope) { return field == null ? "" : ":" + field.getValue(scope); diff --git a/examples/spring-boot/src/main/java/com/example/demo/RecordingExecutionListener.java b/examples/spring-boot/src/main/java/com/example/demo/RecordingExecutionListener.java index 0558cd1..eec0d7f 100644 --- a/examples/spring-boot/src/main/java/com/example/demo/RecordingExecutionListener.java +++ b/examples/spring-boot/src/main/java/com/example/demo/RecordingExecutionListener.java @@ -5,14 +5,8 @@ import org.operaton.bpm.engine.delegate.Expression; /** - * Execution listener that leaves a trace of itself in the process instance. - * - * Every invocation appends {@code :} to the - * {@link MarkerLog}, so registering the same class on both the start and the - * end of an activity yields two distinct markers. A listener carrying an - * injected {@code marker} field appends its value as a third segment, so the - * record shows both that the listener ran and what the engine set on it before - * it did. + * Records {@code :} and the injected {@code marker}, if + * any, in the {@link MarkerLog}. */ public class RecordingExecutionListener implements ExecutionListener { diff --git a/examples/spring-boot/src/main/java/com/example/demo/RecordingTaskListener.java b/examples/spring-boot/src/main/java/com/example/demo/RecordingTaskListener.java index 2ed3aae..b549673 100644 --- a/examples/spring-boot/src/main/java/com/example/demo/RecordingTaskListener.java +++ b/examples/spring-boot/src/main/java/com/example/demo/RecordingTaskListener.java @@ -5,12 +5,9 @@ import org.operaton.bpm.engine.delegate.TaskListener; /** - * Task listener that leaves a trace of itself in the process instance. - * - * It appends {@code :} to the same - * {@link MarkerLog} {@link RecordingExecutionListener} writes, so execution and - * task events share one ordered record, and appends the value of an injected - * {@code marker} field where one is set. + * Records {@code :} and the injected + * {@code marker}, if any, in the same {@link MarkerLog} the execution listener + * writes, so both share one ordered record. */ public class RecordingTaskListener implements TaskListener { diff --git a/examples/spring-boot/src/main/java/com/example/invoice/ChargeService.java b/examples/spring-boot/src/main/java/com/example/invoice/ChargeService.java index 56c294e..0a4a3df 100644 --- a/examples/spring-boot/src/main/java/com/example/invoice/ChargeService.java +++ b/examples/spring-boot/src/main/java/com/example/invoice/ChargeService.java @@ -7,9 +7,8 @@ import org.springframework.stereotype.Component; /** - * Bean referenced by the payment-charge process via - * {@code delegate = "${chargeService}"} (operaton:delegateExpression). The bean - * name "chargeService" is what the delegate expression resolves against. + * The {@code ${chargeService}} delegate expression in + * {@code service-delegate.bpmnscript} resolves to this bean by name. */ @Component("chargeService") public class ChargeService implements JavaDelegate { diff --git a/examples/spring-boot/src/main/java/com/example/invoice/PrintLabelWorker.java b/examples/spring-boot/src/main/java/com/example/invoice/PrintLabelWorker.java index 7ed7f09..41c9d36 100644 --- a/examples/spring-boot/src/main/java/com/example/invoice/PrintLabelWorker.java +++ b/examples/spring-boot/src/main/java/com/example/invoice/PrintLabelWorker.java @@ -10,10 +10,8 @@ import org.springframework.stereotype.Component; /** - * Completes "print-label" external tasks from inside the same JVM as the - * embedded engine, so the shipment-label process reaches its end event without a - * separate worker deployment. It polls the engine's external-task service on a - * fixed delay and completes any task locked on the topic. + * Completes "print-label" external tasks from inside the engine's JVM, so the + * fixture needs no separate worker deployment. */ @Component public class PrintLabelWorker { diff --git a/examples/spring-boot/src/main/java/com/example/invoice/ShippingBean.java b/examples/spring-boot/src/main/java/com/example/invoice/ShippingBean.java index 9126332..678e192 100644 --- a/examples/spring-boot/src/main/java/com/example/invoice/ShippingBean.java +++ b/examples/spring-boot/src/main/java/com/example/invoice/ShippingBean.java @@ -5,10 +5,8 @@ import org.springframework.stereotype.Component; /** - * Bean referenced by the shipping-quote process via - * {@code expression = "${shippingBean.quote(order)}"} (operaton:expression). The - * service task evaluates the method call; the returned quote is logged (the DSL - * does not yet bind an expression result to a process variable). + * The {@code ${shippingBean.quote(order)}} expression in + * {@code service-expression.bpmnscript} resolves to this bean by name. */ @Component("shippingBean") public class ShippingBean { diff --git a/examples/spring-boot/src/main/java/com/example/loan/kopp/ExternalRatingS1Delegate.java b/examples/spring-boot/src/main/java/com/example/loan/kopp/ExternalRatingS1Delegate.java index a4f586e..87e08db 100644 --- a/examples/spring-boot/src/main/java/com/example/loan/kopp/ExternalRatingS1Delegate.java +++ b/examples/spring-boot/src/main/java/com/example/loan/kopp/ExternalRatingS1Delegate.java @@ -5,7 +5,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** External credit bureau S1: rates by credit score. */ public class ExternalRatingS1Delegate implements JavaDelegate { private static final Logger LOG = LoggerFactory.getLogger(ExternalRatingS1Delegate.class); diff --git a/examples/spring-boot/src/main/java/com/example/loan/kopp/ExternalRatingS2Delegate.java b/examples/spring-boot/src/main/java/com/example/loan/kopp/ExternalRatingS2Delegate.java index 72a3902..3d479b4 100644 --- a/examples/spring-boot/src/main/java/com/example/loan/kopp/ExternalRatingS2Delegate.java +++ b/examples/spring-boot/src/main/java/com/example/loan/kopp/ExternalRatingS2Delegate.java @@ -5,7 +5,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** External credit bureau S2: rates by loan size. */ public class ExternalRatingS2Delegate implements JavaDelegate { private static final Logger LOG = LoggerFactory.getLogger(ExternalRatingS2Delegate.class); diff --git a/examples/spring-boot/src/main/java/com/example/loan/kopp/InternalRatingDelegate.java b/examples/spring-boot/src/main/java/com/example/loan/kopp/InternalRatingDelegate.java index 68a186d..218b613 100644 --- a/examples/spring-boot/src/main/java/com/example/loan/kopp/InternalRatingDelegate.java +++ b/examples/spring-boot/src/main/java/com/example/loan/kopp/InternalRatingDelegate.java @@ -5,7 +5,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** Internal rating service: stricter credit-score cutoff than the external bureaus. */ +/** A stricter credit-score cutoff than the external bureau S1 applies. */ public class InternalRatingDelegate implements JavaDelegate { private static final Logger LOG = LoggerFactory.getLogger(InternalRatingDelegate.class); diff --git a/examples/spring-boot/src/main/resources/application-demo.yml b/examples/spring-boot/src/main/resources/application-demo.yml index 8fd133c..b8d5661 100644 --- a/examples/spring-boot/src/main/resources/application-demo.yml +++ b/examples/spring-boot/src/main/resources/application-demo.yml @@ -1,3 +1,2 @@ -# "demo" profile: turns classpath auto-deployment on so the compiled processes deploy at startup. operaton.bpm: auto-deployment-enabled: true diff --git a/examples/spring-boot/src/main/resources/application.yml b/examples/spring-boot/src/main/resources/application.yml index 6798d2f..6f9f3f4 100644 --- a/examples/spring-boot/src/main/resources/application.yml +++ b/examples/spring-boot/src/main/resources/application.yml @@ -2,16 +2,13 @@ server: port: 8080 operaton.bpm: - # Admin user credentials used by the Operaton REST API and web apps. admin-user: id: demo password: demo firstName: Demo - # History level: "audit" (= Operaton's HISTORY_DEFAULT constant) records task and - # variable events needed by the E2E harness. Valid values: none | activity | audit | full | auto. + # The E2E assertions read task and variable history, which "audit" records. history-level: audit - # The testcontainers harness deploys process definitions via REST (POST /engine-rest/deployment/create). - # Disabling classpath auto-deployment prevents duplicate deployments on startup. + # The harness deploys over REST; the demo profile turns this on. auto-deployment-enabled: false diff --git a/package-lock.json b/package-lock.json index ac14baa..d2b2a0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "bpmn-script-workspace", "version": "0.0.1", + "license": "Apache-2.0", "workspaces": [ "packages/language", "packages/cli", @@ -23,6 +24,9 @@ "prettier": "^3.9.6", "shx": "~0.4.0", "typescript": "~6.0.0" + }, + "engines": { + "node": ">=22.0.0" } }, "node_modules/@azure/abort-controller": { @@ -804,9 +808,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -885,9 +889,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -4839,9 +4843,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -4910,9 +4914,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -5593,9 +5597,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -8000,9 +8004,9 @@ "license": "MIT" }, "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -10176,6 +10180,7 @@ "packages/cli": { "name": "@bpmn-script/cli", "version": "0.0.1", + "license": "Apache-2.0", "dependencies": { "@bpmn-script/language": "0.0.1", "@bpmn-script/transform": "*", @@ -10229,6 +10234,7 @@ "packages/language": { "name": "@bpmn-script/language", "version": "0.0.1", + "license": "Apache-2.0", "dependencies": { "langium": "^4.3.1", "vscode-languageserver-types": "^3.18.3" @@ -10251,6 +10257,7 @@ "packages/transform": { "name": "@bpmn-script/transform", "version": "0.0.1", + "license": "Apache-2.0", "dependencies": { "@bpmn-script/language": "*", "bpmn-auto-layout": "^1.2.0", @@ -10267,6 +10274,7 @@ }, "tests": { "version": "0.0.1", + "license": "Apache-2.0", "devDependencies": { "@bpmn-script/language": "0.0.1", "@bpmn-script/transform": "0.0.1", @@ -10276,7 +10284,8 @@ "vscode-languageserver-types": "^3.18.0" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.0.0", + "npm": ">=10.2.3" } } } diff --git a/package.json b/package.json index 396b912..0d150b6 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,17 @@ { "name": "bpmn-script-workspace", - "description": "Base workspace package", + "description": "Write executable business processes as text, compile to BPMN 2.0 XML, and read existing BPMN files back as text.", "version": "0.0.1", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/BPMNscript/bpmnscript.git" + }, "type": "module", "private": true, + "engines": { + "node": ">=22.0.0" + }, "scripts": { "clean": "npm run clean --workspaces --if-present", "watch": "tsc -b tsconfig.build.json --watch", diff --git a/packages/cli/README.md b/packages/cli/README.md index 3d8a994..76bc1df 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -62,8 +62,8 @@ npm test | `src/main.ts` | Entry point; registers `build` and `parse` through `commander` | | `src/build.ts` | `buildAction`: parse, validate, `astToIr`, `irToXml`, write `.bpmn` | | `src/parse.ts` | `parseAction`: read `.bpmn`, `xmlToIr`, `irToDsl`, write `.bpmnscript` | -| `src/util.ts` | `resolveOutputPath` (output-path derivation) and `CLI_VERSION` | -| `bin/cli.js` | Shell script that calls the compiled entry point | +| `src/util.ts` | Shared by both actions: path guards, `buildDocument`, `CLI_VERSION` | +| `bin/cli.js` | Node entry script; imports the compiled `out/main.js` and runs it | ## Dependencies on other packages diff --git a/packages/cli/package.json b/packages/cli/package.json index bed9500..9a3f6ce 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,6 +2,12 @@ "name": "@bpmn-script/cli", "description": "The cli specific package", "version": "0.0.1", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/BPMNscript/bpmnscript.git", + "directory": "packages/cli" + }, "type": "module", "engines": { "node": ">=22.0.0", diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index d0be0bd..7f3eb36 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -1,8 +1,6 @@ import { BpmnScriptLanguageMetaData } from '@bpmn-script/language'; import type { Model } from '@bpmn-script/language'; import chalk from 'chalk'; -import * as fs from 'node:fs/promises'; -import * as fsSync from 'node:fs'; import * as path from 'node:path'; import { astToIr, irToXml, LayoutError } from '@bpmn-script/transform'; @@ -12,13 +10,16 @@ import { SEVERITY_WARNING, buildDocument, diagnosticMessage, + fail, formatDiagnostic, guardOutputPath, - refuseDirectoryInput, + resolveInputPath, resolveOutputPath, + warn, + writeOutput, } from './util.js'; -export type BuildOptions = { +type BuildOptions = { output?: string; force?: boolean; }; @@ -27,26 +28,18 @@ export async function buildAction( fileName: string, opts: BuildOptions, ): Promise { - const resolvedInput = path.resolve(fileName); + const resolvedInput = resolveInputPath(fileName); - if (!fsSync.existsSync(resolvedInput)) { - console.error(chalk.red(`Error: file not found: ${fileName}`)); - process.exit(2); - } - refuseDirectoryInput(resolvedInput, fileName); - - // The document loader below picks its language service by extension, so a - // wrong one cannot merely warn: it fails a few lines later with an internal + // The document loader picks its language service by extension, so a wrong + // one cannot merely warn: it fails a few lines later with an internal // Langium message ("service registry contains no services"). const extensions: readonly string[] = BpmnScriptLanguageMetaData.fileExtensions; if (!extensions.includes(path.extname(resolvedInput))) { - console.error( - chalk.red( - `Error: expected a file with one of these extensions: ${extensions.join(', ')}`, - ), + fail( + 2, + `Error: expected a file with one of these extensions: ${extensions.join(', ')}`, ); - process.exit(2); } const outPath = resolveOutputPath(resolvedInput, '.bpmn', opts.output); @@ -56,12 +49,7 @@ export async function buildAction( try { document = await buildDocument(resolvedInput); } catch (err) { - console.error( - chalk.red( - `Error: failed to parse ${fileName}: ${(err as Error).message}`, - ), - ); - process.exit(2); + fail(2, `Error: failed to parse ${fileName}: ${(err as Error).message}`); } const errors = (document.diagnostics ?? []).filter( @@ -75,34 +63,29 @@ export async function buildAction( process.exit(1); } - // A comment-only or blank source parses without error into a model with no - // processes; catching that here, on the parsed model, keeps this message - // for every such case instead of leaking astToIr's own internal wording. - // Checked only once the document is error-free, so a keyword typo (which - // also yields zero Process nodes) reports its real parser error instead. - const ast = document.parseResult?.value as Model; - if (ast.processes.length === 0) { - console.error(chalk.red('Error: the file has no process')); - process.exit(1); - } + // A blank or comment-only source parses clean into a model with no + // processes, which astToIr would refuse in its own internal wording. Checked + // after the error gate: a keyword typo also yields zero processes and should + // report its parser error instead. + const ast = document.parseResult.value as Model; + if (ast.processes.length === 0) fail(1, 'Error: the file has no process'); const warnings = (document.diagnostics ?? []).filter( (d) => d.severity === SEVERITY_WARNING, ); for (const diag of warnings) { - console.error( - chalk.yellow( - `Warning: line ${diag.range.start.line + 1}: ${diagnosticMessage(diag)}`, - ), + warn( + `Warning: line ${diag.range.start.line + 1}: ${diagnosticMessage(diag)}`, ); } + // The extension's conversion-core.ts and tests/helpers/pipeline.ts run the + // same astToIr -> irToXml chain, each with its own failure reporting. let ir; try { ir = astToIr(ast); } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); + fail(1, `Error: ${(err as Error).message}`); } let xml: string; @@ -110,31 +93,16 @@ export async function buildAction( xml = await irToXml(ir, { exporterVersion: CLI_VERSION }); } catch (err) { if (!(err instanceof LayoutError)) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); + fail(1, `Error: ${(err as Error).message}`); } - // The document itself is fine (Operaton deploys it); only the auto-layout - // step failed, so the file is written without a diagram instead of lost. + // Operaton deploys the document without a diagram, so it is written + // rather than lost. xml = err.xml; - console.error( - chalk.yellow( - `Warning: no diagram could be drawn for this process (${err.message}); the file deploys but opens without shapes in a modeler`, - ), - ); - } - - try { - const outDir = path.dirname(outPath); - await fs.mkdir(outDir, { recursive: true }); - await fs.writeFile(outPath, xml, 'utf-8'); - } catch (err) { - console.error( - chalk.red( - `Error: could not write output to ${outPath}: ${(err as Error).message}`, - ), + warn( + `Warning: no diagram could be drawn for this process (${err.message}); the file deploys but opens without shapes in a modeler`, ); - process.exit(2); } + await writeOutput(outPath, xml); console.log(chalk.green(`Built: ${outPath}`)); } diff --git a/packages/cli/src/parse.ts b/packages/cli/src/parse.ts index 5cf91af..276c7d8 100644 --- a/packages/cli/src/parse.ts +++ b/packages/cli/src/parse.ts @@ -1,7 +1,5 @@ import chalk from 'chalk'; import * as fs from 'node:fs/promises'; -import * as fsSync from 'node:fs'; -import * as path from 'node:path'; import { xmlToIr, @@ -14,28 +12,29 @@ import type { ImportWarning, PrintWarning } from '@bpmn-script/transform'; import { SEVERITY_ERROR, buildDocument, + fail, formatDiagnostic, guardOutputPath, - refuseDirectoryInput, + resolveInputPath, resolveOutputPath, + warn, + writeOutput, } from './util.js'; -export type ParseOptions = { +type ParseOptions = { output?: string; force?: boolean; }; -// The one message `moddle-xml`'s reader gives when the root element does not -// resolve to any registered type, bpmn:Definitions included: it never says -// what it found (`moddle-xml`'s `Reader`, root-handler branch). +// `moddle-xml`'s one message for a root that resolves to no registered type; +// it never says what it found. const WRONG_ROOT_MESSAGE = 'failed to parse document as '; const BPMN_NAMESPACE = 'http://www.omg.org/spec/BPMN/20100524/MODEL'; -/** The tag name and, where present, the xmlns declaration on that same tag. */ function describeRoot(xml: string): string { const tag = /<([\w:]+)/.exec(xml)?.[1]; if (tag === undefined) return ''; - // A greedy `[^>]*` backtracks from the tag's end, so it lands on the LAST + // A greedy `[^>]*` backtracks from the tag's end, so it lands on the last // xmlns declared on the root, not the one for the root's own prefix. const colon = tag.indexOf(':'); const decl = colon === -1 ? 'xmlns' : `xmlns:${tag.slice(0, colon)}`; @@ -44,26 +43,16 @@ function describeRoot(xml: string): string { return ` (root element is <${tag}>; expected in namespace ${BPMN_NAMESPACE}${found})`; } -/** - * `saxen`'s parse errors quote the unparsed remainder of the document, which - * for a large or binary file is megabytes on stderr, control characters - * included when the remainder holds any. Cut at the first line (the - * remainder trails after a real newline in the message), cap and drop - * control characters from what's left, then add whatever root-mismatch - * detail applies. - */ +// `saxen`'s parse errors quote the unparsed remainder of the document after a +// newline: megabytes for a large or binary file, control characters included. function readableParseError(message: string, xml: string): string { const firstLine = message.split('\n')[0]; const capped = ( firstLine.length > 200 ? firstLine.slice(0, 200) + '...' : firstLine ).replace(/\p{C}/gu, ''); - // The root-mismatch hint is added after capping: it is bounded by the - // document's own tag and namespace text, not by the remainder a malformed - // document can dump into the message this replaces. return capped === WRONG_ROOT_MESSAGE ? capped + describeRoot(xml) : capped; } -/** The first 40 characters with control characters stripped, for previewing a non-XML file without dumping it. */ function printablePreview(text: string): string { return text.slice(0, 200).replace(/\p{C}/gu, '').slice(0, 40); } @@ -72,14 +61,7 @@ export async function parseAction( fileName: string, opts: ParseOptions, ): Promise { - const resolvedInput = path.resolve(fileName); - - if (!fsSync.existsSync(resolvedInput)) { - console.error(chalk.red(`Error: file not found: ${fileName}`)); - process.exit(2); - } - refuseDirectoryInput(resolvedInput, fileName); - + const resolvedInput = resolveInputPath(fileName); const outPath = resolveOutputPath(resolvedInput, '.bpmnscript', opts.output); guardOutputPath(resolvedInput, outPath, opts); @@ -87,27 +69,18 @@ export async function parseAction( try { xml = await fs.readFile(resolvedInput, 'utf-8'); } catch (err) { - console.error( - chalk.red(`Error: could not read ${fileName}: ${(err as Error).message}`), - ); - process.exit(2); + fail(2, `Error: could not read ${fileName}: ${(err as Error).message}`); } - // `trim`/`trimStart` treat a leading BOM as whitespace (ECMA-262 WhiteSpace - // includes U+FEFF), so an empty-or-BOM-only file and one indented under a - // BOM both fall through this pair of checks correctly with no extra strip. - if (xml.trim() === '') { - console.error(chalk.red('Error: the file is empty')); - process.exit(2); - } + // `trim` treats a leading BOM as whitespace (ECMA-262 WhiteSpace includes + // U+FEFF), so neither check needs a BOM strip. + if (xml.trim() === '') fail(2, 'Error: the file is empty'); if (!xml.trimStart().startsWith('<')) { - console.error( - chalk.red( - `Error: not an XML document (starts with "${printablePreview(xml.trimStart())}"); ` + - 'a .bpmnscript file is built with `bpmns build`', - ), + fail( + 2, + `Error: not an XML document (starts with "${printablePreview(xml.trimStart())}"); ` + + 'a .bpmnscript file is built with `bpmns build`', ); - process.exit(2); } let ir; @@ -117,44 +90,34 @@ export async function parseAction( } catch (err) { // Subclasses first: they all extend UnsupportedConstructError. if (err instanceof UnsupportedServiceTaskFormError) { - console.error( - chalk.red( - `Error: unsupported ${err.subject.toLowerCase()} form in ${fileName}:\n` + - ` ${err.message}\n` + - ' The attributes are operaton:class (or the deprecated camunda:class ' + - 'alias), operaton:expression, operaton:delegateExpression, ' + - 'operaton:type="external" with operaton:topic, ' + - 'operaton:type="mail" or "shell" with their operaton:field ' + - 'children, and operaton:decisionRef.', - ), + fail( + 1, + `Error: unsupported ${err.subject.toLowerCase()} form in ${fileName}:\n` + + ` ${err.message}\n` + + ' The attributes are operaton:class (or the deprecated camunda:class ' + + 'alias), operaton:expression, operaton:delegateExpression, ' + + 'operaton:type="external" with operaton:topic, ' + + 'operaton:type="mail" or "shell" with their operaton:field ' + + 'children, and operaton:decisionRef.', ); - process.exit(1); } if (err instanceof UnsupportedElementError) { - console.error( - chalk.red( - `Error: unsupported BPMN element in ${fileName}:\n` + - ` ${err.message}`, - ), + fail( + 1, + `Error: unsupported BPMN element in ${fileName}:\n ${err.message}`, ); - process.exit(1); } if (err instanceof UnsupportedConstructError) { - console.error( - chalk.red( - `Error: unsupported BPMN construct in ${fileName}:\n` + - ` ${err.message}`, - ), + fail( + 1, + `Error: unsupported BPMN construct in ${fileName}:\n ${err.message}`, ); - process.exit(1); } - console.error( - chalk.red( - `Error: failed to parse ${fileName}: ` + - readableParseError((err as Error).message, xml), - ), + fail( + 2, + `Error: failed to parse ${fileName}: ` + + readableParseError((err as Error).message, xml), ); - process.exit(2); } let dsl: string; @@ -162,50 +125,32 @@ export async function parseAction( try { ({ source: dsl, warnings: printWarnings } = irToDsl(ir)); } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(2); - } - - try { - const outDir = path.dirname(outPath); - await fs.mkdir(outDir, { recursive: true }); - await fs.writeFile(outPath, dsl, 'utf-8'); - } catch (err) { - console.error( - chalk.red( - `Error: could not write output to ${outPath}: ${(err as Error).message}`, - ), - ); - process.exit(2); + fail(2, `Error: ${(err as Error).message}`); } + await writeOutput(outPath, dsl); console.log(chalk.green(`Parsed: ${outPath}`)); - // The id leads: several messages describe the route on from a step without - // naming it, and two such warnings are otherwise the same line twice. The id - // is also a token the reader can search for in the script just written. + // The id leads because several messages do not name their step, so two of + // them would otherwise print as the same line. for (const w of [...warnings, ...printWarnings]) { - console.error(chalk.yellow(`Warning: ${w.elementId}: ${w.message}`)); + warn(`Warning: ${w.elementId}: ${w.message}`); } - // The print hop from IR to source never refuses (ADR-0014): a warning above - // can name that a setting was carried as written, but none of them run the - // validator, so whether it actually draws an error is still unconfirmed. - // Re-running the same document-builder path `build` uses over the script - // just written is the one place that can still catch it, for every shape. + // The print hop never refuses (ADR-0014) and none of the warnings above run + // the validator, so a setting carried as written may still draw an error; + // building the written script the way `build` would is what catches it. const rebuilt = await buildDocument(outPath); const buildErrors = (rebuilt.diagnostics ?? []).filter( (d) => d.severity === SEVERITY_ERROR, ); if (buildErrors.length > 0) { - console.error( - chalk.yellow( - `Warning: the printed script draws ${buildErrors.length} error(s) ` + - 'when built; hand-repair is needed:', - ), + warn( + `Warning: the printed script draws ${buildErrors.length} error(s) ` + + 'when built; hand-repair is needed:', ); for (const diag of buildErrors) { - console.error(chalk.yellow(formatDiagnostic(rebuilt, diag))); + warn(formatDiagnostic(rebuilt, diag)); } } } diff --git a/packages/cli/src/util.ts b/packages/cli/src/util.ts index 460b46f..0ba80a1 100644 --- a/packages/cli/src/util.ts +++ b/packages/cli/src/util.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync, statSync } from 'node:fs'; +import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import chalk from 'chalk'; import type { LangiumDocument } from 'langium'; @@ -6,18 +7,24 @@ import { URI } from 'langium'; import { NodeFileSystem } from 'langium/node'; import { createBpmnScriptServices } from '@bpmn-script/language'; -/** Read off the document so the shape is Langium's own, not a restatement. */ type Diagnostic = NonNullable[number]; -/** LSP's `DiagnosticSeverity` values; both actions filter diagnostics by these. */ +/** LSP's `DiagnosticSeverity` values. */ export const SEVERITY_ERROR = 1; export const SEVERITY_WARNING = 2; +export function fail(code: number, message: string): never { + console.error(chalk.red(message)); + process.exit(code); +} + +export function warn(message: string): void { + console.error(chalk.yellow(message)); +} + /** - * A diagnostic carries its message as plain text or as LSP markup. Everything - * the language server raises is text, but the union has to be read either way: - * printed straight into a template literal, a markup message reaches the author - * as `[object Object]` instead of the diagnostic. + * Langium's `message` is a string or LSP markup; a template literal would + * print the latter as `[object Object]`. */ export function diagnosticMessage(diagnostic: Diagnostic): string { return typeof diagnostic.message === 'string' @@ -25,16 +32,25 @@ export function diagnosticMessage(diagnostic: Diagnostic): string { : diagnostic.message.value; } -/** - * Resolved relative to this module, so it works from both `out/` (compiled) and - * `src/` (vitest); both sit one level below the package root. - */ +// Resolved relative to this module, which sits one level below the package +// root in both `out/` (compiled) and `src/` (vitest). export const CLI_VERSION: string = ( JSON.parse( readFileSync(new URL('../package.json', import.meta.url), 'utf-8'), ) as { version: string } ).version; +/** Without these checks the loader fails with ENOENT or a bare EISDIR. */ +export function resolveInputPath(fileName: string): string { + const resolved = path.resolve(fileName); + if (!existsSync(resolved)) fail(2, `Error: file not found: ${fileName}`); + if (statSync(resolved).isDirectory()) { + fail(2, `Error: ${fileName} is a directory`); + } + return resolved; +} + +// The extension's `swapExtension` (conversion-core.ts) is the no-override case. export function resolveOutputPath( resolvedInput: string, defaultExt: string, @@ -52,40 +68,39 @@ export function resolveOutputPath( : resolved; } -export type GuardOptions = { force?: boolean }; +type GuardOptions = { force?: boolean }; -/** - * Refuses two destructive shapes before either action reads its input: - * an output path identical to the input, and an existing output without - * `--force`. Both actions call this right after resolving the output path. - */ export function guardOutputPath( resolvedInput: string, outPath: string, opts: GuardOptions, ): void { if (outPath === resolvedInput) { - console.error( - chalk.red('Error: the input and the output are the same file'), - ); - process.exit(2); + fail(2, 'Error: the input and the output are the same file'); } if (!opts.force && existsSync(outPath)) { - console.error( - chalk.red( - `Error: ${outPath} exists; pass --force to overwrite it or -o for another path`, - ), + fail( + 2, + `Error: ${outPath} exists; pass --force to overwrite it or -o for another path`, + ); + } +} + +export async function writeOutput( + outPath: string, + content: string, +): Promise { + try { + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, content, 'utf-8'); + } catch (err) { + fail( + 2, + `Error: could not write output to ${outPath}: ${(err as Error).message}`, ); - process.exit(2); } } -/** - * Parses and validates a BpmnScript document through Langium's own pipeline. - * `build` loads the file it was given this way; `parse` reloads the file it - * just wrote the same way, to catch what the print step (which never - * refuses, ADR-0014) still draws wrong. - */ export async function buildDocument( filePath: string, ): Promise { @@ -100,7 +115,6 @@ export async function buildDocument( return document; } -/** ` line N: message [snippet]`, the shape both actions print a diagnostic in. */ export function formatDiagnostic( document: LangiumDocument, diagnostic: Diagnostic, @@ -110,14 +124,3 @@ export function formatDiagnostic( ` [${document.textDocument.getText(diagnostic.range)}]` ); } - -/** A directory has no text or document to read; refused before the loader turns it into a bare EISDIR. */ -export function refuseDirectoryInput( - resolvedInput: string, - fileName: string, -): void { - if (statSync(resolvedInput).isDirectory()) { - console.error(chalk.red(`Error: ${fileName} is a directory`)); - process.exit(2); - } -} diff --git a/packages/cli/test/build-parse.smoke.test.ts b/packages/cli/test/build-parse.smoke.test.ts index fd81868..1951fa5 100644 --- a/packages/cli/test/build-parse.smoke.test.ts +++ b/packages/cli/test/build-parse.smoke.test.ts @@ -29,6 +29,17 @@ const __dirname = dirname(__filename); const REPO_ROOT = path.resolve(__dirname, '../../..'); +// Read from package.json rather than imported from util.ts: that export is +// what the exporterVersion assertion pins. +const PACKAGE_VERSION = ( + JSON.parse( + fs.readFileSync( + path.resolve(REPO_ROOT, 'packages/cli/package.json'), + 'utf-8', + ), + ) as { version: string } +).version; + const INVOICE_APPROVAL_SRC = path.resolve( REPO_ROOT, 'examples/spring-boot/processes/invoice-approval.bpmnscript', @@ -56,9 +67,8 @@ beforeAll(() => { parse = parseHelper(services.BpmnScript); }); -// Back-edge into a parallel fork (`B -> Fork`). The fork's out-edges are already -// consumed when the back-arrival is reached, so the decompiler emits the -// hand-repair marker instead of a `goto`. Only hand-built BPMN gets here. +// A back-edge into a parallel fork (`B -> Fork`), which the decompiler cannot +// phrase as a `goto`; only hand-built BPMN gets here. const UNSTRUCTURED_FORK_BPMN = ` @@ -76,9 +86,8 @@ const UNSTRUCTURED_FORK_BPMN = ` `; -// Two steps, each with a single conditioned outgoing flow. Both produce the -// same dropped-condition text, so the element id is the only thing telling the -// reader which step lost a condition, and that two did. +// Two steps with one conditioned outgoing flow each, so both draw the same +// dropped-condition warning. const TWO_DROPPED_CONDITIONS_BPMN = ` `; -// An external task's `taskPriority` is carried as written, with an import -// warning naming the step (`BpmnParse.parsePriority` fails the deployment on -// a non-integer constant); the warning says so but does not itself run the -// validator, so re-validating the printed script is what actually confirms -// `"abc"` draws the error. -/** A number field whose default the engine's `LongFormType` cannot convert: imported as written, refused by the validator. */ +// A number field whose default the engine's `LongFormType` cannot convert: +// imported as written, refused by the validator. const DECIMAL_DEFAULT_BPMN = ` { { warningIds: ['CheckStock', 'ReserveGoods'], sameMessage: true, - // The id is worth printing because it is a token the reader can find - // in the script they were just handed. script: ['CheckStock', 'ReserveGoods'], }, ], @@ -195,8 +192,6 @@ describe('bpmns parse', () => { expect(run.exit).toBeUndefined(); expect(run.output).toBeDefined(); - // Every stderr line is accounted for: the id-prefixed warnings, then - // (where the row expects them) the re-validation lines appended after. const tailLen = expected.buildErrorLines?.length ?? 0; expect(run.stderr).toHaveLength(expected.warningIds.length + tailLen); const splitAt = run.stderr.length - tailLen; @@ -225,7 +220,6 @@ describe('bpmns parse', () => { expect(bodies[0]).not.toBe(''); } - // Whatever it writes has to be a script the language accepts. const document = await parse(run.output!); expect(document.parseResult.parserErrors).toHaveLength(0); }); @@ -243,9 +237,7 @@ const WARNING_ONLY_SOURCE = `process warning-only { } `; -// A keyword typo (`proces` for `process`) leaves the grammar unable to find -// a Process node at all, the same symptom an empty file produces; only the -// parser error, not the no-process message, tells the two apart. +// Yields zero Process nodes, as an empty file does. const KEYWORD_TYPO_SOURCE = 'proces p { user A }\n'; // Declares `amount` as string, then compares it numerically: severity 1. @@ -261,11 +253,9 @@ const TYPE_MISMATCH_SOURCE = `process type-mismatch { `; type BuildExpectation = { - /** The code it exited with, or undefined where it ran to the end. */ exit?: number; /** The process id the written BPMN imports back under, if one was written. */ reimportsAs?: string; - /** How many lines reach stderr, and what they must say. */ stderrLines: number; mentions?: string[]; }; @@ -319,6 +309,9 @@ describe('bpmns build', () => { } expect(run.output).toBeDefined(); expect((await xmlToIr(run.output!)).ir.id).toBe(expected.reimportsAs); + expect(run.output!.match(/exporterVersion="[^"]*"/g)).toEqual([ + `exporterVersion="${PACKAGE_VERSION}"`, + ]); }); }); @@ -334,14 +327,11 @@ const VALID_BPMN = ` `; -// DSL text handed to `parse` by mistake: no comma, no newline, over 40 -// characters, so the preview's cutoff is visible without hand-counting it. +// Over 40 characters, so the preview's cutoff shows. const SWAPPED_DSL = 'process invoice_approval { start S user Review(assignee: "demo") end E }'; -// Accented text a real BPMN export can carry: only control bytes are -// stripped from the preview now, so these survive it. Over 40 characters, so -// the cutoff is visible without hand-counting it. +// Over 40 characters of accented text, which the preview keeps. const NON_ASCII_PREVIEW_SOURCE = 'prüfen, ob Umlaute überleben und nicht nur ASCII-Zeichen übrig bleiben'; @@ -349,9 +339,8 @@ type GuardCase = { inputPath: string; opts: { output?: string; force?: boolean }; exit: number | undefined; - /** The exact first stderr line, where the row expects one. */ + /** The first stderr line, where the row expects one. */ line?: string; - /** Run after the action, for what a stderr line can't say (file contents). */ check?: () => void; }; @@ -361,10 +350,8 @@ type GuardRow = readonly [ make: (dir: string) => GuardCase, ]; -// One table for both commands: the five destructive-path guards (same file, -// existing output, --force, directory input, `-o` a directory) are one -// mechanism shared by `build` and `parse` through `util.ts`, so a row pair -// proves it fires identically on both rather than trusting a build-only test. +// The path guards are one mechanism in `util.ts`, so each has a row per +// command to pin that both call it. describe('bpmns build / bpmns parse: guards against destructive or unclear failures', () => { test.each([ [ @@ -592,9 +579,8 @@ describe('bpmns build / bpmns parse: guards against destructive or unclear failu const inputPath = path.join(dir, 'binary.bpmn'); const bytes = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0x00]); fs.writeFileSync(inputPath, bytes); - // Only control bytes (0x00, 0x01, 0x02) are stripped now; 0xff/0xfe - // are not valid UTF-8 on their own and decode as U+FFFD, a printable - // symbol the preview keeps. + // 0xff/0xfe are not valid UTF-8 and decode to U+FFFD, which is not a + // control character, so the preview keeps them. const preview = bytes.toString('utf-8').replace(/\p{C}/gu, ''); return { inputPath, @@ -683,10 +669,10 @@ describe('bpmns build / bpmns parse: guards against destructive or unclear failu 'parse', (dir) => { const inputPath = path.join(dir, 'unclosed.bpmn'); - // `saxen` echoes the whole unparsed remainder into this one line, a - // control byte included, then appends `\n\tline: ...` after it, which - // this row's cutoff must never reach. - const unclosedTag = ' { const prepare = pkg.scripts?.['build:prepare'] ?? ''; expect(prepare).toContain('language/syntaxes'); - expect(prepare).toContain('syntaxes'); expect(prepare, 'build:prepare must perform a file copy').toMatch(/\bcp\b/); }); }); diff --git a/packages/cli/test/build.layout-guard.test.ts b/packages/cli/test/build.layout-guard.test.ts index da2da37..113332a 100644 --- a/packages/cli/test/build.layout-guard.test.ts +++ b/packages/cli/test/build.layout-guard.test.ts @@ -1,9 +1,6 @@ -/** - * `bpmn-auto-layout` is mocked here rather than provoking its real crash - * (`ir-to-xml.layout-guard.test.ts` in `packages/transform` does that): this - * file only needs `bpmns build`'s `LayoutError` fallback exercised end to - * end, not the library's own defect. - */ +// `bpmn-auto-layout` is mocked rather than crashed for real (transform's +// `ir-to-xml.layout-guard.test.ts` does that), and `vi.mock` applies to the +// whole file, so the fallback gets a file of its own. import { describe, it, expect, vi } from 'vitest'; vi.mock('bpmn-auto-layout', () => ({ diff --git a/packages/cli/test/decompile-contract.e2e.test.ts b/packages/cli/test/decompile-contract.e2e.test.ts index 668ddef..af3e341 100644 --- a/packages/cli/test/decompile-contract.e2e.test.ts +++ b/packages/cli/test/decompile-contract.e2e.test.ts @@ -38,8 +38,6 @@ const ERROR_START_BPMN = path.resolve( 'tests/fixtures/error-start.bpmn', ); -// `StartEvent_1`/`EndEvent_1` are what a modeler mints for a start and an end -// drawn without a name of their own. /** A labeled start and end under the given ids, in process `generated-id-labels`. */ const labeledTerminalsBpmn = ( startId: string, @@ -199,8 +197,8 @@ describe('decompile contract: the script it hands back goes through the pipeline expect(diagnostics).toHaveLength(0); }); - // Only the exact ids the compiler generates for the process are left out - // of the script; a modelling tool's default ids are names like any other. + // Only the exact ids the compiler generates for the process are left out of + // the script; a modeller's defaults (`StartEvent_1`) are names like any other. it.each([ [ "a modelling tool's default ids keep their statements and labels", diff --git a/packages/cli/test/helpers/actions.ts b/packages/cli/test/helpers/actions.ts index 09e6df9..eb0b4f4 100644 --- a/packages/cli/test/helpers/actions.ts +++ b/packages/cli/test/helpers/actions.ts @@ -26,13 +26,10 @@ class ExitCalled extends Error { export type ActionRun = { /** The code the action exited with, or undefined where it ran to the end. */ exit: number | undefined; - /** Every line the action wrote to stderr, in order. */ stderr: string[]; - /** The written file's content, or undefined where nothing was written. */ output: string | undefined; }; -/** Either a source written into the temp dir, or a file already on disk. */ export type Input = { text: string } | { file: string }; export type ActionOptions = { output?: string; force?: boolean }; @@ -42,13 +39,6 @@ type Action = ( opts: ActionOptions, ) => Promise; -/** - * Installs the same console/exit capture `run` uses, without owning a - * directory or picking paths: the guard tests below manage their own temp - * dir, since they need file layouts `run`'s fixed `input`/`output` names - * cannot express (a pre-existing output, `-o` equal to the input, a - * directory as either argument). - */ async function capture( invoke: () => Promise, ): Promise> { @@ -58,8 +48,8 @@ async function capture( stderr.push(String(args[0])); }), vi.spyOn(console, 'log').mockImplementation(() => {}), - // Throws so the action stops where process.exit() would have. A no-op - // mock would let it fall through and keep running. + // Throws so the action stops where `process.exit` would have; a no-op + // mock would let it run on. vi.spyOn(process, 'exit').mockImplementation((code?: unknown) => { throw new ExitCalled(typeof code === 'number' ? code : 0); }), @@ -109,10 +99,8 @@ async function run( } /** - * Runs an action against a path the caller already put on disk, with the - * caller's own options, for the destructive-path guards: no path is - * invented or cleaned up here, so the test can assert on the exact file it - * set up (still present, overwritten, or written inside a directory). + * For the guard tests, which lay out their own files: no path is invented or + * cleaned up here, so a test can assert on the exact file it set up. */ export const runActionAt = ( action: 'build' | 'parse', @@ -123,15 +111,12 @@ export const runActionAt = ( (action === 'build' ? buildAction : parseAction)(inputPath, opts), ); -/** Compiles a `.bpmnscript` source to BPMN, as `bpmns build` does. */ export const runBuild = (input: Input): Promise => run(buildAction, '.bpmnscript', '.bpmn', input); -/** Decompiles a `.bpmn` file to a script, as `bpmns parse` does. */ export const runParse = (input: Input): Promise => run(parseAction, '.bpmn', '.bpmnscript', input); -/** Every substring must appear in `text`, so a line that stops saying one fails. */ export function expectMentions( text: string, mentions: readonly string[], diff --git a/packages/cli/test/util.test.ts b/packages/cli/test/util.test.ts index c818ba8..ecdc720 100644 --- a/packages/cli/test/util.test.ts +++ b/packages/cli/test/util.test.ts @@ -2,25 +2,29 @@ import { describe, it, expect } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { - diagnosticMessage, - resolveOutputPath, - CLI_VERSION, -} from '../src/util.js'; +import { diagnosticMessage, resolveOutputPath } from '../src/util.js'; describe('resolveOutputPath', () => { it.each([ - // No override: the default extension replaces the input's own. [ + 'no override: the default extension replaces the input extension', '/work/invoice-approval.bpmnscript', undefined, '/work/invoice-approval.bpmn', ], - // Only the final extension goes, so a dotted basename survives. - ['/work/my.invoice.bpmnscript', undefined, '/work/my.invoice.bpmn'], - // An override is taken verbatim, resolved from cwd. - ['/work/invoice-approval.bpmnscript', 'out/custom.bpmn', 'out/custom.bpmn'], - ] as const)('%s + %s -> %s', (input, override, expected) => { + [ + 'only the final extension goes, so a dotted basename survives', + '/work/my.invoice.bpmnscript', + undefined, + '/work/my.invoice.bpmn', + ], + [ + 'an override is taken verbatim, resolved from cwd', + '/work/invoice-approval.bpmnscript', + 'out/custom.bpmn', + 'out/custom.bpmn', + ], + ] as const)('%s', (_title, input, override, expected) => { expect(resolveOutputPath(path.resolve(input), '.bpmn', override)).toBe( path.resolve(expected), ); @@ -52,10 +56,3 @@ describe('diagnosticMessage', () => { expect(diagnosticMessage({ range: RANGE, message })).toBe('no such step'); }); }); - -describe('CLI_VERSION', () => { - it('is a non-empty string read from package.json', () => { - expect(typeof CLI_VERSION).toBe('string'); - expect(CLI_VERSION.length).toBeGreaterThan(0); - }); -}); diff --git a/packages/extension/README.md b/packages/extension/README.md index 56a5dab..29b7693 100644 --- a/packages/extension/README.md +++ b/packages/extension/README.md @@ -9,7 +9,7 @@ The conversion layer on top is project-specific: three commands (`bpmnscript.com ## How it fits together -Three parts bundle into `out/extension/main.cjs`. +Three parts, two bundles: the client and the conversion layer in `out/extension/main.cjs`, the language server in `out/language/main.cjs`. The extension client (`src/extension/main.ts`) runs inside VS Code. Opening a `.bpmnscript` file starts the language server and connects to it; the client also registers the conversion commands, wires up the sidebar webview provider, and listens for editor changes to keep the sidebar in sync. diff --git a/packages/extension/esbuild.mjs b/packages/extension/esbuild.mjs index 4ccdcdc..3263bb5 100644 --- a/packages/extension/esbuild.mjs +++ b/packages/extension/esbuild.mjs @@ -1,7 +1,7 @@ import * as esbuild from 'esbuild'; import { copyFileSync, mkdirSync } from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; const watch = process.argv.includes('--watch'); const minify = process.argv.includes('--minify'); @@ -12,9 +12,6 @@ const success = watch ? 'Watch build succeeded' : 'Build succeeded'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// Source operaton-moddle.json lives in the transform package next to the TS -// files that read it; its out/ copy is what normal consumers see, but the -// bundle needs a copy beside itself (see assetCopyPlugin below). const operatonModdleSrc = resolve( __dirname, '../transform/src/operaton-moddle.json', @@ -29,56 +26,30 @@ function padZeroes(i) { return i.toString().padStart(2, '0'); } -/** - * esbuild plugin: copies operaton-moddle.json beside the bundle after every - * successful build, including --watch rebuilds. - * - * Why this is needed: the transform package locates the JSON at module-init - * time via dirname(fileURLToPath(import.meta.url)). Under the CJS bundle the - * import.meta.url shim (below) resolves to the bundle file, so moduleDir === - * the bundle's output directory. Copying the asset there satisfies the - * transform resolver's first candidate: - * join(moduleDir, 'operaton-moddle.json') → out/extension/operaton-moddle.json - * - * Exported so that verify/test bundles can include it and get the same copy - * behavior without duplicating the path logic. - */ +// Copies operaton-moddle.json beside the bundle after every build, watch +// rebuilds included: the transform package locates it at module init through +// dirname(fileURLToPath(import.meta.url)), which under the shim below is the +// bundle's own directory. export const assetCopyPlugin = { name: 'asset-copy', setup(build) { build.onEnd((result) => { if (result.errors.length > 0) return; const opts = build.initialOptions; - let targetDir; - if (opts.outfile) { - // Single-file output (e.g. a verify/test bundle): copy beside it. - // Use an absolute-path-aware resolution so this works regardless of cwd. - targetDir = isAbsolute(opts.outfile) - ? dirname(opts.outfile) - : resolve(process.cwd(), dirname(opts.outfile)); - } else { - // outdir-based output: extension entry maps to outdir/extension/. - targetDir = resolve(__dirname, opts.outdir ?? 'out', 'extension'); - } + // outfile is a single-file test bundle; outdir is the extension build, + // whose entry lands under outdir/extension/. + const targetDir = opts.outfile + ? resolve(dirname(opts.outfile)) + : resolve(__dirname, opts.outdir ?? 'out', 'extension'); mkdirSync(targetDir, { recursive: true }); copyFileSync(operatonModdleSrc, join(targetDir, 'operaton-moddle.json')); }); }, }; -/** - * Shared esbuild options, exported so that any verify/test bundle uses the - * identical configuration as the production extension bundle. In particular: - * - * define + banner: needed because @bpmn-script/transform reads - * fileURLToPath(import.meta.url) at module-init time. esbuild's CJS output - * otherwise leaves import.meta.url as undefined, causing - * ERR_INVALID_ARG_TYPE on activation. The banner injects a CJS-compatible - * definition at the top of the bundle before any module-init code runs. - * - * Callers should override entryPoints / outfile / outdir and add their own - * plugins (assetCopyPlugin is exported separately for the same reason). - */ +// define + banner: @bpmn-script/transform reads fileURLToPath(import.meta.url) +// at module init, and esbuild's CJS output otherwise leaves import.meta.url +// undefined, which throws ERR_INVALID_ARG_TYPE on activation. export const sharedBuildOptions = { bundle: true, target: 'ES2022', @@ -93,9 +64,8 @@ export const sharedBuildOptions = { }, }; -// ── Production build entry ──────────────────────────────────────────────────── -// Guard with isMain so that importing this module (e.g. from a test to obtain -// sharedBuildOptions) does not trigger a production build as a side effect. +// The test bundle imports this module for the options above; only a direct +// `node esbuild.mjs` builds. const isMain = process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href; diff --git a/packages/extension/src/extension/conversion-core.ts b/packages/extension/src/extension/conversion-core.ts index 2004887..b97ffdb 100644 --- a/packages/extension/src/extension/conversion-core.ts +++ b/packages/extension/src/extension/conversion-core.ts @@ -1,4 +1,4 @@ -// No `vscode` import here: the unit tests run this without an editor host. +// No `vscode` import here: the unit tests run this without an editor host; // `conversion.ts` is the VS Code adapter. import { createBpmnScriptServices } from '@bpmn-script/language'; @@ -36,6 +36,8 @@ export type DecompileResult = | { ok: false; kind: 'unsupported'; message: string } | { ok: false; kind: 'error'; message: string }; +// LSP's `DiagnosticSeverity.Error`; `vscode-languageserver-types` is not a +// dependency of this package. const SEVERITY_ERROR = 1; // Built once per module load: creating the services is expensive. @@ -49,8 +51,8 @@ export async function compileDslToBpmn( ): Promise { const uri = URI.parse(`memory:///conv-${nextDocId++}.bpmnscript`); - // Registered so the DocumentBuilder can resolve cross-references, and - // removed afterwards so the index does not grow with every call. + // Registered so the DocumentBuilder resolves cross-references, removed + // afterwards so the index does not grow with every call. const doc = shared.workspace.LangiumDocumentFactory.fromString( source, uri, @@ -76,10 +78,11 @@ export async function compileDslToBpmn( return { ok: false, kind: 'validation', diagnostics }; } - const ast = doc.parseResult.value as Model; + // The cli's build.ts and tests/helpers/pipeline.ts run the same + // astToIr -> irToXml chain, each with its own failure reporting. let ir; try { - ir = astToIr(ast); + ir = astToIr(doc.parseResult.value); } catch (err) { return { ok: false, @@ -154,8 +157,8 @@ export async function decompileBpmnToDsl( return { ok: true, output, warnings: [...warnings, ...printWarnings] }; } -// Duplicated from `packages/cli/src/util.ts` rather than imported: importing it -// would pull chalk and commander into the extension bundle. +// The cli's `resolveOutputPath` does the same without an override, but +// importing it would pull chalk and commander into the extension bundle. export function swapExtension(filePath: string, newExt: string): string { const dir = path.dirname(filePath); const base = path.basename(filePath, path.extname(filePath)); diff --git a/packages/extension/src/extension/conversion.ts b/packages/extension/src/extension/conversion.ts index 9ef45d5..9e79fde 100644 --- a/packages/extension/src/extension/conversion.ts +++ b/packages/extension/src/extension/conversion.ts @@ -6,8 +6,12 @@ import { swapExtension, } from './conversion-core.js'; -function resolveSourceUri(uri?: vscode.Uri): vscode.Uri | undefined { - return uri ?? vscode.window.activeTextEditor?.document.uri; +type ConvertHandler = (uri?: vscode.Uri) => Promise; + +interface Source { + uri: vscode.Uri; + name: string; + text: string; } // Prefers an open TextDocument so unsaved edits convert, not the stale file. @@ -22,6 +26,28 @@ async function readText(sourceUri: vscode.Uri): Promise { return new TextDecoder().decode(bytes); } +async function readSource( + uri: vscode.Uri | undefined, + ext: '.bpmnscript' | '.bpmn', + otherCommand: 'Decompile to BPMNscript' | 'Compile to BPMN', +): Promise { + const sourceUri = uri ?? vscode.window.activeTextEditor?.document.uri; + if (!sourceUri) { + await vscode.window.showWarningMessage( + `BPMNscript: No file selected. Open a ${ext} file or select one in the Explorer.`, + ); + return undefined; + } + const name = path.basename(sourceUri.fsPath); + if (path.extname(sourceUri.fsPath).toLowerCase() !== ext) { + await vscode.window.showWarningMessage( + `BPMNscript: "${name}" is not a ${ext} file; use "${otherCommand}" for it.`, + ); + return undefined; + } + return { uri: sourceUri, name, text: await readText(sourceUri) }; +} + async function confirmOverwrite(outputUri: vscode.Uri): Promise { try { await vscode.workspace.fs.stat(outputUri); @@ -37,151 +63,112 @@ async function confirmOverwrite(outputUri: vscode.Uri): Promise { return answer === 'Overwrite'; } -export function compileCommand( - extensionVersion: string, -): (uri?: vscode.Uri) => Promise { - return async (uri?: vscode.Uri): Promise => { - const sourceUri = resolveSourceUri(uri); - if (!sourceUri) { - await vscode.window.showWarningMessage( - 'BPMNscript: No file selected. Open a .bpmnscript file or select one in the Explorer.', - ); - return undefined; - } - - const sourceFileName = path.basename(sourceUri.fsPath); +async function writeOutput( + source: Source, + ext: '.bpmn' | '.bpmnscript', + verb: 'Compiled' | 'Decompiled', + output: string, +): Promise { + const outputPath = swapExtension(source.uri.fsPath, ext); + const outputUri = vscode.Uri.file(outputPath); + if (!(await confirmOverwrite(outputUri))) { + return undefined; + } + await vscode.workspace.fs.writeFile( + outputUri, + new TextEncoder().encode(output), + ); + await vscode.window.showTextDocument(outputUri); + void vscode.window.showInformationMessage( + `BPMNscript: ${verb} "${source.name}" -> "${path.basename(outputPath)}"`, + ); + return outputUri; +} - if (path.extname(sourceUri.fsPath).toLowerCase() !== '.bpmnscript') { - await vscode.window.showWarningMessage( - `BPMNscript: "${sourceFileName}" is not a .bpmnscript file; ` + - 'use "Decompile to BPMNscript" for it.', - ); +export function compileCommand(extensionVersion: string): ConvertHandler { + return async (uri) => { + const source = await readSource( + uri, + '.bpmnscript', + 'Decompile to BPMNscript', + ); + if (!source) { return undefined; } - const text = await readText(sourceUri); - - const result = await compileDslToBpmn(text, extensionVersion); - - if (result.ok) { - const outputPath = swapExtension(sourceUri.fsPath, '.bpmn'); - const outputUri = vscode.Uri.file(outputPath); - - if (!(await confirmOverwrite(outputUri))) { - return undefined; - } - - await vscode.workspace.fs.writeFile( - outputUri, - new TextEncoder().encode(result.output), - ); - - await vscode.window.showTextDocument(outputUri); - - void vscode.window.showInformationMessage( - `BPMNscript: Compiled "${sourceFileName}" -> "${path.basename(outputPath)}"`, - ); - - if (result.layoutWarning) { - void vscode.window.showWarningMessage( - `BPMNscript: "${sourceFileName}" compiled without a diagram: ${result.layoutWarning}`, + const result = await compileDslToBpmn(source.text, extensionVersion); + if (!result.ok) { + if (result.kind === 'validation') { + // The language client already publishes these diagnostics; a second + // collection would only duplicate them. + await vscode.window.showTextDocument(source.uri); + await vscode.commands.executeCommand('workbench.action.problems.focus'); + await vscode.window.showErrorMessage( + `BPMNscript: "${source.name}" has ${result.diagnostics.length} compilation error(s). See the Problems panel.`, + ); + } else { + await vscode.window.showErrorMessage( + `BPMNscript: Failed to compile "${source.name}": ${result.message}`, ); } - - return outputUri; - } else if (result.kind === 'validation') { - // The language client already publishes these same diagnostics for - // every workspace .bpmnscript file; showing the source and focusing - // Problems is enough, a second collection would only duplicate them. - await vscode.window.showTextDocument(sourceUri); - await vscode.commands.executeCommand('workbench.action.problems.focus'); - await vscode.window.showErrorMessage( - `BPMNscript: "${sourceFileName}" has ${result.diagnostics.length} compilation error(s). See the Problems panel.`, - ); return undefined; - } else { - await vscode.window.showErrorMessage( - `BPMNscript: Failed to compile "${sourceFileName}": ${result.message}`, + } + + const outputUri = await writeOutput( + source, + '.bpmn', + 'Compiled', + result.output, + ); + if (outputUri && result.layoutWarning) { + void vscode.window.showWarningMessage( + `BPMNscript: "${source.name}" compiled without a diagram: ${result.layoutWarning}`, ); - return undefined; } + return outputUri; }; } -export function decompileCommand(): ( - uri?: vscode.Uri, -) => Promise { - return async (uri?: vscode.Uri): Promise => { - const sourceUri = resolveSourceUri(uri); - if (!sourceUri) { - await vscode.window.showWarningMessage( - 'BPMNscript: No file selected. Open a .bpmn file or select one in the Explorer.', - ); +export function decompileCommand(): ConvertHandler { + return async (uri) => { + const source = await readSource(uri, '.bpmn', 'Compile to BPMN'); + if (!source) { return undefined; } - const sourceFileName = path.basename(sourceUri.fsPath); - - if (path.extname(sourceUri.fsPath).toLowerCase() !== '.bpmn') { - await vscode.window.showWarningMessage( - `BPMNscript: "${sourceFileName}" is not a .bpmn file; ` + - 'use "Compile to BPMN" for it.', + const result = await decompileBpmnToDsl(source.text); + if (!result.ok) { + await vscode.window.showErrorMessage( + result.kind === 'unsupported' + ? `BPMNscript: "${source.name}" contains an unsupported construct: ${result.message}` + : `BPMNscript: Failed to decompile "${source.name}": ${result.message}`, ); return undefined; } - const text = await readText(sourceUri); - - const result = await decompileBpmnToDsl(text); - - if (result.ok) { - const outputPath = swapExtension(sourceUri.fsPath, '.bpmnscript'); - const outputUri = vscode.Uri.file(outputPath); - - if (!(await confirmOverwrite(outputUri))) { - return undefined; - } - - await vscode.workspace.fs.writeFile( - outputUri, - new TextEncoder().encode(result.output), + const outputUri = await writeOutput( + source, + '.bpmnscript', + 'Decompiled', + result.output, + ); + if (outputUri && result.warnings.length > 0) { + // Prefixed with the element id: several messages describe the route on + // from a step without naming it, so two of them would read as one line + // repeated. + const details = result.warnings + .map((w) => `${w.elementId}: ${w.message}`) + .join('; '); + void vscode.window.showWarningMessage( + `BPMNscript: "${source.name}" reported ${result.warnings.length} item(s) during decompile: ${details}`, ); - - await vscode.window.showTextDocument(outputUri); - - void vscode.window.showInformationMessage( - `BPMNscript: Decompiled "${sourceFileName}" -> "${path.basename(outputPath)}"`, - ); - - if (result.warnings.length > 0) { - // Each entry leads with its element id, as `bpmns parse` does: several - // messages describe the route on from a step without naming it, and - // two of those read as one line repeated when the id is left off. - const details = result.warnings - .map((w) => `${w.elementId}: ${w.message}`) - .join('; '); - void vscode.window.showWarningMessage( - `BPMNscript: "${sourceFileName}" reported ${result.warnings.length} item(s) during decompile: ${details}`, - ); - } - - return outputUri; - } else if (result.kind === 'unsupported') { - await vscode.window.showErrorMessage( - `BPMNscript: "${sourceFileName}" contains an unsupported construct: ${result.message}`, - ); - return undefined; - } else { - await vscode.window.showErrorMessage( - `BPMNscript: Failed to decompile "${sourceFileName}": ${result.message}`, - ); - return undefined; } + return outputUri; }; } export function pickBpmnAndDecompileCommand( - decompile: (uri?: vscode.Uri) => Promise, + decompile: ConvertHandler, ): () => Promise { return async (): Promise => { const picked = await vscode.window.showOpenDialog({ diff --git a/packages/extension/src/extension/sidebar-view-provider.ts b/packages/extension/src/extension/sidebar-view-provider.ts index a21a3a9..87acf5a 100644 --- a/packages/extension/src/extension/sidebar-view-provider.ts +++ b/packages/extension/src/extension/sidebar-view-provider.ts @@ -4,8 +4,7 @@ import * as fs from 'node:fs'; import { randomBytes } from 'node:crypto'; import { swapExtension } from './conversion-core.js'; -// Types shared between host and webview (duplicated in sidebar.js as JSDoc). - +// The webview in media/sidebar.js reads this shape untyped: change both. interface Counterpart { uri: string; name: string; @@ -40,8 +39,8 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { webviewView.webview.html = this._buildHtml(webviewView.webview); - // retainContextWhenHidden is false, so the view is rebuilt on every show. - // Without collecting the disposable the listeners pile up. + // retainContextWhenHidden is false, so the view is rebuilt on every show + // and the listeners would pile up. const disposables: vscode.Disposable[] = []; webviewView.webview.onDidReceiveMessage( (message: unknown) => void this._handleMessage(message), diff --git a/packages/extension/test/bundled-conversion.e2e.test.ts b/packages/extension/test/bundled-conversion.e2e.test.ts index 4c48770..d9caab0 100644 --- a/packages/extension/test/bundled-conversion.e2e.test.ts +++ b/packages/extension/test/bundled-conversion.e2e.test.ts @@ -1,10 +1,7 @@ -// Run `npm run build` before this suite: it consumes the compiled out/ -// directories of language, transform, and extension. -// -// What conversion makes of a given input is `conversion-core.test.ts`. What is -// under test here is the one thing only a bundle can answer: whether the -// transform still finds its moddle asset once esbuild has flattened it into a -// single CommonJS file, which is how the extension actually ships. +// Needs `npm run build` first: it consumes the compiled out/ of language, +// transform and extension. Under test is the one thing only a bundle can +// answer: whether the transform still finds its moddle asset once esbuild has +// flattened it into a single CommonJS file, which is how the extension ships. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import * as fs from 'node:fs'; @@ -17,8 +14,7 @@ import * as esbuild from 'esbuild'; // @ts-ignore esbuild.mjs is a plain JS module with no type declarations. import { sharedBuildOptions, assetCopyPlugin } from '../esbuild.mjs'; -// Vitest transforms TS in place, so import.meta.url resolves to this source -// file, not to any out/ directory. +// Vitest transforms TS in place, so import.meta.url is this source file. const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -39,8 +35,8 @@ describe('bundled asset resolution and transform under the shim', () => { // Unique per run: parallel suites would otherwise collide on these paths. const runId = `${process.pid}-${Date.now()}`; const verifyEntryFile = path.join(os.tmpdir(), `verify-entry-${runId}.js`); - // Under out/extension/ so assetCopyPlugin lands operaton-moddle.json beside - // it whatever the cwd, which is what the import.meta.url shim then resolves. + // Under out/extension/ so assetCopyPlugin lands operaton-moddle.json where + // the import.meta.url shim resolves. const verifyOutfile = path.resolve( EXT_DIR, 'out', @@ -49,6 +45,8 @@ describe('bundled asset resolution and transform under the shim', () => { ); beforeAll(async () => { + // The only check on assetCopyPlugin's outdir branch: the build below takes + // the outfile branch, and the extension itself ships through outdir. const moddlePath = path.resolve( EXT_DIR, 'out', @@ -78,8 +76,7 @@ describe('bundled asset resolution and transform under the shim', () => { fs.writeFileSync(verifyEntryFile, entrySource, 'utf-8'); - // nodePaths is required: the entry lives in /tmp/, so esbuild's resolution - // walk finds no node_modules and cannot resolve @bpmn-script/transform. + // The entry lives in /tmp/, where esbuild's node_modules walk finds nothing. await esbuild.build({ ...sharedBuildOptions, entryPoints: [verifyEntryFile], @@ -90,8 +87,7 @@ describe('bundled asset resolution and transform under the shim', () => { }, 60_000); afterAll(() => { - // Only the two per-run files; out/extension/operaton-moddle.json belongs to - // the production build and must survive. + // out/extension/operaton-moddle.json belongs to the extension build. if (fs.existsSync(verifyEntryFile)) fs.unlinkSync(verifyEntryFile); if (fs.existsSync(verifyOutfile)) fs.unlinkSync(verifyOutfile); }); diff --git a/packages/extension/test/conversion-core.test.ts b/packages/extension/test/conversion-core.test.ts index c2d0942..9633c05 100644 --- a/packages/extension/test/conversion-core.test.ts +++ b/packages/extension/test/conversion-core.test.ts @@ -1,6 +1,5 @@ -// The editor-independent half of the conversion commands: what each direction -// makes of a given input. `conversion.test.ts` covers what the VS Code adapter -// then shows the author. +// What each conversion direction makes of a given input; what the VS Code +// adapter then shows the author is `conversion.test.ts`. import { describe, expect, test, beforeAll } from 'vitest'; import * as fs from 'node:fs'; @@ -41,16 +40,6 @@ const BAD_SERVICE_TASK_BPMN = path.resolve( 'tests/golden/bad-service-task-no-binding.bpmn', ); -for (const [label, p] of [ - ['invoice-approval.bpmnscript', INVOICE_APPROVAL_SRC], - ['invoice-approval-generated.bpmn', GOLDEN_GENERATED_BPMN], - ['bad-service-task-no-binding.bpmn', BAD_SERVICE_TASK_BPMN], -] as const) { - if (!fs.existsSync(p)) { - throw new Error(`Fixture not found: ${label} at ${p}`); - } -} - let validate: ReturnType>; beforeAll(() => { @@ -58,7 +47,6 @@ beforeAll(() => { validate = validationHelper(services.BpmnScript); }); -/** Every substring must appear, so a message that stops naming one fails. */ function expectMentions(text: string, mentions: readonly string[]): void { for (const mention of mentions) { expect(text, `expected to find "${mention}" in: ${text}`).toContain( @@ -67,10 +55,8 @@ function expectMentions(text: string, mentions: readonly string[]): void { } } -/** - * A diagnostic minus its wording: the positions, the severity and the source - * text are this module's own mapping, the sentence belongs to the validator. - */ +// Minus the wording: the positions, severity and source text are this module's +// own mapping, the sentence belongs to the validator. type DiagnosticPosition = Omit; type CompileOutcome = @@ -91,7 +77,6 @@ describe('compileDslToBpmn', () => { { ok: true, reimportsAs: 'invoice-approval' }, ], [ - // String `name` in a numeric comparison: a severity-1 diagnostic. 'a type mismatch blocks the compile and reports the comparison it rejected', `process p {\n var name: string\n if (name > 1000) { user A }\n}\n`, { @@ -99,7 +84,6 @@ describe('compileDslToBpmn', () => { kind: 'validation', diagnostics: [ { - // 0-based, LSP convention. line: 2, character: 6, endLine: 2, @@ -111,7 +95,6 @@ describe('compileDslToBpmn', () => { }, ], [ - // Uses `amount` without declaring it: severity 2, which must not block. 'an undeclared variable is only a warning, so the source still compiles', `process p { if (amount > 1000) { user A } }`, { ok: true, reimportsAs: 'p' }, @@ -129,8 +112,7 @@ describe('compileDslToBpmn', () => { if (result.ok) return; expect(result.kind).toBe(expected.kind); - // The union type alone does not stop the adapter writing result.output if a - // kind check goes missing, so assert the field is absent. + // A failed result carries nothing the adapter could write. expect('output' in result).toBe(false); if (result.kind !== 'validation') return; expect( @@ -141,9 +123,8 @@ describe('compileDslToBpmn', () => { ); }); - // `bpmn-auto-layout`'s grid solver throws on this validator-clean `goto` - // restructuring (a mixed true/false/expression `else if` chain feeding one - // `goto` each); the source is otherwise unremarkable. + // `bpmn-auto-layout`'s grid solver throws on this validator-clean shape: a + // mixed true/false/expression `else if` chain feeding one `goto` each. test('a layouter crash on a validator-clean goto graph still compiles, without a diagram, and reports why', async () => { const source = `process p { if (true) { @@ -170,8 +151,6 @@ describe('compileDslToBpmn', () => { }); }); -// `formHandlerClass` and the lane are both dropped without loss of -// behavior, so `xmlToIr` warns instead of refusing. const LANE_AND_ASYNC_ATTR_BPMN = ` { result.warnings.map((w) => w.message).join('\n'), expected.mentions, ); - // Whatever it accepts, it must hand back a script the compiler accepts. - // Parsing alone is not that bar: it takes source the validator refuses. - // Severity 1 is the bar the compile direction blocks on, so it is the - // bar the output has to clear; a BPMN carrying no variable declarations - // decompiles to a script that warns about them. + // Parsing alone accepts source the validator refuses, so the output has + // to clear what the compile direction blocks on: no severity-1 + // diagnostic (a BPMN declares no variables, so warnings are expected). const { diagnostics } = await validate(result.output); expect( diagnostics.filter((d) => d.severity === 1).map((d) => d.message), diff --git a/packages/extension/test/conversion.test.ts b/packages/extension/test/conversion.test.ts index ce573d8..bf5ef8a 100644 --- a/packages/extension/test/conversion.test.ts +++ b/packages/extension/test/conversion.test.ts @@ -1,11 +1,11 @@ -// `vscode` is injected by the extension host, not installed from npm, so it -// has to be mocked with the surface the adapter touches. The conversion itself -// is mocked too: what is under test is the notification each outcome composes. +// `vscode` is injected by the extension host, not installed from npm, so it is +// mocked with the surface the adapter touches. The conversion is mocked too: +// under test is what each outcome shows the author. import { beforeEach, describe, expect, test, vi } from 'vitest'; -// vi.mock factories are hoisted above the module body, so a plain top-level -// const would still be in its temporal dead zone here. vi.hoisted is required. +// vi.mock factories are hoisted above the module body, where a plain top-level +// const is still in its temporal dead zone. const mocks = vi.hoisted(() => ({ showWarningMessage: vi.fn(), showErrorMessage: vi.fn(), @@ -69,7 +69,6 @@ function occurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } -/** The first argument of every notification the run raised, by severity. */ function notifications(): Record<'info' | 'warning' | 'error', string[]> { const firstArgs = (fn: { mock: { calls: unknown[][] } }): string[] => fn.mock.calls.map((call) => String(call[0])); @@ -80,20 +79,16 @@ function notifications(): Record<'info' | 'warning' | 'error', string[]> { }; } -/** The fsPath of every document the run opened, in call order. */ function shownDocuments(): string[] { return mocks.showTextDocument.mock.calls.map( (call) => (call[0] as vscode.Uri).fsPath, ); } -/** The first argument of every editor command the run executed. */ function executedCommands(): string[] { return mocks.executeCommand.mock.calls.map((call) => String(call[0])); } -// Neither message names its own element: they say "here" and leave the id to -// the caller, so unrendered they arrive as one line repeated. const SAME_WORDING = 'The model weighs the route on from here with a condition, ' + 'which the script leaves out.'; @@ -102,19 +97,16 @@ type Expected = { info?: string[]; warning?: string[]; error?: string[]; - /** The uri the command returns, or undefined where it gives up. */ returns: string | undefined; - /** Every document opened via showTextDocument, in call order. */ shown?: string[]; - /** Every editor command executed, in call order. */ executed?: string[]; }; type Row = readonly [ title: string, command: 'compile' | 'decompile', - /** Basename of the source file the row hands the command. */ - sourceName: string, + // undefined hands the command no uri, with no active editor to fall back on. + sourceName: string | undefined, // undefined means the guard refuses the file before the core ever runs. result: CompileResult | DecompileResult | undefined, expected: Expected, @@ -127,33 +119,7 @@ describe('conversion commands: what the author is shown', () => { test.each([ [ - 'a decompile that dropped something names the file once and the dropped item after it', - 'decompile', - 'example.bpmn', - { - ok: true, - output: 'process P { start S end E }', - warnings: [ - { - elementId: 'Task1', - category: 'extensionAttribute', - message: - "The 'formHandlerClass' setting on 'Task1' was not imported", - }, - ], - }, - { - info: ['BPMNscript: Decompiled "example.bpmn" -> "example.bpmnscript"'], - warning: [ - 'BPMNscript: "example.bpmn" reported 1 item(s) during decompile: ' + - "Task1: The 'formHandlerClass' setting on 'Task1' was not imported", - ], - returns: '/tmp/example.bpmnscript', - shown: ['/tmp/example.bpmnscript'], - }, - ], - [ - 'two same-worded warnings are told apart by the element each is about', + 'a decompile that dropped things names the file once and each dropped item by its element', 'decompile', 'example.bpmn', { @@ -263,6 +229,18 @@ describe('conversion commands: what the author is shown', () => { returns: undefined, }, ], + [ + 'compile with nothing selected and no editor open says so and stops', + 'compile', + undefined, + undefined, + { + warning: [ + 'BPMNscript: No file selected. Open a .bpmnscript file or select one in the Explorer.', + ], + returns: undefined, + }, + ], [ 'compile takes an upper-case extension', 'compile', @@ -290,7 +268,11 @@ describe('conversion commands: what the author is shown', () => { handler = decompileCommand(); } - const returned = await handler(vscode.Uri.file(`/tmp/${sourceName}`)); + const returned = await handler( + sourceName === undefined + ? undefined + : vscode.Uri.file(`/tmp/${sourceName}`), + ); const { info = [], @@ -307,10 +289,12 @@ describe('conversion commands: what the author is shown', () => { const core = command === 'compile' ? compileDslToBpmn : decompileBpmnToDsl; expect(core).toHaveBeenCalledTimes(result === undefined ? 0 : 1); - // The file is named once per line. The success line is the exception: it + // The file is named once per line; the success line is the exception, it // names the file it read and the file it wrote. - for (const message of [...warning, ...error]) { - expect(occurrences(message, sourceName)).toBe(1); + if (sourceName !== undefined) { + for (const message of [...warning, ...error]) { + expect(occurrences(message, sourceName)).toBe(1); + } } }); }); diff --git a/packages/extension/test/injection-grammar.test.ts b/packages/extension/test/injection-grammar.test.ts index d4dc5f6..0cae1ac 100644 --- a/packages/extension/test/injection-grammar.test.ts +++ b/packages/extension/test/injection-grammar.test.ts @@ -1,7 +1,6 @@ -// These tests run the grammar's begin/end patterns through JS RegExp instead of -// a TextMate engine. That only holds because the patterns stick to constructs -// JS and Oniguruma agree on: literal backticks, alternation, \s, $, character -// classes. Add an Oniguruma-only construct and this file stops modeling VS Code. +// The begin/end patterns run through JS RegExp instead of a TextMate engine, +// which holds only while they stick to constructs JS and Oniguruma agree on +// (literal backticks, alternation, \s, $, character classes). import { describe, expect, it } from 'vitest'; import { readFileSync } from 'node:fs'; @@ -9,8 +8,8 @@ import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { SCRIPT_FORMAT_ALIASES } from '@bpmn-script/language'; -// The TextMate scope each canonical script format embeds. A format missing here -// has no installed grammar, and every fence tag reaching it must say so below. +// A format missing here has no installed grammar, and every tag reaching it +// must be listed in NO_INSTALLED_GRAMMAR. const EMBEDDED_SCOPE_BY_FORMAT: Readonly> = { javascript: 'source.js', python: 'source.python', @@ -18,7 +17,7 @@ const EMBEDDED_SCOPE_BY_FORMAT: Readonly> = { groovy: 'source.groovy', }; -// Tags deliberately left unhighlighted: VS Code ships no grammar for them. +// VS Code ships no grammar for these. const NO_INSTALLED_GRAMMAR = new Set(['feel', 'juel']); const EXTENSION_DIR = path.resolve( @@ -54,32 +53,19 @@ function matchFence(line: string): { name: string; rule: any } | undefined { } describe('fenced-script injection grammar', () => { - it('injects into source.bpmn-script and is registered with injectTo', () => { + it('is registered against source.bpmn-script at a path build:prepare copies it to', () => { expect(injection.injectionSelector).toContain('source.bpmn-script'); const entry = pkg.contributes.grammars.find( (g: { scopeName: string }) => g.scopeName === injection.scopeName, ); expect(entry).toBeDefined(); - expect(entry.injectTo).toContain('source.bpmn-script'); - expect(entry.path).toBe('syntaxes/bpmn-script.injection.tmLanguage.json'); - }); - - it('build:prepare copies the injection asset into the runtime syntaxes dir', () => { - expect(pkg.scripts['build:prepare']).toContain( - './injection/bpmn-script.injection.tmLanguage.json', - ); + expect(entry.injectTo).toEqual(['source.bpmn-script']); expect(pkg.scripts['build:prepare']).toContain( - './syntaxes/bpmn-script.injection.tmLanguage.json', + `cp -f ./injection/bpmn-script.injection.tmLanguage.json ./${entry.path}`, ); expect(() => - readFileSync( - path.join( - EXTENSION_DIR, - 'syntaxes', - 'bpmn-script.injection.tmLanguage.json', - ), - ), + readFileSync(path.join(EXTENSION_DIR, entry.path)), ).not.toThrow(); }); diff --git a/packages/extension/test/language-configuration.test.ts b/packages/extension/test/language-configuration.test.ts index cc48433..f9d8c01 100644 --- a/packages/extension/test/language-configuration.test.ts +++ b/packages/extension/test/language-configuration.test.ts @@ -1,19 +1,16 @@ -// language-configuration.json is JSONC (line comments), which JSON.parse -// rejects. Stripping whole comment lines, not a trailing `//`, keeps the -// "lineComment": "//" string value intact. - import { describe, expect, it } from 'vitest'; import { readFileSync } from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { EmptyFileSystem, GrammarAST, GrammarUtils } from 'langium'; -import { createBpmnScriptServices } from '@bpmn-script/language'; +import { ID_TERMINAL } from '@bpmn-script/language'; const EXTENSION_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', ); +// JSONC: whole comment lines are stripped, not a trailing `//`, which would +// eat the "lineComment": "//" value. function readWordPattern(): unknown { const raw = readFileSync( path.join(EXTENSION_DIR, 'language-configuration.json'), @@ -24,14 +21,6 @@ function readWordPattern(): unknown { describe('language configuration', () => { it("the editor's word pattern is the grammar's ID terminal", () => { - const { Grammar } = createBpmnScriptServices(EmptyFileSystem).BpmnScript; - const idRule = Grammar.rules.find( - (rule) => GrammarAST.isTerminalRule(rule) && rule.name === 'ID', - ) as GrammarAST.TerminalRule | undefined; - if (!idRule) { - throw new Error("grammar has no 'ID' terminal rule"); - } - - expect(readWordPattern()).toBe(GrammarUtils.terminalRegex(idRule).source); + expect(readWordPattern()).toBe(ID_TERMINAL.source); }); }); diff --git a/packages/language/README.md b/packages/language/README.md index 9ed31c5..21eb765 100644 --- a/packages/language/README.md +++ b/packages/language/README.md @@ -15,7 +15,6 @@ The package also owns the base VS Code TextMate grammar (`syntaxes/bpmn-script.t ## The language -One `process` block per file. Steps run top to bottom, so sequence flow is never written out, and control flow uses structured statements instead. Optional `var` declarations go in the process header, before the body, alongside the header's own settings: `versionTag`, which labels the deployed process definition, `historyTimeToLive`, and `candidateStarterUsers` and `candidateStarterGroups`, whose entries are listed as candidate starters (Tasklist's process list; `BpmnDeployer.addAuthorizations` stores each entry as written). `historyTimeToLive` is the number of days the engine keeps a finished instance's history, written `PD` or ``, and an absent value compiles to `P30D`, since `HistoryTimeToLiveParser.parseAndValidate` refuses a null value under the engine's default `enforceHistoryTimeToLive`; the cleanup itself runs only once its batch window is configured. @@ -36,10 +35,9 @@ process invoice-approval { } ``` -Every targetable statement carries an explicit id, which is what `goto` and boundary events refer to. An id is ASCII only: a letter or `_`, then any run of letters, digits and `_`, with single hyphens allowed between such runs (the `ID` terminal in the grammar). -A step, a call and a sub-process derive the BPMN `name` from that id (`ReviewInvoice` becomes "Review Invoice") unless a `label` setting gives one instead: `user ReviewInvoice(label: "Review invoice")`; a `start` and an `end` are named by `label` alone, and `on`, `throw`, `emit` and `await` show no name at all, except the two ends of a link pair, which carry the link name. -A `documentation` setting carries free-form text alongside it, spelled the same way: `user ReviewInvoice(documentation: "Escalate to the senior approver above 1000.")`. +A step, a call and a sub-process derive the BPMN `name` from that id (`ReviewInvoice` becomes "Review Invoice") unless a `label` setting gives one instead: `user ReviewInvoice(label: "Review invoice")`; a `start` and an `end` are named by `label` alone, and `on`, `throw`, `emit` and `await` show no name at all. +A `documentation` setting carries free-form text alongside `label`, spelled the same way: `user ReviewInvoice(documentation: "Escalate to the senior approver above 1000.")`. ### Statements @@ -108,42 +106,6 @@ A user task names its deployed form with `formKey` or with `formRef`, never both `formRef` also needs `binding: latest`, `binding: deployment`, or `version: ` beside it, the same version-pinning a `call` or a `decide` step carries, since the engine refuses to deploy a form reference it cannot resolve a version for. `version` is a positive whole number, quoted or not, or an expression yielding one, on a `formRef`, a `call`, and a `decide` step alike: the engine parses it as an integer when the step runs (`BaseCallableElement.getVersion`, `TaskEntity.initializeFormRefFromTaskDefinition`), so `1.5` deploys and then fails the instance. -#### Job settings on a gateway - -An `if`, a `while`, a `do...while`, a `parallel`, and a multi-branch `await` each synthesize a gateway with no name to hang a setting on. -Its five settings sit in a parens on the statement head instead, right where an ordinary element's settings sit. -`if`, `parallel`, and a multi-branch `await` also synthesize a join beside the split, and the join's settings share the same parens under a `join`-prefixed spelling: `joinAsyncBefore`, `joinAsyncAfter`, `joinExclusive`, `joinJobPriority`, `joinRetryCycle`. -An `if` chain lowers to one exclusive-gateway split no matter how many `else if` branches it carries, so the head parens govern the whole chain and an `else if` head takes none of its own. - -```bpmnscript -process order-fulfillment { - var amount: number - - start OrderPlaced - - if (amount > 10000) (asyncBefore: true, joinAsyncBefore: true, joinJobPriority: 5) { - user AuditReview(assignee: "auditor") - } else { - user SkipAudit(assignee: "clerk") - } - - parallel (asyncBefore: true, exclusive: true, joinAsyncAfter: true) { - { - user PackItems - } - { - user PrintLabel - } - } - - end OrderShipped -} -``` - -A `while` or a `do...while` has one gateway, so a `join`-prefixed key on either is an error naming the plain spelling to write instead. -A multi-branch `await` head refuses `asyncAfter`: `BpmnParse.parseEventBasedGateway` refuses the flag on the gateway it deploys, so the setting has to move onto whichever branch trigger should carry it. -A `join`-prefixed setting on a statement whose branches all end their own path is a warning rather than an error, since the join it would set is never reached. - #### Forms A process's own `start` and a `user` task each take one `form` block, `operaton:formData` on the wire, which Tasklist renders as one input per field and which fills one process variable per field when the form is submitted. @@ -256,7 +218,11 @@ process notify-ops { ``` A mail task needs `to` and one of `text` or `html`; `cc` and `bcc` satisfy neither. +`from` sets the sender address; left unset, `MailActivityBehavior` falls back to the process engine's configured default sender. +`charset` sets the message's character encoding. A shell task needs `command`. +`arg1` through `arg5` are positional arguments appended after `command`, in order; `ShellActivityBehavior` skips any left unset. +`directory` sets the working directory the command runs in; left unset, it runs in the engine process's own. Every shell field is a quoted literal, never a `"${...}"` expression, and its three flags, `wait`, `redirectError`, and `cleanEnv`, are written `true` or `false`. A shell task's output has no `resultVariable` of its own to hold it. `field outputVariable = "..."` names the process variable the command's stdout is written to, and `field errorCodeVariable = "..."` the one its exit code is written to. @@ -493,7 +459,6 @@ A second `else` branch on one statement is rejected, and so is an `else` on a st ### The event layer -The event layer reads like try/catch. An `on { }` handler is a catch block, sitting at the end of the body it guards, the way a catch block sits after its try body: `on error(PAYMENT_FAILED) { ... }`. That body is a process, a `subprocess` or `attempt` block, or another handler's body, since BPMN lets one event sub-process nest inside another. @@ -809,6 +774,42 @@ The `error` heading a mapping is the same soft trigger word every code site writ They're among the most common variable and property names a Java-background author reaches for, which is why they stay available. The editor still highlights and completes them, but only in the positions where they carry event meaning. +### Job settings on a gateway + +An `if`, a `while`, a `do...while`, a `parallel`, and a multi-branch `await` each synthesize a gateway with no name to hang a setting on. +Its five settings sit in a parens on the statement head instead, right where an ordinary element's settings sit. +`if`, `parallel`, and a multi-branch `await` also synthesize a join beside the split, and the join's settings share the same parens under a `join`-prefixed spelling: `joinAsyncBefore`, `joinAsyncAfter`, `joinExclusive`, `joinJobPriority`, `joinRetryCycle`. +An `if` chain lowers to one exclusive-gateway split no matter how many `else if` branches it carries, so the head parens govern the whole chain and an `else if` head takes none of its own. + +```bpmnscript +process order-fulfillment { + var amount: number + + start OrderPlaced + + if (amount > 10000) (asyncBefore: true, joinAsyncBefore: true, joinJobPriority: 5) { + user AuditReview(assignee: "auditor") + } else { + user SkipAudit(assignee: "clerk") + } + + parallel (asyncBefore: true, exclusive: true, joinAsyncAfter: true) { + { + user PackItems + } + { + user PrintLabel + } + } + + end OrderShipped +} +``` + +A `while` or a `do...while` has one gateway, so a `join`-prefixed key on either is an error naming the plain spelling to write instead. +A multi-branch `await` head refuses `asyncAfter`: `BpmnParse.parseEventBasedGateway` refuses the flag on the gateway it deploys, so the setting has to move onto whichever branch trigger should carry it. +A `join`-prefixed setting on a statement whose branches all end their own path is a warning rather than an error, since the join it would set is never reached. + ### Expressions Condition expressions are parsed as a real AST over the JUEL native subset: integer, decimal, string, boolean, and null literals; variable references with dot-property and index accessors; unary `!` and `-`; binary arithmetic and comparison; logical `&&` and `||`; the ternary `? :`; and parentheses. @@ -822,6 +823,7 @@ Validation beyond syntax lives in `src/bpmn-script-validator.ts`, which is the s The categories it covers: - Variables: an undeclared reference (warning), a type mismatch against the declared `var`, a name declared twice, a JUEL keyword or a hyphen in a name a rendered expression carries, a JUEL keyword read as a property inside a `"${...}"` template (`"${order.and}"`, outside its string literals), a condition whose static type is not boolean, and a composite `"${...} ${...}"` template under an operator. + The reserved words are `empty`, `div`, `mod`, `not`, `and`, `or`, `le`, `lt`, `eq`, `ne`, `ge`, `gt`, and `instanceof`; the literal words, reserved the same way, are `true`, `false`, and `null`. - Tasks: a duplicate attribute key, a `service`, `send`, or `decide` task without exactly one binding attribute, a binding value in the wrong shape (quoted text under `expression`, `delegate`, or `mapperDelegate`, a template under `class` or `mapper`, an empty `class`, `expression`, `delegate`, `mapper`, `mapperDelegate`, `topic`, `decision`, or `process`), a `resultVariable` beside a `class` or `delegate` binding, which the engine refuses to deploy, or beside `topic` or `type`, which nothing writes (warning), a `mapDecisionResult` outside the four result mappings or without a `resultVariable` to map into (warning), `binding`, `version`, or `mapDecisionResult` on a `decide` step with no `decision`, a `script` task with an unsupported fence tag or an empty or unterminated body. A user task adds its own: a `priority` that is neither an integer nor a `"${...}"` expression, and a `dueDate` or `followUpDate` constant that is neither a `P...` period nor an ISO date-time. A `version` on a `formRef`, a `call`, or a `decide` step that is not a positive whole number or an expression is refused on all three alike. diff --git a/packages/language/package.json b/packages/language/package.json index fd2d8be..1f8424b 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -2,6 +2,12 @@ "name": "@bpmn-script/language", "description": "The language specific package", "version": "0.0.1", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/BPMNscript/bpmnscript.git", + "directory": "packages/language" + }, "type": "module", "engines": { "node": ">=22.0.0", diff --git a/packages/language/src/bpmn-script-completion.ts b/packages/language/src/bpmn-script-completion.ts index 603d7be..0b1c411 100644 --- a/packages/language/src/bpmn-script-completion.ts +++ b/packages/language/src/bpmn-script-completion.ts @@ -34,10 +34,14 @@ import { isSubProcess, isThrowStatement, isVarRef, + CodeDecl, + ErrorMapping, + Listener, + LiteralBool, + OnHandler, + VarRef, type Block, - type OnHandler, type ParenItem, - type VarRef, } from './generated/ast.js'; import type { BpmnScriptServices } from './bpmn-script-module.js'; import { @@ -112,30 +116,17 @@ const SCRIPT_LANGUAGES = [ ...new Set(Object.values(SCRIPT_FORMAT_ALIASES)), ].join(','); -/** - * The words of `triggers` whose payload is a declared name, as a snippet choice - * list. Their scaffold writes the name bare, so it reads as the cross-reference - * it is. - */ const declaredCodeChoices = (triggers: readonly string[]): string => triggers.filter((word) => DECLARED_CODE_TRIGGERS.has(word)).join(','); -/** - * The words whose payload is the name the engine keys a subscription by. It - * declares nothing, so the scaffold quotes it. - */ const subscriptionChoices = (triggers: readonly string[]): string => triggers .filter((word) => namesACode(word) && !DECLARED_CODE_TRIGGERS.has(word)) .join(','); /** - * Snippet bodies for the structural keywords, keyed by keyword text. Accepting - * one scaffolds the whole construct so the caret lands inside the body, where - * the next completions are already offered. Placeholders are LSP snippet - * syntax: `$1` tab stops, `$0` final caret, `${n:default}`, `${n|a,b|}` - * choices. A keyword absent here keeps the default bare-keyword completion, and - * one opening several constructs lists a form per shape. + * Keyed by keyword; accepting one scaffolds the whole construct and leaves the + * caret inside it. A keyword absent here keeps Langium's bare-keyword item. */ const STRUCTURE_SNIPPETS: Readonly< Record @@ -167,11 +158,9 @@ const STRUCTURE_SNIPPETS: Readonly< ], subprocess: 'subprocess ${1:id} {\n\t$0\n}', attempt: 'attempt ${1:id} {\n\t$0\n}', - // Each event word takes one of two payloads, so each keyword lists a form per - // payload: a code names a declaration in the process header, a subscription - // carries its own quoted name. A trigger reading a timer or a condition - // instead is offered at the bare ID position. The host is a cross-reference, - // so no hosted variant is scaffolded. + // A code names a header declaration and is written bare; a subscription name + // is quoted. A timer or condition payload is scaffolded at the trigger word + // instead, and the host is a cross-reference, so neither gets a form here. on: [ { label: 'on', @@ -212,8 +201,6 @@ const STRUCTURE_SNIPPETS: Readonly< 'emit ${1|' + subscriptionChoices(EMIT_TRIGGERS) + '|}("${2:NAME}")', }, ], - // The second form waits on several triggers at once and continues down the - // one that fires first. await: [ { label: 'await', @@ -231,7 +218,6 @@ const STRUCTURE_SNIPPETS: Readonly< }, ], call: 'call ${1:id}(process: "${2:process-id}") {\n\tin ${3:input}\n\tout ${4:result}\n}', - // Offered at the position after a statement's name, not as a setting. for: [ { label: 'for each', insertText: 'for each ${1:item} in ${2:collection}' }, { label: 'for', insertText: 'for ${1:3}' }, @@ -247,7 +233,6 @@ const ENGINE_VALUE_SNIPPETS: Readonly> = { retryCycle: '"${1:R3/PT10M}"', }; -/** @param keys The engine keys to spell through `keyOf`; a carrier may take fewer than all. */ const engineSnippets = ( keyOf: (key: string) => string, keys: readonly EngineKey[] = ENGINE_KEYS, @@ -260,10 +245,9 @@ const engineSnippets = ( ); /** - * Snippet bodies for the settings an element's parens can hold. These lex as - * plain identifiers, so the default completion offers nothing for them. The - * `\$` escapes keep an EL `${...}` literal instead of opening a nested - * placeholder. + * Setting keys lex as plain identifiers, so the default completion offers + * nothing for them. The `\$` escapes keep an EL `${...}` literal instead of + * opening a nested placeholder. */ const SETTING_SNIPPETS: Readonly> = { label: 'label: "${1:label}"', @@ -304,8 +288,7 @@ const SETTING_SNIPPETS: Readonly> = { candidateStarterUsers: 'candidateStarterUsers: "${1:demo,manager}"', candidateStarterGroups: 'candidateStarterGroups: "${1:adjusters}"', initiator: 'initiator: "${1:starter}"', - // A form field's parens. The two flags are on while written, so neither - // scaffolds a choice. + // The two form flags are on while written, so neither scaffolds a choice. required: 'required: true', readonly: 'readonly: true', min: 'min: ${1:0}', @@ -317,20 +300,15 @@ const SETTING_SNIPPETS: Readonly> = { }; /** - * A handler binds what the event it caught carries to variables of its own, so - * the value is a name the handler introduces rather than one it looks up. The - * same word means something else as a setting: `message` on a receive task - * names the subscription the engine waits on. + * A catch binding names a variable the handler declares, so it is bare; the + * same `message` key on a receive task is the quoted subscription name. */ const CATCH_BINDING_SNIPPETS: Readonly> = { code: 'code: ${1:code}', message: 'message: ${1:message}', }; -/** - * A declaration's fields hold the text the event is made of, so both are - * quoted where a handler's binding of the same name is a bare variable. - */ +/** A declaration's fields are text, so both are quoted. */ const DECLARATION_FIELD_SNIPPETS: Readonly> = { code: 'code: "${1:code}"', message: 'message: "${1:message}"', @@ -342,7 +320,7 @@ const settingForms = ( ): StructureForm[] => keys.map((key) => ({ label: key, insertText: snippets[key] })); -/** The words a setting's value slot offers, keyed by the setting; a quoted choice inserts its quotes. */ +/** The words a value slot offers; a quoted choice inserts its quotes. */ const SETTING_VALUE_CHOICES: Readonly> = { [TYPE_BINDING_KEY]: TYPE_BINDING_VALUES.map((value) => `"${value}"`), binding: CALL_BINDING_VALUES, @@ -356,27 +334,22 @@ const BOOLEAN_SETTING_KEYS: ReadonlySet = new Set( const LITERAL_WORDS: ReadonlySet = new Set(JUEL_LITERAL_WORDS); /** The grammar rule of the two boolean words; `null` has one of its own. */ -const BOOLEAN_LITERAL_RULE = 'LiteralBool'; +const BOOLEAN_LITERAL_RULE = LiteralBool.$type; /** - * The keys naming a timer's date and cycle. A duration is the bare payload the - * trigger word's own snippet already scaffolds, so it is not offered again. + * A duration is the bare payload the trigger word's snippet scaffolds, so only + * the date and cycle keys are offered. */ const TIMER_KEY_SNIPPETS: Readonly> = { [TIMER_PARTICLE_BY_KIND.date]: `${TIMER_PARTICLE_BY_KIND.date}: "\${1:2026-08-01T09:00:00}"`, [TIMER_PARTICLE_BY_KIND.cycle]: `${TIMER_PARTICLE_BY_KIND.cycle}: "\${1:R/PT10M}"`, }; -/** The keys a timer trigger takes, wherever one is written. */ -function timerKeyForms(node: AstNode): StructureForm[] { - if (triggerWordOf(node) !== 'timer') return []; - return Object.entries(TIMER_KEY_SNIPPETS).map(([label, insertText]) => ({ - label, - insertText, - })); -} +const timerKeyForms = (node: AstNode): StructureForm[] => + triggerWordOf(node) === 'timer' + ? settingForms(Object.keys(TIMER_KEY_SNIPPETS), TIMER_KEY_SNIPPETS) + : []; -/** The bindings a handler catching an error or an escalation takes. */ function catchBindingForms(node: AstNode): StructureForm[] { if ( !isOnHandler(node) || @@ -389,15 +362,13 @@ function catchBindingForms(node: AstNode): StructureForm[] { ); } -/** The items written in `owner`'s parens; a kind without parens has none. */ const itemsOf = (owner: AstNode): ParenItem[] => (owner as { items?: ParenItem[] }).items ?? []; /** - * Whether the unkeyed slot of `owner`'s parens has to be filled before a - * setting is legal there: a subscription name, a timer clause, a condition, - * and the code a throw or emit names, which is required even where a handler - * may catch every code. + * Whether a setting is legal only after the unkeyed payload. A throw or emit + * must name its code even where a handler of the same trigger may catch every + * code. */ function payloadRequired(owner: AstNode): boolean { const trigger = triggerWordOf(owner); @@ -414,9 +385,8 @@ function payloadRequired(owner: AstNode): boolean { } /** - * Whether the unkeyed slot is written before the caret's token, bare or as a - * timer's keyed clause. A word still being typed there is that slot, not - * something after it. + * Keyed on the payload's CST end against the caret's token, not the caret: a + * word still being typed at the slot is that slot, not something after it. */ function payloadWritten(owner: AstNode, context: CompletionContext): boolean { const items = itemsOf(owner); @@ -430,10 +400,6 @@ function payloadWritten(owner: AstNode, context: CompletionContext): boolean { const payloadOpen = (owner: AstNode, context: CompletionContext): boolean => payloadRequired(owner) && !payloadWritten(owner, context); -/** - * Whether the caret opens the condition an event's parens take, the one item - * start an expression belongs at. - */ function conditionSlotOpen(context: CompletionContext): boolean { const owner = owningElement(context); const trigger = owner === undefined ? undefined : triggerWordOf(owner); @@ -446,9 +412,8 @@ function conditionSlotOpen(context: CompletionContext): boolean { } /** - * The bare words `node`'s parens take. The one flag any rule lists is the - * handler's `alongside`, so the list is offered whole where the trigger's - * rule lets a handler run alongside its host. + * The only flag any rule lists is the handler's `alongside`, so the list is + * offered whole where the trigger admits it. */ function flagWordsFor(node: AstNode): readonly string[] { const trigger = triggerWordOf(node); @@ -458,21 +423,17 @@ function flagWordsFor(node: AstNode): readonly string[] { } /** - * The settings the parens of `node` take, in the order they are offered, or - * `undefined` where `node` has no parens of its own. The keys come from the - * element's own row, so a kind that takes no label is offered none; a listener - * carries a list of its own, being a callback on the element rather than one - * of its settings. The `run` keys are offered only with a `for` clause, which - * the validator requires for them. + * `undefined` where `node` has no parens of its own; `owningElement` keys on + * that. The `run` keys need the `for` clause the validator requires for them. */ function settingFormsFor(node: AstNode): StructureForm[] | undefined { - if (node.$type === 'Process') { + if (isProcess(node)) { return settingForms(PROCESS_HEADER_KEYS); } - if (node.$type === 'Listener') { + if (isListener(node)) { return settingForms(LISTENER_BINDING_KEYS); } - if (node.$type === 'FormField') { + if (isFormField(node)) { return settingForms(FORM_FIELD_SETTING_KEYS); } if (isCodeDecl(node)) { @@ -490,8 +451,7 @@ function settingFormsFor(node: AstNode): StructureForm[] | undefined { } const rule = attributeBlockRuleOf(node); if (!rule) return undefined; - // A throw or emit owns the implementation bindings, which only a thrown - // message carries. + // The implementation bindings belong to a thrown or emitted message alone. const own = (isThrowStatement(node) || isEmitStatement(node)) && node.trigger !== THROW_BINDING_TRIGGER @@ -510,10 +470,7 @@ function settingFormsFor(node: AstNode): StructureForm[] | undefined { const writtenKeysOf = (owner: AstNode): ReadonlySet => new Set(settingsOf(itemsOf(owner)).map((setting) => setting.key)); -/** - * Whether the parens name a binding the engine injects a field into. A - * listener's fenced script binds it in place of its settings and takes none. - */ +/** A listener's fenced script binds it in place of its settings, so no field. */ function bindsAField(owner: AstNode): boolean { if (isListener(owner) && owner.script !== undefined) return false; const keys = writtenKeysOf(owner); @@ -528,11 +485,10 @@ const mappingOffered = (owner: AstNode): boolean => (attributeBlockRuleOf(owner)?.externalExtras ?? false) && bindsATopic(owner); /** - * The directions a member of `owner`'s block is written with. A listener's - * block holds injected fields alone, for the reason - * `BpmnScriptValidator.checkListenerFields` states, and a form field's holds - * properties alone, so neither has a row to read. A field and a property each - * ride one binding, so they are offered once that binding is written. + * A listener's block holds fields alone, for the reason + * `BpmnScriptValidator.checkListenerFields` gives, and a form field's holds + * properties alone, so neither has a rule row; a field or a property is + * offered once the binding it rides is written. */ function parameterDirectionsOf(owner: AstNode): readonly string[] { if (isListener(owner)) { @@ -553,21 +509,17 @@ function parameterDirectionsOf(owner: AstNode): readonly string[] { } /** - * Whether the caret sits in `owner`'s body rather than in its member block. - * The two open with the same brace and the parser reads the first as the body, - * so a member offered there would land in the body, where it belongs to + * On an element with a body, a lone brace after its head opens the body, not + * the member block, and `Block` parses a parameter there as readily as the + * member block does, so none is offered in the body, where it belongs to * nothing. */ const inBodyOf = (owner: AstNode, context: CompletionContext): boolean => AstUtils.getContainerOfType(context.node, isBlock)?.$container === owner; /** - * Whether a bare name at the value slot of `key` on `owner` reads a variable. - * The validator's own predicate cannot be asked: with nothing typed after the - * colon the recovered tree holds the key as a bare value and no setting node. - * A text key takes its value as written, a boolean its two words, a form - * field's constraints a number, a timer clause a time, a catch binding a name - * it declares, and a declaration's fields text. + * The validator's `isVariableUse` cannot be asked: with nothing typed after + * the colon the recovered tree holds the key as a bare value and no `Setting`. */ function valueSlotReadsVariable(owner: AstNode, key: string): boolean { return ( @@ -579,7 +531,7 @@ function valueSlotReadsVariable(owner: AstNode, key: string): boolean { ); } -/** The stand-in for a reference not yet typed, held by the caret's node as the default builds it. */ +/** The default's stand-in for an untyped reference, held by the caret's node. */ const referenceStandInAt = (node: AstNode): VarRef => ({ $type: 'VarRef', $container: node as VarRef['$container'], @@ -589,29 +541,27 @@ const referenceStandInAt = (node: AstNode): VarRef => ({ }); /** - * The word written before the token at the caret. A mapping with nothing after - * its head word does not parse and leaves no node, so the head is read off the - * text. + * A mapping with nothing after its head word leaves no node, so the head is + * read off the text. */ const wordBefore = (context: CompletionContext): string | undefined => /(\w+)\s*$/.exec( context.textDocument.getText().slice(0, context.tokenOffset), )?.[1]; -/** Whether both braces are written: a brace the recovery inserted leaves no leaf. */ +/** A brace the recovery inserted leaves no leaf. */ function closesWithBrace(cst: CstNode): boolean { const last = isCompositeCstNode(cst) ? cst.content.at(-1) : undefined; return last !== undefined && isLeafCstNode(last) && last.text === '}'; } /** - * The blocks whose braces hold the caret, outermost first, read off the CST - * rather than off the caret's node: a handler with its trigger still unwritten - * does not parse, and the recovery closes the body before it and hangs the - * handler on the process, so its containers say nothing about where it was - * typed. A block the recovery closed stays open to a caret past its end. + * The innermost block whose braces hold the caret, read off the CST: at + * `on |` inside a block the recovery closes that block before the handler and + * hangs it on the process, so the handler's containers say nothing about where + * it was typed, and the closed block must still count as open past its end. */ -function openBlocksAt(context: CompletionContext): Block[] { +function innermostOpenBlockAt(context: CompletionContext): Block | undefined { return AstUtils.streamAst(context.document.parseResult.value) .filter(isBlock) .filter((block) => { @@ -622,15 +572,15 @@ function openBlocksAt(context: CompletionContext): Block[] { (cst.end > context.offset || !closesWithBrace(cst)) ); }) - .toArray(); + .toArray() + .at(-1); } /** - * The words a handler at the caret catches. Host-less it is an event - * sub-process, and an undo block belongs directly in the subprocess whose work - * it undoes; hosted it is a boundary event, which Operaton attaches as a - * cancel to an attempt block alone and as an escalation to the hosts - * `isEscalationLegalHost` names. An unresolved host keeps the whole list. + * Host-less, a handler is an event sub-process and its undo block belongs in + * the subprocess it undoes; hosted, it is a boundary event, which Operaton + * attaches as a cancel to a transaction alone. An unresolved host keeps the + * whole list. */ function handlerTriggerWords( handler: OnHandler, @@ -638,7 +588,9 @@ function handlerTriggerWords( context: CompletionContext, ): readonly string[] { if (handler.host === undefined) { - const inSubProcess = isSubProcess(openBlocksAt(context).at(-1)?.$container); + const inSubProcess = isSubProcess( + innermostOpenBlockAt(context)?.$container, + ); return words.filter( (word) => TRIGGER_PAYLOAD[word]?.hostless && @@ -656,31 +608,31 @@ function handlerTriggerWords( } /** - * The grammar rule `node` belongs to. Where one word is written by two rules - * the rule tells them apart and the AST node cannot, because at a caret after a - * finished construct the node is that construct, not the enclosing one. + * The grammar rule a feature belongs to. Where two rules write one word the + * rule tells them apart and the caret's node cannot: after a finished construct + * the node is that construct, not the enclosing one. */ function ruleNameOf(node: AstNode): string | undefined { return AstUtils.getContainerOfType(node, GrammarAST.isParserRule)?.name; } -/** A `MapKey` in a parameter value assigns `key` too, and takes the author's own keys. */ -const SETTING_KEY_RULE = 'ParenKey'; +/** + * A `MapKey` in a parameter value assigns `key` too, and takes the author's + * own keys. A datatype rule has no generated `$type` to name it by. + */ +export const SETTING_KEY_RULE = 'ParenKey'; -/** The rule of the bare words an element's parens take. */ -const FLAG_WORD_RULE = 'FlagWord'; +export const FLAG_WORD_RULE = 'FlagWord'; -/** Whether the caret opens a new item in an element's parens, where a key may start. */ const atParenItemStart = (context: CompletionContext): boolean => context.features.some( (next) => ruleNameOf(next.feature) === SETTING_KEY_RULE, ); /** - * The key whose value slot holds the caret, `key: |` or `key: pre|`, or - * `undefined` anywhere else. With nothing typed after the colon the parser - * recovers the key as a bare unkeyed value and drops the colon, so that shape - * is told by the colon still standing between the word and the caret. + * The key whose value slot holds the caret, or `undefined`. At `key: |` the + * parser recovers the key as a bare unkeyed value and drops the colon, so that + * shape is told by the colon still standing between the word and the caret. */ function settingKeyAt(context: CompletionContext): string | undefined { const node = context.node; @@ -700,11 +652,10 @@ function settingKeyAt(context: CompletionContext): string | undefined { } /** - * The element whose settings hold the caret. The node at the caret is that - * element only while the parens are empty; afterwards it is the preceding - * item's leaf. An item already closed above the caret is passed over, and - * parens whose closing token is not typed yet enclose nothing, so there the - * innermost element stands in. + * The element whose settings hold the caret. The caret's node is that element + * only while its parens are empty and the preceding item's leaf afterwards, so + * the walk climbs to the first element still enclosing the caret; parens whose + * closing token is not typed enclose nothing, so there the innermost stands in. */ function owningElement(context: CompletionContext): AstNode | undefined { let innermost: AstNode | undefined; @@ -724,11 +675,6 @@ function owningElement(context: CompletionContext): AstNode | undefined { return innermost; } -/** - * Scaffolds for the trigger words whose payload is neither a name nor a code. - * A timer reads a bare duration, which is the common case; a fixed date or a - * repeating cycle is written as an `at` or `every` setting instead. - */ const TRIGGER_PAYLOAD_SNIPPETS: Readonly< Record > = { @@ -742,7 +688,6 @@ const TRIGGER_PAYLOAD_SNIPPETS: Readonly< }, }; -/** The trigger words each statement takes, with the captions a word earns. */ const STATEMENT_TRIGGERS: Readonly< Record< string, @@ -784,10 +729,7 @@ const STATEMENT_TRIGGERS: Readonly< }, }; -/** - * The header declarations opening with a soft word, which no keyword path - * offers. Each names its code; only an error carries a message. - */ +/** The header declarations open with a soft word, so no keyword path offers them. */ const CODE_DECLARATION_FORMS: readonly (StructureForm & { detail: string })[] = [...DECLARED_CODE_TRIGGERS].map((kind) => ({ label: kind, @@ -797,7 +739,6 @@ const CODE_DECLARATION_FORMS: readonly (StructureForm & { detail: string })[] = (TRIGGER_PAYLOAD[kind]?.message ? '(message: "${2:message}")' : ''), })); -/** Captions replacing the default one, keyed as {@link STRUCTURE_SNIPPETS} is. */ const STRUCTURE_DETAILS: Readonly> = { call: 'call another process like a function', for: 'how often the preceding step runs', @@ -819,12 +760,7 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { this.variables = services.references.VariableSymbolProvider; } - /** - * Offers items for the soft words at the `trigger`, `particle`, `key`, - * `direction`, `event` and `kind` positions, which lex as plain `ID`s. - * `OnHandler.host` is absent because it is a real cross-reference, already - * offered by the inherited `completionForCrossReference`. - */ + /** The soft words lex as plain `ID`s, so the default offers nothing for them. */ protected override completionFor( context: CompletionContext, next: NextFeature, @@ -843,11 +779,14 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { // a timer's date and cycle keys are that payload. const forms = payloadOpen(owner, context) ? timerKeyForms(owner) - : settingFormsFor(owner); - if (forms) { - this.acceptSettingSnippets(context, acceptor, forms); - return; + : (settingFormsFor(owner) ?? []); + for (const form of forms) { + this.acceptSnippet(context, acceptor, { + ...form, + detail: 'BPMNscript setting', + }); } + return; } // A form field's type slot admits any word, so the grammar's own list of // variable types is replaced by the form types the validator holds it to. @@ -892,30 +831,18 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { this.acceptListenerEvents(context, acceptor, owner); return; } - if (next.property === 'kind' && next.type === 'CodeDecl') { + if (next.property === 'kind' && next.type === CodeDecl.$type) { for (const form of CODE_DECLARATION_FORMS) { - acceptor(context, { - label: form.label, - kind: CompletionItemKind.Snippet, - detail: form.detail, - insertText: form.insertText, - insertTextFormat: InsertTextFormat.Snippet, - sortText: '1', - }); + this.acceptSnippet(context, acceptor, form); } return; } - // A mapping is offered where its binding takes one; elsewhere the block - // position offers nothing for it, so a user task is never handed one. - if (next.property === 'trigger' && next.type === 'ErrorMapping') { + if (next.property === 'trigger' && next.type === ErrorMapping.$type) { if (owner && mappingOffered(owner)) { - acceptor(context, { + this.acceptSnippet(context, acceptor, { label: ERROR_MAPPING_HEAD, - kind: CompletionItemKind.Snippet, detail: 'raise a declared error when a reported failure matches', insertText: `${ERROR_MAPPING_HEAD} \${1:CODE} ${ERROR_MAPPING_WHEN} \${2:condition}`, - insertTextFormat: InsertTextFormat.Snippet, - sortText: '1', }); } return; @@ -934,28 +861,28 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { } /** - * A host resolves against every named statement, so the validator can say + * A host resolves against every named statement so the validator can say * why a start or a throw cannot carry a boundary event; the offer stops at - * the activities that can. The recovery of a trigger-less handler hangs it - * on the process, so the scope is read for a stand-in placed in the - * innermost block still open at the caret, where the handler was typed. - * With a body still open, `on Ins` parses as a complete `Listener`, not an - * `OnHandler`, so the guard keys on the property alone: only `OnHandler` - * has `host`. + * the activities that can. */ protected override getReferenceCandidates( refInfo: ReferenceInfo, context: CompletionContext, ): Stream { + // Inside a block body a bare `on Ins` parses as a complete `Listener`, not + // an `OnHandler`, so the guard keys on the property: only `OnHandler` has + // `host`. if (refInfo.property !== 'host') { return super.getReferenceCandidates(refInfo, context); } - const block = openBlocksAt(context).at(-1); + // The recovery hangs a trigger-less handler on the process, so the scope + // is read for a stand-in placed where the handler was typed. const placed: ReferenceInfo = { ...refInfo, container: { - $type: 'OnHandler', - $container: block ?? refInfo.container.$container, + $type: OnHandler.$type, + $container: + innermostOpenBlockAt(context) ?? refInfo.container.$container, }, }; return super @@ -971,10 +898,10 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { next: NextFeature, acceptor: CompletionAcceptor, ): MaybePromise { - if (next.type === 'ErrorMapping' && next.property === 'code') { - // context.node is the caret's leaf, which after any preceding member - // is that member, not the block's owner; climb to the nearest node - // with an attribute-block rule (the element the mapping belongs to). + if (next.type === ErrorMapping.$type && next.property === 'code') { + // Not `owningElement`: with only the head word written the member block + // is dropped from the CST and the element no longer encloses the caret, + // so an enclosure walk climbs past it. let owner = context.node; while (owner !== undefined && attributeBlockRuleOf(owner) === undefined) { owner = owner.$container; @@ -989,7 +916,7 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { return; } if ( - next.type === 'VarRef' && + next.type === VarRef.$type && next.property === 'ref' && this.variablesOffered(context) ) { @@ -1000,17 +927,16 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { } /** - * The stand-in the default builds for the code slot carries no trigger word, - * and with nothing after the head word no parsed mapping holds it either, so - * the scope would read no code kind. The parser reaches the slot only after - * the head word, so the stand-in is given it. + * The default's stand-in carries no trigger word, and with nothing after the + * head word no parsed mapping holds one either, so the scope would read no + * code kind; the stand-in is given the head word the parser has passed. */ private acceptMappingCodes( context: CompletionContext, acceptor: CompletionAcceptor, ): void { const standIn: AstNode & { trigger: string } = { - $type: 'ErrorMapping', + $type: ErrorMapping.$type, $container: context.node, $containerProperty: 'code', trigger: ERROR_MAPPING_HEAD, @@ -1028,12 +954,6 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { } } - /** - * Whether a bare name at the caret reads a variable. In an element's parens - * only an open condition slot does; at a setting's value the key decides; - * elsewhere the validator's predicate answers on the stand-in for the - * reference, whose containers are the caret's. - */ private variablesOffered(context: CompletionContext): boolean { if (atParenItemStart(context)) return conditionSlotOpen(context); const key = settingKeyAt(context); @@ -1047,10 +967,7 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { ); } - /** - * Every variable the process knows, in the order the table seeds them. A - * declared name sorts ahead of a keyword, as a code or a host does. - */ + /** A declared name sorts ahead of a keyword, as a code or a host does. */ private acceptVariables( context: CompletionContext, acceptor: CompletionAcceptor, @@ -1067,7 +984,6 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { } } - /** A plain keyword item per word. */ private acceptWords( context: CompletionContext, acceptor: CompletionAcceptor, @@ -1084,12 +1000,19 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { } } - /** - * Whether a literal word belongs at the caret. In an element's parens it is - * a payload, which an open condition slot alone takes and only as a boolean; - * at a setting's value it fits a boolean key alone; an expression position - * takes it as any operand. - */ + private acceptSnippet( + context: CompletionContext, + acceptor: CompletionAcceptor, + item: StructureForm & { detail: string }, + ): void { + acceptor(context, { + ...item, + kind: CompletionItemKind.Snippet, + insertTextFormat: InsertTextFormat.Snippet, + sortText: '1', + }); + } + private literalWordOffered( context: CompletionContext, boolean: boolean, @@ -1101,7 +1024,6 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { return key === undefined || (boolean && BOOLEAN_SETTING_KEYS.has(key)); } - /** A plain keyword item per word, or a snippet where the word carries a payload. */ private acceptEventWords( context: CompletionContext, acceptor: CompletionAcceptor, @@ -1124,23 +1046,6 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { } } - private acceptSettingSnippets( - context: CompletionContext, - acceptor: CompletionAcceptor, - forms: readonly StructureForm[], - ): void { - for (const form of forms) { - acceptor(context, { - label: form.label, - kind: CompletionItemKind.Snippet, - detail: 'BPMNscript setting', - insertText: form.insertText, - insertTextFormat: InsertTextFormat.Snippet, - sortText: '1', - }); - } - } - private static readonly DIRECTION_DETAILS: Readonly> = { input: 'a value handed to this step', @@ -1152,10 +1057,9 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { }; /** - * A field's value is quoted where an io parameter's is not: it lowers to a - * `stringValue` attribute or to an expression child, and neither takes a - * list, a map, or an inline script. A property's is the same text in a - * `value` attribute. + * A field or property value is quoted where an io parameter's is not: a + * field lowers to a `stringValue` attribute or an expression child and a + * property to a `value` attribute, and none takes a list, map or script. */ private static readonly DIRECTION_VALUES: Readonly> = { [FIELD_DIRECTION]: '"${2:value}"', @@ -1171,24 +1075,20 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { const value = BpmnScriptCompletionProvider.DIRECTION_VALUES[direction] ?? '${2:value}'; - acceptor(context, { + this.acceptSnippet(context, acceptor, { label: direction, - kind: CompletionItemKind.Snippet, detail: BpmnScriptCompletionProvider.DIRECTION_DETAILS[direction], insertText: `${direction} \${1:name} = ${value}`, - insertTextFormat: InsertTextFormat.Snippet, - sortText: '1', }); } } - /** Each event scaffolds its binding and, for `timeout`, the timer clause. */ private acceptListenerEvents( context: CompletionContext, acceptor: CompletionAcceptor, owner: AstNode, ): void { - const host = owner.$type === 'Listener' ? owner.$container : owner; + const host = isListener(owner) ? owner.$container : owner; const rule = host && attributeBlockRuleOf(host); if (!rule) { return; @@ -1198,13 +1098,10 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { // tab stops swap places on `timeout`. const timer = event === 'timeout' ? ' after "${1:PT1H}"' : ''; const binding = event === 'timeout' ? '${2:' : '${1:'; - acceptor(context, { + this.acceptSnippet(context, acceptor, { label: event, - kind: CompletionItemKind.Snippet, detail: 'BPMNscript listener event', insertText: `${event}${timer}(class: "${binding}com.example.Listener}")`, - insertTextFormat: InsertTextFormat.Snippet, - sortText: '1', }); } } @@ -1214,17 +1111,14 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { keyword: GrammarAST.Keyword, acceptor: CompletionAcceptor, ): void { - if (keyword.value === 'on' && ruleNameOf(keyword) === 'Listener') { - acceptor(context, { + if (keyword.value === 'on' && ruleNameOf(keyword) === Listener.$type) { + this.acceptSnippet(context, acceptor, { label: 'on', - kind: CompletionItemKind.Snippet, detail: 'run code when this step reaches a lifecycle point', insertText: 'on ${1|' + EXECUTION_LISTENER_EVENTS.join(',') + '|}(class: "${2:com.example.Listener}")', - insertTextFormat: InsertTextFormat.Snippet, - sortText: '1', }); return; } @@ -1238,9 +1132,9 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { return; } const rule = ruleNameOf(keyword); - // A keyword in a setting position names a key or a flag, never the start of - // a construct. Which of them belongs to the element is answered from its own - // vocabulary, so the grammar's raw alternatives are dropped here. + // In a setting position a keyword is a key or a flag, and which of them the + // element takes is answered from its vocabulary, so the grammar's raw + // alternatives are dropped. if (rule === FLAG_WORD_RULE) { const owner = owningElement(context); if ( @@ -1266,22 +1160,14 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { void super.completionForKeyword(context, keyword, acceptor); return; } - // Respect the same word-like filtering the default applies to keywords. - if (!this.filterKeyword(context, keyword)) { - return; - } const forms = typeof snippet === 'string' ? [{ label: keyword.value, insertText: snippet }] : snippet; for (const form of forms) { - acceptor(context, { - label: form.label, - kind: CompletionItemKind.Snippet, + this.acceptSnippet(context, acceptor, { + ...form, detail: STRUCTURE_DETAILS[keyword.value] ?? 'BPMNscript construct', - insertText: form.insertText, - insertTextFormat: InsertTextFormat.Snippet, - sortText: '1', }); } } diff --git a/packages/language/src/bpmn-script-document-validator.ts b/packages/language/src/bpmn-script-document-validator.ts index b1ddf12..182f9d7 100644 --- a/packages/language/src/bpmn-script-document-validator.ts +++ b/packages/language/src/bpmn-script-document-validator.ts @@ -1,18 +1,12 @@ /** - * Drops the link failures that were never meant to resolve. - * - * Every identifier in every expression is a cross-reference to a code - * declaration, because nothing in the parens tells `error(OUT_OF_STOCK)` from - * `condition(ready)`. A code position is the only one whose scope holds - * anything, so `if (amount > 100)` fails to link by design and the - * undeclared-variable warning stays the only diagnostic its author sees. - * - * This is the seam because the two earlier ones cannot be. `Linker` can only - * reword: `createLinkingError` returns the error, `doLink` stores it on the - * reference and lists the reference regardless. Skipping that push instead - * would suppress the diagnostic and leave a stale `_ref` behind, since - * `document.references` is the list `unlink()` walks to reset lazily-resolved - * references between builds. + * Drops the link failures that were never meant to resolve. Every identifier + * in every expression is a cross-reference to a code declaration, and only a + * code position has a scope, so `if (amount > 100)` fails to link by design + * and the undeclared-variable warning stays the one diagnostic its author + * sees. This is the seam because `Linker` can only reword: `doLink` stores + * the error and lists the reference regardless, and skipping that push would + * leave a stale `_ref` behind, since `document.references` is what `unlink()` + * walks between builds. */ import { @@ -25,11 +19,7 @@ import type { Diagnostic } from 'vscode-languageserver-types'; import { isVarRef } from './generated/ast.js'; import { isCodePosition } from './paren-items.js'; -/** - * A `goto` target and a handler host are reported as they always were: only an - * identifier written outside a code position is one the author never asked to - * resolve. - */ +/** Only an identifier outside a code position is one the author never asked to resolve. */ function isExpectedToResolve(source: AstNode): boolean { return !isVarRef(source) || isCodePosition(source); } @@ -40,9 +30,8 @@ export class BpmnScriptDocumentValidator extends DefaultDocumentValidator { diagnostics: Diagnostic[], options: ValidationOptions, ): void { - // A view over the document rather than a copy of it: `textDocument` is a - // lazy getter, and spreading would build the whole TextDocument to - // validate one reference list. + // A view rather than a spread copy: `textDocument` is a lazy getter, and + // spreading would build the whole TextDocument for one reference list. const reported: LangiumDocument = Object.create(document, { references: { value: document.references.filter( diff --git a/packages/language/src/bpmn-script-hover.ts b/packages/language/src/bpmn-script-hover.ts index 7ad3d73..f539dc9 100644 --- a/packages/language/src/bpmn-script-hover.ts +++ b/packages/language/src/bpmn-script-hover.ts @@ -1,12 +1,8 @@ /** - * Hover content for the three shapes a name can declare: a named statement, a - * declared error or escalation code, and a variable. - * - * Langium's hover resolves the caret through `References.findDeclarations` - * before this method ever runs: a `goto` target, a handler's host, a thrown - * or caught code, and every site of a variable all resolve to the declaration - * they reference, so the same line answers there too, with no case for any of - * them here. + * Hover for a named statement, a declared code and a variable. Langium + * resolves the caret through `References.findDeclarations` first, so a `goto` + * target, a host, a thrown or caught code and every site of a variable show + * their declaration's line with no case of their own. */ import type { AstNode } from 'langium'; @@ -25,7 +21,6 @@ import { attributeBlockRuleOf } from './vocabulary.js'; import { isVariableSymbolNode } from './bpmn-script-references.js'; import { declaredCodeOf, settingsOf } from './paren-items.js'; -/** The literal-string value of a written setting, `undefined` where absent or not quoted text. */ function literalSetting(items: ParenItem[], key: string): string | undefined { const setting = settingsOf(items).find((item) => item.key === key); return setting && isLiteralString(setting.value) @@ -43,10 +38,8 @@ function namedStatementHover(node: NamedStatement): string { function codeDeclHover(decl: CodeDecl): string { const article = /^[aeiou]/i.test(decl.kind) ? 'an' : 'a'; let line = `${article} ${decl.kind} '${decl.name}'`; - // `declaredCodeOf` falls back to the declaration's own name when no `code` - // setting is written; that fallback is the code the engine uses, not a - // value the source spells, so hover only quotes one that was actually - // written. + // `declaredCodeOf` falls back to the name; hover quotes only a written + // setting. const hasCodeSetting = settingsOf(decl.items).some( (item) => item.key === 'code', ); diff --git a/packages/language/src/bpmn-script-linker.ts b/packages/language/src/bpmn-script-linker.ts index ceff13b..c396755 100644 --- a/packages/language/src/bpmn-script-linker.ts +++ b/packages/language/src/bpmn-script-linker.ts @@ -1,15 +1,11 @@ /** - * Boundary-explanation linker for `goto` and for an `on` handler's host: - * Langium's stock resolution failure, which names the grammar type - * (`Statement`) rather than anything the author wrote, is replaced by a - * message naming the boundary crossed where the name exists elsewhere in the - * process, or by a plain "no step named" message where it exists nowhere at - * all. A validator could not do the job: it only ever sees a `goto` that - * already resolved. An undeclared code is reworded here for the same reason. - * - * Rewording is all a linker can do. `doLink` stores the error on the reference - * and lists the reference either way, so whether the error becomes a diagnostic - * is decided in `bpmn-script-document-validator.ts`. + * Langium's stock resolution failure names the grammar type (`Statement`) + * rather than anything the author wrote. A `goto` or host naming a step that + * exists elsewhere in the process gets the boundary it crosses instead, one + * naming nothing gets "no step named", and an undeclared code gets how to + * declare it. A validator cannot do this: it only sees a reference that + * already resolved. Rewording is all a linker can do; whether the error + * becomes a diagnostic is decided in `bpmn-script-document-validator.ts`. */ import { @@ -64,10 +60,6 @@ function handlerPhrase(handler: OnHandler): string | undefined { return `${article} '${header}' handler`; } -/** - * The kind a declaration of `name` in the enclosing process was written under, - * or `undefined` where the process declares no such name. - */ function declaredCodeKind(source: AstNode, name: string): string | undefined { const process = AstUtils.getContainerOfType(source, isProcess); const decls = process?.decls.filter(isCodeDecl) ?? []; @@ -80,11 +72,7 @@ interface Location { crossesHandler: boolean; } -/** - * Where `target` lives relative to `sourceContainer`, called once the two are - * known to differ. A handler carrying a host is never reported: the container - * walk passes through it. - */ +/** Where `target` lives relative to `sourceContainer`, once the two are known to differ. */ function locateTarget( target: NamedStatement, sourceContainer: FlowContainer, @@ -132,10 +120,9 @@ export class BpmnScriptLinker extends DefaultLinker { const codeTrigger = codeTriggerOf(source); if (codeTrigger !== undefined) { const name = refInfo.reference.$refText; - // The scope holds this kind's declarations only, so a name declared - // under the other kind arrives here unresolved. Saying it is undeclared - // would be false, and advising a second declaration of that name would - // walk the author into the duplicate-name error. + // A name declared under the other kind arrives here unresolved too; + // advising a second declaration would walk the author into the + // duplicate-name error. const declaredKind = declaredCodeKind(source, name); const message = declaredKind === undefined diff --git a/packages/language/src/bpmn-script-parser-error-message-provider.ts b/packages/language/src/bpmn-script-parser-error-message-provider.ts index cb74320..30d519b 100644 --- a/packages/language/src/bpmn-script-parser-error-message-provider.ts +++ b/packages/language/src/bpmn-script-parser-error-message-provider.ts @@ -1,16 +1,13 @@ /** - * Reserved-word guidance for parse errors: a reserved grammar keyword (`date`, - * `class`, `if`, ...) written where the parser expects a plain identifier gets - * a message naming the word and pointing at the quoted `"${...}"` raw-string - * fallback. Two Chevrotain paths reach that mistake, so both are overridden: - * `buildMismatchTokenMessage` where the grammar expects exactly `ID`, and - * `buildNoViableAltMessage` where `ID` is one alternative among several. A slot - * whose alternatives are all keywords takes neither path and gets its own - * message naming the words it does take. Every message here stays free of BPMN - * vocabulary (ADR-0013). - * - * This only enriches the message Chevrotain already built; it cannot suppress - * or restructure error recovery, nor change which token positions are legal. + * Guidance in place of Chevrotain's stock wording for the mistakes an author + * makes with the grammar's shape: a reserved word where a plain identifier + * belongs, a `var` or a repeat clause after the position that takes it, a + * mistyped statement keyword. A reserved word reaches the parser down two + * Chevrotain paths, so both builders are overridden: `buildMismatchTokenMessage` + * where the grammar expects exactly `ID`, `buildNoViableAltMessage` where `ID` + * is one alternative among several. Every message stays free of BPMN + * vocabulary (ADR-0013). Only the message changes; recovery and the legal + * token positions are Chevrotain's. */ import { @@ -27,42 +24,27 @@ import { const ID_TOKEN_NAME = 'ID'; /** - * A `var` declaration is legal only in the process header, so anywhere else the - * parser has finished the statement list and reports "expecting `}`, found - * `var`". `var` used as a name expects `ID` and takes the reserved-word path. + * A `var` after the first step, or a `for` after a statement that takes no + * repeat clause, ends the statement list early: the parser expects `}` and + * finds the keyword. An unclosed block reads the same way at `EOF`, whose + * image is the empty string, so the stock message would say "found ``". */ const CLOSE_BRACE_TOKEN_NAME = '}'; const VAR_KEYWORD_TOKEN_NAME = 'var'; - -/** After a statement that takes no repeat clause, the same "expecting `}`". */ const FOR_KEYWORD_TOKEN_NAME = 'for'; - -/** - * Chevrotain names the end-of-input token `EOF`; its image is the empty - * string, so the stock message reads "found ``" where an unclosed block ran - * out of source before its closing brace. - */ const EOF_TOKEN_NAME = 'EOF'; /** * A code declaration is the one header declaration opening with a plain `ID`, - * so a mistyped statement keyword in the header region is parsed as one and - * fails at its name slot, where Chevrotain's raw expected-token list says - * nothing about the actual mistake. A word that really does open a declaration - * is excluded, so `error "PF"` still blames the text where the name belongs. A - * mistyped kind *followed by a name* parses whole and never reaches here; the - * validator names its kind instead. + * so a mistyped statement keyword parses as its kind and fails at the name + * slot. A word that really opens a declaration is excluded, so `error "PF"` + * still blames the text where the name belongs. */ const CODE_DECL_RULE_NAME = 'CodeDecl'; -/** A token whose image could have been meant as a word rather than punctuation or a literal. */ const WORD_SHAPED = /^[A-Za-z_]/; -/** - * Langium's generated Chevrotain rules carry a trailing zero-width space on - * `ruleName` (`withRuleSuffix` in `langium-parser.ts`) so rule names never - * collide with reserved JavaScript identifiers. - */ +/** Langium suffixes every Chevrotain rule name with a zero-width space (`withRuleSuffix` in `langium-parser.ts`). */ function bareRuleName(ruleName: string): string { return ruleName.replace(/\u200b+$/, ''); } @@ -167,10 +149,9 @@ export class BpmnScriptParserErrorMessageProvider extends LangiumParserErrorMess } /** - * The keywords a slot admits, in grammar order, when every alternative is one - * keyword and nothing else. `undefined` anywhere else, so a slot also taking - * an identifier, a literal, or a longer phrase keeps Chevrotain's message - * rather than being described as a closed set of words. + * The keywords a slot admits when every alternative is one keyword and + * nothing else, else `undefined`: a slot also taking an identifier, a + * literal or a longer phrase is not a closed set of words. */ private keywordAlternatives( expectedPathsPerAlt: NoViableAltOptions['expectedPathsPerAlt'], diff --git a/packages/language/src/bpmn-script-references.ts b/packages/language/src/bpmn-script-references.ts index 748f09b..a1e8df0 100644 --- a/packages/language/src/bpmn-script-references.ts +++ b/packages/language/src/bpmn-script-references.ts @@ -1,21 +1,13 @@ /** - * Definition, references, rename, document highlight and hover for a - * variable, in one service. - * - * `VarRef.ref` is a cross-reference to `CodeDecl` that the scope provider - * resolves in a code position alone, so Langium's providers, which key on the - * resolved reference and on a `name` node, know nothing of `if (amount > 1)` - * and rename `var amount` on its own. All five resolve the caret through - * `References.findDeclarations` and walk the sites through - * `References.findReferences`, so one override serves them all. - * - * The identity a rename keys on is the variable, not a node: up to five node - * kinds declare one, and a repeated statement declares its element beside its - * own name, so no node can stand for the variable it declares. Each request - * builds a fresh symbol node from `declaringSites` instead. That walk is three - * passes over the AST and answers in single-digit milliseconds on a 3,000-line - * file, so a document-highlight request per cursor move is affordable without - * a cache. + * Definition, references, rename, document highlight and hover for a variable. + * `VarRef.ref` resolves in a code position alone, so Langium's five providers, + * keyed on the resolved reference and a `name` node, know nothing of + * `if (amount > 1)`; all five go through `References.findDeclarations` and + * `References.findReferences`, so one override serves them. No AST node stands + * for a variable (five node kinds declare one, and a repeated statement + * declares its element beside its own name), so each request builds a symbol + * node from `declaringSites`. That walk answers in single-digit milliseconds + * on a 3,000-line file, so a highlight per cursor move needs no cache. */ import { @@ -45,7 +37,7 @@ import type { } from './variable-symbol-provider.js'; /** The node a variable's sites all resolve to. Its `$cstNode` is the first site's name leaf. */ -export interface VariableSymbolNode extends AstNode { +interface VariableSymbolNode extends AstNode { readonly $type: 'VariableSymbolNode'; readonly $container: Process; readonly name: string; @@ -115,10 +107,10 @@ export class BpmnScriptReferences extends DefaultReferences { } /** - * The variable the leaf under the caret names: a declaring site's name, or a - * variable use whose name has at least one site. A name with no site (a loop - * counter, an undeclared name) falls through to the default, which finds no - * resolved reference and answers nothing, so it is not renameable. + * The variable the leaf under the caret names, by a declaring site or a use + * with at least one site. A name with no site (a loop counter, an undeclared + * name) falls through to the default, which finds no resolved reference and + * so refuses a rename. */ private variableAt(cst: CstNode): VariableSymbolNode | undefined { const node = cst.astNode; diff --git a/packages/language/src/bpmn-script-scope-provider.ts b/packages/language/src/bpmn-script-scope-provider.ts index 25053b1..8278ae0 100644 --- a/packages/language/src/bpmn-script-scope-provider.ts +++ b/packages/language/src/bpmn-script-scope-provider.ts @@ -1,28 +1,19 @@ /** - * Container-scoped resolution for `goto` and for an `on` handler's host: both - * see every named step of their own flow container at any nesting depth and - * nothing outside it. A code reference, whether an event's payload or the - * code slot of an `error ... when` mapping, sees the enclosing process's - * declarations of its own kind in a code position and nothing at all anywhere - * else. + * A `goto` and an `on` handler's host see every named step of their own flow + * container at any nesting depth and nothing outside it. A code reference, an + * event's payload or the code slot of an `error ... when` mapping, sees the + * process's declarations of its own kind in a code position and nothing + * anywhere else. * - * Langium's stock block-lexical visibility is wrong for `goto` twice over: a - * step nested in a `parallel`/`if`/`while` block would be invisible to a legal - * `goto` outside it, and nothing would stop a jump into a different container. - * BPMN forbids a sequence flow from crossing a subprocess boundary, an event - * handler included, so a cross-boundary `goto` must fail to resolve; - * `bpmn-script-linker.ts` turns that into a boundary explanation. - * - * The host candidates stay at "the named steps of this container" rather than - * narrowing to activities, so a host naming a step that cannot carry an - * attached event still resolves and the validator can say what it is. - * - * Langium's precomputed scopes are deliberately not consulted for a code - * reference. They would offer every declaration of the process from every - * expression in it, which resolves `if (PAYMENT_DECLINED)` to the declaration - * of that code and offers every code as a completion wherever an expression is - * legal. Both are invisible in a green suite, so the scope is built here from - * the code position outward instead. + * Langium's block-lexical default is wrong for `goto` twice over: a step + * nested in a `parallel`/`if`/`while` block would be invisible to a legal + * `goto` outside it, and nothing would stop a jump into another container, + * which BPMN forbids for a subprocess and an event handler alike. The host + * candidates are not narrowed to activities, so a host naming a step that + * cannot carry an attached event still resolves and the validator can say + * what it is. Langium's precomputed scopes are not consulted for a code + * reference: they would resolve `if (PAYMENT_DECLINED)` to the declaration + * and offer every code as a completion wherever an expression is legal. */ import { @@ -42,7 +33,6 @@ import { isEndEvent, isErrorMapping, isGenericTask, - isGotoStatement, isIntermediateCatchEvent, isOnHandler, isProcess, @@ -74,10 +64,9 @@ import { } from './generated/ast.js'; /** - * The `Statement` subtypes carrying a `name`, so the valid `goto` targets and - * handler hosts. A `throw`/`emit`/`await` name is optional (the id is - * synthesized when omitted), so an unnamed one is neither referenceable nor - * able to collide. + * The `Statement` subtypes carrying a `name`: the `goto` targets and handler + * hosts. An unnamed `throw`/`emit`/`await` gets a synthesized id, so it is + * neither referenceable nor able to collide. */ export type NamedStatement = | StartEvent @@ -117,15 +106,15 @@ export function isNamedStatement(node: AstNode): node is NamedStatement { /** A handler body counts as a full BPMN container for every container-scoped rule. */ export type FlowContainer = Process | SubProcess | OnHandler; -export function isFlowContainer(node: AstNode): node is FlowContainer { +function isFlowContainer(node: AstNode): node is FlowContainer { return isProcess(node) || isSubProcess(node) || isOnHandler(node); } /** - * The flow container `node` lives in. The walk starts at `node.$container` so a - * node that is itself a container does not short-circuit it. A handler carrying - * a host is skipped: its body compiles into its host's container, so its steps - * and the surrounding main flow share one sequence-flow scope. + * The flow container `node` lives in, starting at `node.$container` so a + * container does not answer itself. A hosted handler is skipped: its body + * compiles into its host's container, so its steps and the main flow share one + * sequence-flow scope. */ export function enclosingFlowContainer( node: AstNode, @@ -140,13 +129,12 @@ export function enclosingFlowContainer( return container; } -function isContainerScoped(context: ReferenceInfo): boolean { - return ( - (isGotoStatement(context.container) && context.property === 'target') || - (isOnHandler(context.container) && context.property === 'host') - ); -} - +/** + * The grammar has four cross-references, `VarRef.ref`, `ErrorMapping.code`, + * `GotoStatement.target` and `OnHandler.host`, and every one of them sits + * under a `Process` (a completion stand-in hangs off a parsed node), so + * neither branch has a default to fall back to. + */ export class BpmnScriptScopeProvider extends DefaultScopeProvider { override getScope(context: ReferenceInfo): Scope { if (isVarRef(context.container) || isErrorMapping(context.container)) { @@ -155,39 +143,35 @@ export class BpmnScriptScopeProvider extends DefaultScopeProvider { // code gets nothing, as a `VarRef` outside a code position does. const trigger = codeTriggerOf(context.container); if (trigger === undefined) return EMPTY_SCOPE; - const process = AstUtils.getContainerOfType(context.container, isProcess); // No outer scope: a declaration of another process, or of the other // kind, is out of reach. An error and an escalation are separate event // definitions even under one code, so `escalation(X)` naming an error // has to fail rather than reach the XML as a second root. - return process - ? this.createScopeForNodes( - process.decls.filter( - (decl) => isCodeDecl(decl) && decl.kind === trigger, - ), - ) - : EMPTY_SCOPE; + const process = AstUtils.getContainerOfType( + context.container, + isProcess, + )!; + return this.createScopeForNodes( + process.decls.filter( + (decl) => isCodeDecl(decl) && decl.kind === trigger, + ), + ); } - if (isContainerScoped(context)) { - // From the reference's container, not the node itself: a handler naming - // a host is a container, and a scope taken from it would offer the - // handler's own body instead of its host's surroundings. - const container = enclosingFlowContainer(context.container); - if (container) { - // The reference type is `Statement`, so process-scope declarations - // such as `var`, which also carry a `name`, are filtered out here. - const referenceType = this.reflection.getReferenceType(context); - const targets = AstUtils.streamAllContents(container).filter( - (node) => - this.reflection.isSubtype(node.$type, referenceType) && - // Only where this container is the candidate's own nearest one, - // isolating a nested `subprocess` or host-less handler body. - enclosingFlowContainer(node) === container, - ); - // No outer scope: anything beyond this container is unreachable. - return this.createScopeForNodes(targets); - } - } - return super.getScope(context); + // From the reference's container, not the node itself: a handler naming a + // host is a container, and a scope taken from it would offer the handler's + // own body instead of its host's surroundings. + const container = enclosingFlowContainer(context.container)!; + // The reference type is `Statement`, so process-scope declarations such + // as `var`, which also carry a `name`, are filtered out here. + const referenceType = this.reflection.getReferenceType(context); + const targets = AstUtils.streamAllContents(container).filter( + (node) => + this.reflection.isSubtype(node.$type, referenceType) && + // Only where this container is the candidate's own nearest one, + // isolating a nested `subprocess` or host-less handler body. + enclosingFlowContainer(node) === container, + ); + // No outer scope: anything beyond this container is unreachable. + return this.createScopeForNodes(targets); } } diff --git a/packages/language/src/bpmn-script-semantic-tokens.ts b/packages/language/src/bpmn-script-semantic-tokens.ts index 0bad27a..3924361 100644 --- a/packages/language/src/bpmn-script-semantic-tokens.ts +++ b/packages/language/src/bpmn-script-semantic-tokens.ts @@ -1,14 +1,11 @@ /** - * Semantic-token highlighting for the soft words. They lex as plain `ID` so that - * `var message: string` still parses, and the generated TextMate grammar, a - * regex over token text, cannot tell `on error` from `var error: string` or a - * setting `priority` from the variable in `if (priority > 5)`. Semantic - * tokens are computed from the parsed AST, so a soft word highlights exactly - * where the grammar gave it that meaning, and VS Code's default themes render a - * semantic `keyword` like a lexical one. - * - * `OnHandler.host` is excluded: it is a cross-reference to the activity the - * handler attaches to, not a trigger word. + * The soft words lex as plain `ID`, so the TextMate grammar, a regex over + * token text, cannot tell `on error` from `var error: string`. A semantic + * token comes off the parsed AST, so a soft word highlights exactly where the + * grammar gave it that meaning, and VS Code's default themes render a + * semantic `keyword` like a lexical one. `OnHandler.host` is a cross-reference + * to the activity the handler attaches to, not a trigger word, so it is left + * out. */ import type { AstNode } from 'langium'; diff --git a/packages/language/src/bpmn-script-validator.ts b/packages/language/src/bpmn-script-validator.ts index 9e6c073..cf501c7 100644 --- a/packages/language/src/bpmn-script-validator.ts +++ b/packages/language/src/bpmn-script-validator.ts @@ -1,3 +1,12 @@ +/** + * Every diagnostic the language raises. Most messages and the value shapes + * come first, then the class, in this order: the process-wide checks, the + * per-element entry points `registerValidationChecks` wires, the + * attribute-block machinery from `checkAttributeKeys` on, gateways and blocks, + * handlers and events, declarations. Free helpers sit outside the class, + * nearest the check or message they serve. + */ + import { AstUtils, type AstNode, @@ -145,8 +154,10 @@ import { engineSpellings, ERROR_MAPPING_HEAD, ERROR_MAPPING_WHEN, + EVENT_BINDING_FIELD_SET, EVENT_BINDING_FIELDS, EXECUTION_LISTENER_EVENTS, + EXPRESSION_ANYWHERE, EXPRESSION_OPEN, EXTERNAL_BINDING_KEY, EXTERNAL_TASK_EL_NAME, @@ -161,6 +172,7 @@ import { formatPlainWordList, formatWordList, gatewayStatementRuleOf, + ID_TEXT, IO_DIRECTIONS, JOIN_ENGINE_KEYS, JOIN_KEY_BY_ENGINE_KEY, @@ -170,6 +182,7 @@ import { LISTENER_BINDING_KEYS, listenerEventsFor, ON_TRIGGERS, + OUTPUT_DIRECTION, parameterDirectionsFor, PROCESS_HEADER_KEYS, PROPERTY_DIRECTION, @@ -192,6 +205,7 @@ import { TRIGGER_PAYLOAD, TYPE_BINDING_KEY, TYPE_BINDING_VALUES, + USER_TASK_VERBATIM_KEYS, type AttributeBlockRule, type AttributeOwner, type BuiltinTaskType, @@ -256,26 +270,23 @@ type GatewayStatement = | ParallelStatement | RaceStatement; +/** A user task's own priority, `operaton:priority`, beside the job and external-task ones. */ +const USER_PRIORITY_KEY = 'priority'; + /** - * Keys whose value names something outside process-variable scope, so a - * bareword there must not warn about an undeclared variable, nor be scanned - * for the JUEL spelling rules. `jobPriority`, `taskPriority`, `priority`, and - * `businessKey` stay out: a bareword there lowers to `${...}` and does name a - * variable. `assignee` is in: a bareword lowers to the plain text - * `operaton:assignee="demo"`, which `TaskDecorator.initializeTaskAssignee` - * reads as the user id `demo`, exactly as `candidateUsers` is read, and - * `label`, `documentation` and `versionTag` lower as plain text the same way. - * The date keys are here because `dueDate = deadline` emits - * `operaton:dueDate="deadline"`, which Operaton cannot parse as a date, so - * declaring `deadline` would hide the warning and leave the attribute just as - * broken; {@link BpmnScriptValidator.checkAttributeValues} asks for a quoted - * date instead. + * Keys whose value names something other than a process variable, so a + * bareword there neither warns as undeclared nor is scanned for the JUEL + * spelling rules. The priority keys and `businessKey` stay out because a + * bareword there lowers to `${...}`; `assignee` is in because it lowers to the + * plain text `operaton:assignee="demo"`, the user id + * (`TaskDecorator.initializeTaskAssignee`). The date keys are in so that + * declaring `deadline` cannot hide the unparseable-date error + * {@link BpmnScriptValidator.checkAttributeValues} raises for `dueDate = deadline`. */ export const NON_VARIABLE_ATTR_KEYS: ReadonlySet = new Set([ 'label', 'documentation', 'class', - 'formKey', 'formRef', 'expression', 'delegate', @@ -285,11 +296,7 @@ export const NON_VARIABLE_ATTR_KEYS: ReadonlySet = new Set([ 'binding', 'version', 'mapDecisionResult', - 'assignee', - 'candidateGroups', - 'candidateUsers', - 'dueDate', - 'followUpDate', + ...USER_TASK_VERBATIM_KEYS.filter((key) => key !== USER_PRIORITY_KEY), ...engineSpellings('retryCycle'), 'resultVariable', 'versionTag', @@ -305,31 +312,20 @@ const BOOLEAN_ATTR_KEYS: ReadonlySet = new Set( BOOLEAN_ENGINE_KEYS.flatMap(engineSpellings), ); -/** Each timer job key is dead on an element with no job, so written alone it draws a warning. */ const TIMER_JOB_KEYS_WITHOUT_PRIORITY: readonly string[] = TIMER_JOB_KEYS.filter((key) => key !== 'jobPriority'); const TIMER_JOB_KEY_SET: ReadonlySet = new Set(TIMER_JOB_KEYS); -/** A user task's own priority, `operaton:priority`, beside the job and external-task ones. */ -const USER_PRIORITY_KEY = 'priority'; - -/** The two dates a user task carries, read through one business calendar. */ const DUE_DATE_KEYS: readonly string[] = ['dueDate', 'followUpDate']; const DUE_DATE_KEY_SET: ReadonlySet = new Set(DUE_DATE_KEYS); -/** - * Keys `BpmnParse.parsePriority` reads: a constant there must parse as an - * integer or the deployment fails, so anything else has to be an expression. - * A user task's `priority` deploys unchecked and is held to the same shape - * by `TaskDecorator.initializeTaskPriority` when the task is created. - */ +/** Each parsed with `Integer.parseInt`, at deployment (`BpmnParse.parsePriority`) or when the task is created (`TaskDecorator.initializeTaskPriority`). */ const PRIORITY_ATTR_KEYS: ReadonlySet = new Set([ ...engineSpellings('jobPriority'), TASK_PRIORITY_KEY, USER_PRIORITY_KEY, ]); -/** Every spelling `retryCycle` carries, shared between the key-membership check and its shape check. */ const RETRY_CYCLE_ATTR_KEYS: ReadonlySet = new Set( engineSpellings('retryCycle'), ); @@ -353,13 +349,9 @@ const TEXT_ATTR_KEYS: ReadonlySet = new Set([ const DUE_DATE_TEXT = /^(P|\d{4}-\d{2}-\d{2})/; /** - * `BaseCallableElement.getVersion` and - * `TaskEntity.initializeFormRefFromTaskDefinition` parse the value as an - * integer when the step runs, so a constant is the digits of a deployed - * version, and a decimal deploys and then fails the instance. A bare name is - * refused too, since `version` is in `NON_VARIABLE_ATTR_KEYS` and the name - * would never be checked against the declarations; a raw template is the - * spelling for a computed version. + * A bare name is refused too, unlike a priority's: `version` is in + * {@link NON_VARIABLE_ATTR_KEYS}, so the name would never be checked against + * the declarations, and a raw template is the spelling for a computed version. */ const VERSION_TEXT = /^[1-9]\d*$/; @@ -369,9 +361,6 @@ const HISTORY_TIME_TO_LIVE_TEXT = /^(P\d+D|\d+)$/; /** `ACT_RE_PROCDEF.VERSION_TAG_` is 64 characters wide in every `create.engine.sql`; nothing checks the length before the insert. */ const VERSION_TAG_MAX_LENGTH = 64; -/** A template anywhere in the text, not only at its start, since a starter list is split at commas first. */ -const TEMPLATE_ANYWHERE = /[$#]\{/; - /** * The duration `DurationHelper.parsePeriod` reads (no anchors, for reuse in an * interval and a retry cycle's comma list): `PW` on its own, or the XML @@ -388,14 +377,11 @@ const ISO_DURATION_BODY = String.raw`(?:P\d+W|P(?!$)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d const JODA_PERIOD_BODY = String.raw`P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?`; /** - * `DateTimeUtil.parseDateTime` is Joda's `ISODateTimeFormat.dateTimeParser`: - * a calendar date (a year with an optional month and day), an ordinal date - * (`2026-100`) or a week date (`2026-W10-1`), then an optional `T` with an - * optional time down to hours and an optional zone, `Z` or an offset down to - * hours, the colon optional. The zone sits inside the `T` group: Joda's - * grammar is `date-element ['T' [time-element] [offset]]`, so `2026-12-01T` - * parses and `2026-12-01Z` is refused. Joda is looser still (single-digit - * fields, a time with no date); those spellings stay refused here. + * `DateTimeUtil.parseDateTime` is Joda's `ISODateTimeFormat.dateTimeParser`, + * whose grammar is `date-element ['T' [time-element] [offset]]`: the zone sits + * inside the `T` group, so `2026-12-01T` parses and `2026-12-01Z` is refused. + * Joda is looser still (single-digit fields, a time with no date); those + * spellings stay refused here. */ const ISO_DATE_TIME_BODY = String.raw`(?:\d{4}(-\d{2}(-\d{2})?)?|\d{4}-\d{3}|\d{4}-W\d{2}(-\d)?)(T(\d{2}(:\d{2}(:\d{2})?)?([.,]\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)?)?`; @@ -405,7 +391,6 @@ const ISO_DATE_TIME_BODY = String.raw`(?:\d{4}(-\d{2}(-\d{2})?)?|\d{4}-\d{3}|\d{ */ const INTERVAL_BODY = String.raw`(?:${ISO_DURATION_BODY}(?:/${ISO_DATE_TIME_BODY})?|${ISO_DATE_TIME_BODY}/(?:${ISO_DURATION_BODY}|${ISO_DATE_TIME_BODY}))`; -/** A `DurationHelper` repeat: `R`, an optional count, then the interval. */ const REPEAT_BODY = String.raw`R\d*/${INTERVAL_BODY}`; /** @@ -478,9 +463,6 @@ const ENGINE_KEY_BY_JOIN_KEY: Readonly> = Object.entries(JOIN_KEY_BY_ENGINE_KEY).map(([key, join]) => [join, key]), ); const TIMER_PARTICLE_SET: ReadonlySet = new Set(TIMER_PARTICLES); -const EVENT_BINDING_FIELD_SET: ReadonlySet = new Set( - EVENT_BINDING_FIELDS, -); const IO_DIRECTION_SET: ReadonlySet = new Set(IO_DIRECTIONS); const FIELD_BINDING_KEY_SET: ReadonlySet = new Set(FIELD_BINDING_KEYS); /** The remaining binding keys, so the two lists cannot name the same word. */ @@ -514,14 +496,14 @@ const TASK_LISTENER_EVENT_SET: ReadonlySet = new Set( * enforce. `attempt` shares its AST type with `subprocess`, so it has no row * of its own to read and is added back here. */ -const PARAMETER_HOSTS_MESSAGE = `parameters belong on ${formatPlainWordList( +export const PARAMETER_HOSTS_MESSAGE = `parameters belong on ${formatPlainWordList( [...Object.values(ATTRIBUTE_BLOCK_RULES), ATTEMPT_BLOCK_RULE] .filter((rule) => rule.parameters) .map((rule) => rule.description), 'and', )}.`; -const REPEATED_OUTPUT_MESSAGE = +export const REPEATED_OUTPUT_MESSAGE = "A repeated step cannot map an 'output' parameter: the engine refuses to " + 'deploy it (BpmnParse.checkActivityOutputParameterSupported). Move the ' + 'mapping to a step after the repetition.'; @@ -538,21 +520,15 @@ const FIELD_HOSTS_MESSAGE = `an injected field belongs on ${Object.values( .map((rule) => rule.description) .join(', ')}, and on a listener.`; -/** @param description Noun phrase with article, e.g. `'a user task'`. */ -const noFieldHostMessage = (description: string) => +export const noFieldHostMessage = (description: string) => `${capitalize(description)} cannot declare a 'field' parameter; ${FIELD_HOSTS_MESSAGE}`; /** - * Refuses a field written under a binding that receives no field list; - * {@link FIELD_BINDING_KEYS} states the rule. - * - * @param subject The message's leading noun phrase (`'A service task'`). - * @param written The fieldless bindings the author wrote here. Naming those - * rather than every fieldless key keeps the tail off keys the subject cannot - * write: a listener takes neither `topic` nor `decision`. - * @param takes The field bindings the subject can write, and what each names. + * `written` names the fieldless bindings the author wrote rather than every + * fieldless key, so the tail never names a key the subject cannot write: a + * listener takes neither `topic` nor `decision`. */ -const fieldBindingMessage = ( +export const fieldBindingMessage = ( subject: string, written: readonly string[], takes: { keys: readonly string[]; targets: string }, @@ -563,46 +539,33 @@ const fieldBindingMessage = ( ? '.' : `, and the binding written with ${formatWordList(written)} receives none.`); -const ELEMENT_FIELD_BINDINGS = { +export const ELEMENT_FIELD_BINDINGS = { keys: FIELD_BINDING_KEYS, targets: 'the class, the delegate, or the built-in behaviour', }; -const LISTENER_FIELD_BINDINGS = { +export const LISTENER_FIELD_BINDINGS = { keys: LISTENER_FIELD_BINDING_KEYS, targets: 'the class or the delegate', }; -/** The bindings written on an element or a listener that receive no field list. */ const fieldlessBindingsOf = (attrs: readonly Setting[]): string[] => bindingKeysOf(attrs, FIELDLESS_BINDING_KEYS); -/** - * Read off the block rules, as {@link FIELD_HOSTS_MESSAGE} is. The extras are - * legal beside `topic` alone: `parseExternalServiceTask`, their one reader, - * runs for `operaton:type="external"` and for nothing else. - */ +/** The extras are legal beside `topic` alone: `parseExternalServiceTask`, their one reader, runs for `operaton:type="external"` and for nothing else. */ const EXTERNAL_HOSTS_PHRASE = `${formatPlainWordList( Object.values(ATTRIBUTE_BLOCK_RULES) .filter((rule) => rule.externalExtras) .map((rule) => rule.description), )} bound with 'topic'`; -/** @param description Noun phrase with article, e.g. `'a user task'`. */ -const noPropertyHostMessage = (description: string) => +export const noPropertyHostMessage = (description: string) => `${capitalize(description)} cannot declare a 'property' line; a property line belongs on ${EXTERNAL_HOSTS_PHRASE}, and in a form field's block.`; -/** @param description Noun phrase with article, e.g. `'a user task'`. */ -const noMappingHostMessage = (description: string) => +export const noMappingHostMessage = (description: string) => `${capitalize(description)} cannot map a reported failure; an 'error when ' line belongs on ${EXTERNAL_HOSTS_PHRASE}, whose external worker is what reports one.`; -/** - * The shape of {@link fieldBindingMessage}. - * - * @param item The extra as written (`'a property line'`). - * @param written The bindings the author wrote here, none of them `topic`. - */ -const topicBindingMessage = ( +export const topicBindingMessage = ( subject: string, item: string, written: readonly string[], @@ -613,89 +576,85 @@ const topicBindingMessage = ( : `, and the binding written with ${formatWordList(written)} hands the step to none.`); /** `ExternalTaskEntity.evaluateThrowBpmnError` raises a BPMN error and nothing else. */ -const MAPPING_HEAD_MESSAGE = +export const MAPPING_HEAD_MESSAGE = 'An external task maps a reported failure onto an error and nothing else; ' + `write '${ERROR_MAPPING_HEAD} ${ERROR_MAPPING_WHEN} '.`; -const MAPPING_WHEN_MESSAGE = `Write '${ERROR_MAPPING_WHEN}' between the code and the condition: '${ERROR_MAPPING_HEAD} ${ERROR_MAPPING_WHEN} '.`; +export const MAPPING_WHEN_MESSAGE = `Write '${ERROR_MAPPING_WHEN}' between the code and the condition: '${ERROR_MAPPING_HEAD} ${ERROR_MAPPING_WHEN} '.`; -const priorityShapeMessage = (key: string) => +export const priorityShapeMessage = (key: string) => key === USER_PRIORITY_KEY ? `Setting '${key}' takes an integer or a "\${...}" expression; the engine parses a constant with Integer.parseInt when the task is created (TaskDecorator.initializeTaskPriority) and fails the instance on anything else.` : `Setting '${key}' takes an integer or a "\${...}" expression; the engine refuses to deploy a constant that is not an integer.`; -const VERSION_SHAPE_MESSAGE = +export const VERSION_SHAPE_MESSAGE = `Setting 'version' takes a positive whole number, quoted or not, or a "\${...}" expression yielding one; ` + 'the engine parses the value as an integer when the step runs (BaseCallableElement.getVersion, TaskEntity.initializeFormRefFromTaskDefinition) and fails the instance on anything else.'; -const dueDateShapeMessage = (key: string) => +export const dueDateShapeMessage = (key: string) => `Setting '${key}' takes a period starting with 'P' or an ISO date-time such as "2026-01-01T09:00:00", or a "\${...}" expression; ` + 'the engine parses a constant with DueDateBusinessCalendar.resolveDuedate when the task is created and fails the instance on anything else.'; -const RETRY_CYCLE_SHAPE_MESSAGE = +export const RETRY_CYCLE_SHAPE_MESSAGE = "Setting 'retryCycle' takes an ISO 8601 duration, an 'R/' repeat, or a comma " + 'list of durations; a single interval the engine cannot read is logged and dropped, so the ' + 'job keeps its default retries (ParseUtil.parseRetryIntervals), and a bad member of a list ' + 'is stored unchecked and, when its turn comes, drops that retry to the default strategy ' + 'with no wait (DefaultJobRetryCmd.execute).'; -/** @param binding The binding key written beside it (`'topic'` or `'type'`). */ -const resultVariableUnreadMessage = (binding: string) => +export const resultVariableUnreadMessage = (binding: string) => `Setting 'resultVariable' has no effect beside '${binding}': the engine hands it to an 'expression' binding alone (BpmnParse.parseServiceTaskLike), so nothing writes the variable.`; -const MAP_DECISION_RESULT_UNREAD_MESSAGE = +export const MAP_DECISION_RESULT_UNREAD_MESSAGE = "Setting 'mapDecisionResult' has no effect without 'resultVariable': the engine applies the mapping only when storing the result into that variable (DecisionEvaluationUtil.evaluateDecision)."; -const decisionModifierMessage = (key: string) => +export const decisionModifierMessage = (key: string) => `Setting '${key}' stands only beside 'decision': the engine reads 'binding', 'version' and 'mapDecisionResult' on a step answering a decision table alone (BpmnParse.parseBusinessRuleTask).`; -/** The three settings the engine reads on the decision path alone. */ const DECISION_MODIFIER_KEYS: readonly string[] = [ 'binding', 'version', 'mapDecisionResult', ]; -const HISTORY_TIME_TO_LIVE_MESSAGE = +export const HISTORY_TIME_TO_LIVE_MESSAGE = "Setting 'historyTimeToLive' takes a quoted number of days, 'PD' or ''; " + 'the engine reads the attribute as text, never as an expression, and refuses to deploy anything else (ParseUtil.parseHistoryTimeToLive).'; -const VERSION_TAG_LITERAL_MESSAGE = +export const VERSION_TAG_LITERAL_MESSAGE = "Setting 'versionTag' takes a quoted string; the engine stores the tag as written, never evaluated (BpmnParse.parseProcess)."; -const VERSION_TAG_LENGTH_MESSAGE = `Setting 'versionTag' is longer than ${VERSION_TAG_MAX_LENGTH} characters, the width of the column it is stored in (ACT_RE_PROCDEF.VERSION_TAG_), so the deployment fails.`; +export const VERSION_TAG_LENGTH_MESSAGE = `Setting 'versionTag' is longer than ${VERSION_TAG_MAX_LENGTH} characters, the width of the column it is stored in (ACT_RE_PROCDEF.VERSION_TAG_), so the deployment fails.`; -const candidateStarterMessage = (key: string) => +export const candidateStarterMessage = (key: string) => `Setting '${key}' takes ids as written: the engine stores each entry as a candidate identity link without evaluating it (BpmnDeployer.addAuthorizations), so a "\${...}" template names the id spelled by its text.`; -const headerLiteralMessage = (key: string) => +export const headerLiteralMessage = (key: string) => `Setting '${key}' takes a quoted string on a process header; the engine stores it as written, never evaluated (BpmnParse.parseProcess).`; -/** @param description Noun phrase with article, e.g. `'a service task'`. */ -const runWithoutClauseMessage = (key: string, description: string) => +export const runWithoutClauseMessage = (key: string, description: string) => `Setting '${key}' is not valid on ${description} that does not repeat: it makes one job per run, so write a 'for' clause, or '${ENGINE_KEY_BY_RUN_KEY[key]}' for one job around the step.`; -const RUN_JOB_PRIORITY_MESSAGE = `Setting '${RUN_JOB_PRIORITY_KEY}' does not exist: Operaton reads a job priority off the step alone (BpmnParse.createActivityOnScope), so 'jobPriority' applies to every run's job.`; +export const RUN_JOB_PRIORITY_MESSAGE = `Setting '${RUN_JOB_PRIORITY_KEY}' does not exist: Operaton reads a job priority off the step alone (BpmnParse.createActivityOnScope), so 'jobPriority' applies to every run's job.`; -const REPEAT_COUNT_MESSAGE = +export const REPEAT_COUNT_MESSAGE = 'A repeat count must be a non-negative whole number, a variable, or a ' + '"${...}" expression yielding one; the engine reads a constant as text ' + 'with Integer.parseInt and truncates any other number with intValue() ' + '(MultiInstanceActivityBehavior.resolveLoopCardinality).'; -const TYPE_VALUE_MESSAGE = `Setting '${TYPE_BINDING_KEY}' must be ${formatWordList(TYPE_BINDING_VALUES)}.`; +export const TYPE_VALUE_MESSAGE = `Setting '${TYPE_BINDING_KEY}' must be ${formatWordList(TYPE_BINDING_VALUES)}.`; -/** The class each type's fields are set on, for the refusal of an undeclared name. */ const BUILTIN_BEHAVIOUR_CLASS: Readonly> = { mail: 'MailActivityBehavior', shell: 'ShellActivityBehavior', }; /** - * The bindings `BpmnParse.parseServiceTaskLike` fails the deployment for when - * a result variable sits beside them, each with the attribute name its - * refusal quotes. The `expression` branch alone is built with the variable; - * the `type` branches never read it, and a `decision` reads it on its own path. + * The bindings `BpmnParse.parseServiceTaskLike` refuses a result variable + * beside, each with the attribute name its refusal quotes. The `expression` + * branch alone is built with the variable; the `type` branches never read it, + * and a `decision` reads it on its own path. */ const RESULT_VARIABLE_REFUSING_BINDINGS: Readonly> = { class: 'class', @@ -711,49 +670,42 @@ const SERVICE_TASK_LIKE_ELEMENT: Readonly< BusinessRuleTask: 'businessRuleTask', }; -/** @param description Noun phrase with article, e.g. `'a service task'`. */ -const resultVariableBindingMessage = ( +export const resultVariableBindingMessage = ( description: string, binding: string, element: string, ) => `${capitalize(description)} cannot carry 'resultVariable' beside '${binding}': the engine refuses to deploy it ('resultVariableName' not supported for ${element} elements using '${RESULT_VARIABLE_REFUSING_BINDINGS[binding]}'); bind with 'expression' to store the return value, or drop it.`; -/** @param subject The message's leading noun phrase (`"Service task 'Notify'"`). */ -const missingBuiltinFieldMessage = ( +export const missingBuiltinFieldMessage = ( subject: string, type: BuiltinTaskType, group: RequiredFieldGroup, ) => `${subject} binds ${TYPE_BINDING_KEY}: "${type}" without a ${formatWordList(group.names)} field; Operaton refuses to deploy it: "${group.error}" (BpmnParse.${BUILTIN_FIELD_VALIDATOR[type]}).`; -const unknownBuiltinFieldMessage = (name: string, type: BuiltinTaskType) => +export const unknownBuiltinFieldMessage = ( + name: string, + type: BuiltinTaskType, +) => `Field '${name}' is not one a ${type} task takes; the engine sets it on ${BUILTIN_BEHAVIOUR_CLASS[type]}, which declares ${formatPlainWordList(BUILTIN_FIELD_NAMES[type], 'and')} (ClassDelegateUtil.applyFieldDeclaration).`; -const shellFieldExpressionMessage = (name: string) => +export const shellFieldExpressionMessage = (name: string) => `Field '${name}' on a shell task takes a quoted literal: Operaton reads every shell field as a fixed value (BpmnParse.validateFieldDeclarationsForShell) and fails the deployment on an expression.`; -const shellFlagValueMessage = (name: string) => +export const shellFlagValueMessage = (name: string) => `Field '${name}' on a shell task takes "true" or "false"; the engine reads any other spelling as false (ShellActivityBehavior.readFields).`; const SHELL_FLAG_LITERAL_SET: ReadonlySet = new Set( SHELL_FLAG_LITERALS, ); -/** - * A fenced body binds a listener in place of its settings, and the script - * listener behaviours are built from the script alone. Naming the bindings - * that do take a field would be a dead end here: a listener writing both a - * script and a `class` setting reaches this and has already followed it. - * - * @param subject The message's leading noun phrase (`"The 'on start' listener"`). - */ -const scriptListenerFieldMessage = (subject: string) => +export const scriptListenerFieldMessage = (subject: string) => `${subject} runs a fenced script, which the engine hands no field list; ` + `remove the script and bind the listener with ${formatWordList(LISTENER_FIELD_BINDING_KEYS)} ` + 'to inject one.'; -const fieldValueMessage = (name: string) => +export const fieldValueMessage = (name: string) => `Field '${name}' takes a quoted string or a "\${...}" expression; ` + 'put the value in quotes.'; @@ -763,22 +715,15 @@ const fieldValueMessage = (name: string) => * to is decided by its opening, so a literal opening with one can only ever * reach the expression slot. */ -const escapedFieldLiteralMessage = (name: string) => +export const escapedFieldLiteralMessage = (name: string) => `Field '${name}' cannot carry quoted text opening with '\${' or '#{': the expression slot is picked by that opening, so the text would be evaluated rather than injected as written. Drop the backslash to write an expression.`; -/** - * `getStringValueFromAttributeOrElement` reads an empty `stringValue` as - * absent, so `parseStringFieldDeclaration` finds no literal and - * `parseExpressionFieldDeclaration` finds no expression either: the - * declaration is neither and the deployment fails. - */ -const emptyFieldMessage = (name: string) => +export const emptyFieldMessage = (name: string) => `Field '${name}' cannot be empty: the engine reads an empty value as absent, so the ` + 'field declares neither a fixed value nor an expression and the deployment fails ' + `(BpmnParse.parseFieldDeclaration). Write '\${""}' for an actual empty string.`; -/** `BpmnParseUtil.getElValueProvider` reads an empty attribute as no value at all, never as an empty string. */ -const EMPTY_STRING_VALUE_MESSAGE = +export const EMPTY_STRING_VALUE_MESSAGE = 'An empty or blank string writes no value at all: the engine trims it and ' + 'reads it as absent rather than as an empty string (BpmnParseUtil.getElValueProvider). ' + 'Write \'${""}\' for an actual empty string.'; @@ -787,34 +732,30 @@ const JUEL_RESERVED_WORD_SET: ReadonlySet = new Set( JUEL_RESERVED_WORDS, ); -/** - * A raw template is plain text, so unlike a declaration or a rendered - * accessor it can spell `true`, `false` or `null` as a `.prop` read; the - * scanner still returns their keyword tokens there (see - * {@link JUEL_LITERAL_WORDS}). - */ const JUEL_RAW_TEMPLATE_WORD_SET: ReadonlySet = new Set([ ...JUEL_RESERVED_WORDS, ...JUEL_LITERAL_WORDS, ]); -/** @param key The string-key spelling that reaches the name, `execution.getVariable('x')` or `order['x']`. */ -const juelKeywordMessage = (word: string, key: string) => +export const juelKeywordMessage = (word: string, key: string) => `'${word}' is a JUEL keyword (Scanner.addKeyToken), so the engine refuses any expression naming it. Reach the variable through a string key instead: "\${${key}}".`; /** `Character.isJavaIdentifierPart('-')` is false, so the identifier ends at the hyphen. */ -const hyphenNameMessage = (name: string, key: string) => +export const hyphenNameMessage = (name: string, key: string) => `'${name}' carries a hyphen, which JUEL scans as a minus (Scanner.nextIdentifier), so the engine reads a subtraction. Reach the variable through a string key instead: "\${${key}}".`; /** The slots whose value the engine casts to a Boolean, each throwing in its own method. */ type ConditionSlot = 'condition' | 'until'; -const nonBooleanConditionMessage = (shape: string, slot: ConditionSlot) => +export const nonBooleanConditionMessage = ( + shape: string, + slot: ConditionSlot, +) => slot === 'until' ? `An 'until' condition must be boolean, but this one is ${shape}: the engine throws when a completion condition evaluates to anything else (MultiInstanceActivityBehavior.completionConditionSatisfied).` : `A condition must be boolean, but this one is ${shape}: the engine throws 'condition expression returns ${shape === 'null' ? 'null' : 'non-Boolean'}' when it evaluates it (UelExpressionCondition.evaluate).`; -const COMPOSITE_OPERAND_MESSAGE = +export const COMPOSITE_OPERAND_MESSAGE = "A composite template cannot be spliced into the surrounding expression: JUEL has no '${' token once inside an expression (Scanner.nextEval), so only a raw that is exactly one '${...}' with no '}' outside a string literal in its body can be an operand. Write the whole expression as one raw template instead."; /** The keys whose value the engine evaluates as EL, so quoted text reaches it as a string. */ @@ -842,33 +783,22 @@ const EMPTY_BINDING_NOUN: Readonly> = { process: 'process to start', }; -const literalElBindingMessage = (key: string) => +export const literalElBindingMessage = (key: string) => `Setting '${key}' takes a "\${...}" template or a bare name, never quoted text: ` + (key === 'expression' ? 'the string evaluates to itself and runs nothing' : 'a string resolves to no delegate to run') + `. Write '${key}: "\${...}"' or '${key}: '.`; -const templateAsClassMessage = (key: string, alternative: string) => +export const templateAsClassMessage = (key: string, alternative: string) => `Setting '${key}' takes a class name, loaded as written (ClassDelegateUtil.instantiateDelegate); a "\${...}" template there is not evaluated. Write '${key}: com.example.X', or '${alternative}: "\${bean}"' to resolve one at runtime.`; /** - * The shapes the binding keys refuse. A blank literal: - * `BpmnParse.parseServiceTaskLike` passes over a blank `class` or `expression` - * and `validateServiceTaskLike` then refuses the task as unbound, - * `parseExecutionListener` refuses an empty `class` or `delegateExpression` - * outright, and a blank `topic`, `decision`, `process` or mapping class - * deploys and then names nothing at the step; a blank `delegate` or - * `mapperDelegate` deploys too (`parseServiceTaskLike` and - * `parseCallActivity` test that attribute against null alone) and its empty - * expression evaluates to a string no delegate is, so - * `ServiceTaskDelegateExpressionActivityBehavior.performExecution` and - * `CallableElementActivityBehavior.getDelegateVariableMapping` throw at the - * step; this language refuses all of them alike. Quoted text under an EL key - * reaches the engine as a string: - * `ServiceTaskDelegateExpressionActivityBehavior.performExecution` throws - * because a string is no delegate, and an expression evaluates to the text - * itself. A template under a class key is loaded as the class name it spells. + * A blank literal is refused under every key alike, though the engine treats + * them apart: `validateServiceTaskLike` and `parseExecutionListener` refuse a + * blank `class`, `expression` or `delegateExpression` at deployment, while a + * blank `topic`, `decision`, `process`, mapping class, `delegate` or + * `mapperDelegate` deploys and then names nothing, or throws, at the step. */ function bindingValueMessage(key: string, value: Expr): string | undefined { const noun = EMPTY_BINDING_NOUN[key]; @@ -885,34 +815,34 @@ function bindingValueMessage(key: string, value: Expr): string | undefined { : undefined; } -const unknownDirectionMessage = (word: string, legal: readonly string[]) => - `Unknown parameter direction '${word}'; write ${formatWordList(legal)}.`; +export const unknownDirectionMessage = ( + word: string, + legal: readonly string[], +) => `Unknown parameter direction '${word}'; write ${formatWordList(legal)}.`; -const FORM_KEY_AND_REF_MESSAGE = +export const FORM_KEY_AND_REF_MESSAGE = "A user task names its form with 'formKey' or with 'formRef', never both; " + 'the engine refuses to deploy a task carrying the two.'; -/** One phrase per mode the setting takes, as an author writes it. */ const BINDING_MODE_PHRASES: readonly string[] = CALL_BINDING_VALUES.map( (value) => `'binding: ${value}'`, ); -const FORM_REF_BINDING_MESSAGE = `A 'formRef' needs the binding resolving it: add ${formatPlainWordList( +export const FORM_REF_BINDING_MESSAGE = `A 'formRef' needs the binding resolving it: add ${formatPlainWordList( [...BINDING_MODE_PHRASES, "'version: '"], )}. The engine refuses to deploy a form reference with none.`; -const FORM_REF_MISSING_MESSAGE = +export const FORM_REF_MISSING_MESSAGE = "'binding' and 'version' pin which deployed version of a form the engine " + "resolves, so neither stands without a 'formRef'."; -const formFieldSettingsOnlyMessage = (id: string, text: string) => +export const formFieldSettingsOnlyMessage = (id: string, text: string) => `Form field '${id}' takes 'key: value' settings in its parens; '${text}' is not one.`; -const unknownFormFieldSettingMessage = (id: string, key: string) => +export const unknownFormFieldSettingMessage = (id: string, key: string) => `Unknown form field setting '${key}' on '${id}'; write ${formatWordList(FORM_FIELD_SETTING_KEYS)}.`; -/** The engine deploys the pair and then fails every submission of the field ({@link FORM_CONSTRAINT_TYPES}). */ -const constraintMisfitMessage = ( +export const constraintMisfitMessage = ( name: string, field: FormField, fits: readonly string[], @@ -920,81 +850,74 @@ const constraintMisfitMessage = ( `Constraint '${name}' fits a ${formatPlainWordList(fits)} field, not the ${field.type} field '${field.id}': the engine checks a submitted ${formatPlainWordList(fits)} alone and fails every other submission.`; /** `FormTypes.parseFormPropertyType` reads `datePattern` under `type="date"` alone. */ -const patternMisfitMessage = (field: FormField) => +export const patternMisfitMessage = (field: FormField) => `Setting 'pattern' is the date pattern a 'date' field is parsed with; '${field.id}' is a ${field.type} field, which the engine reads no pattern off.`; -const flagFalseMessage = (key: string) => +export const flagFalseMessage = (key: string) => `A field is ${key} only while the setting is written, so '${key}: false' says nothing; leave the setting out.`; -const flagNotTrueMessage = (key: string) => +export const flagNotTrueMessage = (key: string) => `Setting '${key}' takes the literal true; write '${key}: true'.`; -const integerBoundMessage = (key: string) => +export const integerBoundMessage = (key: string) => `Setting '${key}' takes an integer literal or a quoted integer such as "-5".`; -const PATTERN_VALUE_MESSAGE = `Setting 'pattern' takes a non-empty quoted date pattern such as "dd/MM/yyyy".`; +export const PATTERN_VALUE_MESSAGE = `Setting 'pattern' takes a non-empty quoted date pattern such as "dd/MM/yyyy".`; -const PATTERN_LETTERS_MESSAGE = +export const PATTERN_LETTERS_MESSAGE = "Setting 'pattern' may hold only SimpleDateFormat letters " + '(G, y, Y, M, L, w, W, D, d, F, E, u, a, H, k, K, h, m, s, S, z, Z, X), ' + 'quoted literal runs, and non-letters; the engine builds a java.text.SimpleDateFormat ' + 'from it and throws on any other letter (DateFormType).'; -const VALIDATOR_EMPTY_MESSAGE = +export const VALIDATOR_EMPTY_MESSAGE = "Setting 'validator' cannot be empty; name the class or the expression it " + 'resolves (FormValidators.createValidator).'; -const valuesOnNonEnumMessage = (field: FormField) => +export const valuesOnNonEnumMessage = (field: FormField) => `Value lines belong on an 'enum' field; '${field.id}' is a ${field.type} field.`; /** `EnumFormType.validateValue` refuses any value outside the (empty) map. */ -const emptyEnumMessage = (id: string) => +export const emptyEnumMessage = (id: string) => `Enum field '${id}' offers no values, so the engine rejects every submitted value; add a value line such as 'basic "Basic"'.`; -const duplicateValueMessage = (id: string) => `Duplicate value '${id}'.`; +export const duplicateValueMessage = (id: string) => `Duplicate value '${id}'.`; /** * `FormFieldHandler.createFormField` converts the default through the enum type * on every render of the form, so the deployment succeeds and the form never * opens. */ -const enumDefaultMessage = ( +export const enumDefaultMessage = ( id: string, value: string, ids: readonly string[], ) => `The default "${value}" of enum field '${id}' names none of its values; write ${formatWordList(ids)}.`; -/** `LongFormType.convertValue` runs `Long.valueOf` on every render; a decimal or a word throws. */ -const numberDefaultMessage = (id: string, text: string) => +export const numberDefaultMessage = (id: string, text: string) => `The default ${text} of number field '${id}' is not an integer; the engine ` + 'converts it with Long.valueOf every time the form renders (LongFormType.convertValue) ' + 'and throws on anything else.'; -/** `BooleanFormType.convertValue` never throws; it reads anything but "true" as false with no error at all. */ -const booleanDefaultMessage = (id: string, text: string) => +export const booleanDefaultMessage = (id: string, text: string) => `The default ${text} of boolean field '${id}' is not "true" or "false"; the engine ` + 'reads any other spelling as false (BooleanFormType.convertValue).'; -/** - * `DateFormType.convertToModelValue` parses the default with the field's - * pattern, or the process engine's own "dd/MM/yyyy" where none is written - * (`ProcessEngineConfigurationImpl.initFormTypes`); an ISO-shaped default is - * the one mistake worth guessing at, since it reads as a legal date to a human. - */ -const isoDateDefaultMessage = (id: string, value: string) => +/** An ISO-shaped default is the one misfit worth guessing at: it reads as a legal date to a human and fails the engine's own pattern. */ +export const isoDateDefaultMessage = (id: string, value: string) => `The default "${value}" of date field '${id}' is an ISO date, but the engine's default ` + 'pattern is "dd/MM/yyyy" (ProcessEngineConfigurationImpl.initFormTypes); add a \'pattern\' ' + 'setting, or write the date to fit it.'; -const formFieldDirectionMessage = ( +export const formFieldDirectionMessage = ( id: string, direction: string, isEnum: boolean, ) => `Unknown member direction '${direction}' in form field '${id}': its block takes 'property = ""' lines${isEnum ? ' and value lines' : ''}.`; -const propertyValueMessage = (name: string) => +export const propertyValueMessage = (name: string) => `Property '${name}' takes a quoted string or a "\${...}" expression; ` + 'put the value in quotes.'; @@ -1048,7 +971,6 @@ const datePatternShape: ValueShapeRule = (_key, value) => { : PATTERN_LETTERS_MESSAGE; }; -/** What each setting of a form field's parens takes; typed as {@link FORM_CONSTRAINT_TYPES} is. */ const FORM_FIELD_VALUE_RULES: Readonly> = { required: literalTrue, readonly: literalTrue, @@ -1078,10 +1000,10 @@ const versionTag: ValueShapeRule = (_key, value) => ? VERSION_TAG_LENGTH_MESSAGE : undefined; -/** A bare name is an id as written; only a template, quoted whole or inside the list, is refused. */ +/** A bare name is an id as written; only a template, quoted whole or inside the comma-split list, is refused. */ const candidateStarters: ValueShapeRule = (key, value) => isRawExpr(value) || - (isLiteralString(value) && TEMPLATE_ANYWHERE.test(value.value)) + (isLiteralString(value) && EXPRESSION_ANYWHERE.test(value.value)) ? candidateStarterMessage(key) : undefined; @@ -1099,15 +1021,10 @@ const PROCESS_HEADER_VALUE_RULES: Readonly> = { candidateStarterGroups: candidateStarters, }; -/** @param subject The clause or noun phrase that does take one, quoted as written. */ function particleOnlyMessage(subject: string): string { return `Only ${subject} takes a particle.`; } -/** - * @param subject The message's leading noun phrase (`'An awaited message'`). - * @param kind The event kind whose name is missing, for the possessive and plural. - */ function nameRequiredMessage(subject: string, kind: string): string { return `${subject} needs the ${kind}'s name: the engine matches ${kind}s by name.`; } @@ -1115,13 +1032,13 @@ function nameRequiredMessage(subject: string, kind: string): string { const TIMER_PAYLOAD_PREFIX = 'A timer needs to know how to read the time: write '; -const TIMER_PAYLOAD_MESSAGE = +export const TIMER_PAYLOAD_MESSAGE = TIMER_PAYLOAD_PREFIX + `'timer("PT1H")', 'timer(at: "2026-08-01T09:00:00")', or ` + `'timer(every: "R/PT10M")'.`; /** A listener writes the clause after its event word, so it needs its own wording. */ -const LISTENER_TIMER_PAYLOAD_MESSAGE = +export const LISTENER_TIMER_PAYLOAD_MESSAGE = TIMER_PAYLOAD_PREFIX + `'after "PT1H"', 'at "2026-08-01T09:00:00"', or 'every "R/PT10M"'.`; @@ -1132,25 +1049,25 @@ const timerShapeMessage = ( ) => `'${particle}' takes ${expected}; the engine reads it as text and fails when it does not fit (${calendar}).`; -const AFTER_SHAPE_MESSAGE = timerShapeMessage( +export const AFTER_SHAPE_MESSAGE = timerShapeMessage( 'after', 'an ISO 8601 duration such as "PT1H", on its own or beside a start or end date-time ("2026-01-01T00:00:00/PT1H", "PT1H/2026-12-31T00:00:00")', 'DurationBusinessCalendar.resolveDuedate', ); -const AT_SHAPE_MESSAGE = timerShapeMessage( +export const AT_SHAPE_MESSAGE = timerShapeMessage( 'at', 'an ISO date-time such as "2026-08-01T09:00:00", or a duration counted from now', 'DueDateBusinessCalendar.resolveDuedate', ); -const EVERY_SHAPE_MESSAGE = timerShapeMessage( +export const EVERY_SHAPE_MESSAGE = timerShapeMessage( 'every', 'an ISO 8601 repeat such as "R/PT10M", or a six-field cron expression or one of its nicknames such as "@daily"', 'CycleBusinessCalendar.resolveDuedate', ); -/** The shape each particle's calendar reads; {@link BpmnScriptValidator.checkTimerShape} picks by {@link TimerPayload.particle}. */ +/** The shape each particle's calendar reads. */ const TIMER_SHAPE_BY_PARTICLE: Readonly< Record > = { @@ -1160,13 +1077,9 @@ const TIMER_SHAPE_BY_PARTICLE: Readonly< }; /** - * What the condition diagnostics differ by from one position to the next: the - * subject of the sentence, the clause as that position spells it, and how a - * sentence names the position on its own. `only` is separate because the start - * clause carries a name slot: quoting `start S condition` at a user who never - * wrote an `S` puts a placeholder in front of them that nothing introduces. The - * three wordings themselves live once, in - * {@link BpmnScriptValidator.checkConditionPayload}. + * `only` is separate from `clause` because the start clause carries a name + * slot: quoting `start S condition` at an author who never wrote an `S` puts + * a placeholder in front of them that nothing introduces. */ const CONDITION_PHRASING = { handler: { @@ -1188,83 +1101,85 @@ const CONDITION_PHRASING = { type ConditionPosition = keyof typeof CONDITION_PHRASING; -const SECOND_PAREN_VALUE_MESSAGE = +export const SECOND_PAREN_VALUE_MESSAGE = 'The parens carry one unkeyed value, the payload; a second one names ' + "nothing and never reaches the engine. Write it as a 'key: value' setting, " + 'or remove it.'; -const COMPENSATE_TYPO_MESSAGE = +export const COMPENSATE_TYPO_MESSAGE = "Unknown event kind 'compensate'; write 'compensation'."; -/** Answered the same wherever the near miss is written. */ -const CONDITIONAL_TYPO_MESSAGE = `Unknown event kind 'conditional'; did you mean 'condition'?`; +export const CONDITIONAL_TYPO_MESSAGE = `Unknown event kind 'conditional'; did you mean 'condition'?`; -const PARALLEL_SECOND_ELSE_MESSAGE = +export const PARALLEL_SECOND_ELSE_MESSAGE = "A 'parallel' statement takes one 'else' branch at most; the first one " + 'already runs when no condition held. Fold this branch into it or give it a ' + 'condition.'; -const PARALLEL_ELSE_WITHOUT_CONDITION_MESSAGE = +export const PARALLEL_ELSE_WITHOUT_CONDITION_MESSAGE = "An 'else' branch needs a sibling branch with a condition: with no condition " + 'anywhere every branch runs, so there is nothing to fall back from. Give a ' + "sibling a condition, or drop the 'else'."; -const PARALLEL_ELSE_BESIDE_UNCONDITIONED_MESSAGE = +export const PARALLEL_ELSE_BESIDE_UNCONDITIONED_MESSAGE = "An 'else' branch runs only when no sibling branch was taken, and a branch " + 'with no condition is always taken, so this one could never run. Give every ' + "sibling a condition, or drop the 'else'."; -const START_TRIGGER_IN_HANDLER_MESSAGE = +export const START_TRIGGER_IN_HANDLER_MESSAGE = "The start of an event-handler body carries no trigger; the handler's own " + "'on ' is what it catches."; -const NESTED_START_FORM_MESSAGE = +export const NESTED_START_FORM_MESSAGE = 'A start inside a subprocess, attempt block, or handler body takes no ' + "form: the engine reads a start form off the process's own start alone " + '(BpmnParse.parseStartFormHandlers) and BpmnParse.parseScopeStartEvent ' + 'reads none, so this form is never shown.'; -const NESTED_START_INITIATOR_MESSAGE = +export const NESTED_START_INITIATOR_MESSAGE = 'A start inside a subprocess, attempt block, or handler body takes no ' + "'initiator': the engine reads it off the process's own start alone " + '(BpmnParse.parseProcessDefinitionStartEvent) and ' + 'BpmnParse.parseScopeStartEvent reads none, so nothing is written.'; -const SECOND_DEFAULT_START_MESSAGE = +export const SECOND_DEFAULT_START_MESSAGE = 'A process takes one plain or timer start: Operaton refuses a second one ' + '(BpmnParse.selectInitial), so the deployment fails. Keep one, or give ' + 'this start a message, signal, or condition trigger.'; -const START_AFTER_IMPLICIT_START_MESSAGE = +export const START_AFTER_IMPLICIT_START_MESSAGE = 'A body that does not open with a start gets a plain start of its own, ' + "so this is the process's second plain or timer start, which Operaton " + 'refuses (BpmnParse.selectInitial), so the deployment fails. Open the ' + 'body with this start, or give it a message, signal, or condition trigger.'; -function duplicateNamedStartMessage(trigger: string, name: string): string { +export function duplicateNamedStartMessage( + trigger: string, + name: string, +): string { return `Another start already subscribes to ${trigger} '${name}': Operaton keeps one ${trigger} start subscription per name and process (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails.`; } -function duplicateConditionStartMessage(text: string): string { +export function duplicateConditionStartMessage(text: string): string { return `Another start already carries the condition '${text}': Operaton keeps one conditional start per condition text and process (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails.`; } -function timerJobKeyTwiceMessage(key: string): string { +export function timerJobKeyTwiceMessage(key: string): string { return `Setting '${key}' is already written on the 'on timer' head, and both land on this start event, the element the engine reads the timer job's settings from (BpmnParse.parseTimer, DefaultFailedJobParseListener.parseStartEvent); keep one.`; } -function raceDuplicateMessage(trigger: string, name: string): string { +export function raceDuplicateMessage(trigger: string, name: string): string { return `Another branch of this 'await' already catches ${trigger} '${name}': every branch subscribes on the gateway's own scope (BpmnParse.parseIntermediateCatchEvent), which keeps one ${trigger} subscription per name (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails.`; } -const DEAD_LOOP_MESSAGE = +export const DEAD_LOOP_MESSAGE = "This loop can never repeat: every path through the 'do' body ends or redirects the flow, so the condition is never evaluated and the loop gateway would lower to a disconnected node with no incoming flow, which is invalid BPMN. End after the loop, or keep one path through the body."; -function emptyLoopBodyMessage(keyword: string): string { +export function emptyLoopBodyMessage(keyword: string): string { return `The '${keyword}' body has no steps, so the loop and its condition would be dropped: with nothing to loop over, the gateway keeps only its exit and the condition is never written. Put a step in the body, or remove the loop.`; } -function blockMemberMessage(what: string, verb: string): string { +export function blockMemberMessage(what: string, verb: string): string { return `${what} written inside a body belongs to nothing and is dropped: put it in the attribute block of the element it ${verb}, the braces before that element's body.`; } @@ -1272,14 +1187,14 @@ const END_TRIGGERS_MESSAGE = "An end event carries 'terminate', which stops every running path in this " + `scope, or 'cancel', which gives up the 'attempt' block it sits in.`; -const END_TIMER_MESSAGE = +export const END_TIMER_MESSAGE = 'A timer cannot end a process; a timer is something a process waits on. ' + `Write 'await timer("PT1H")' to pause the flow here, ` + `'on timer("PT1H")' to react while the surrounding steps run, or ` + `'on : timer("PT1H")' to watch only while that step runs. ` + END_TRIGGERS_MESSAGE; -const END_CONDITION_MESSAGE = +export const END_CONDITION_MESSAGE = 'A condition cannot end a process; a condition is something a process ' + `waits on. Write 'await condition(amount > 100)' to pause the flow ` + `here, 'on condition(amount > 100)' to react while the surrounding ` + @@ -1287,7 +1202,7 @@ const END_CONDITION_MESSAGE = 'while that step runs. ' + END_TRIGGERS_MESSAGE; -const END_TRIGGER_NO_CODE_MESSAGES: Readonly> = { +export const END_TRIGGER_NO_CODE_MESSAGES: Readonly> = { terminate: 'Terminate names nothing: it stops every running path in this scope; ' + 'leave the payload out.', @@ -1296,106 +1211,98 @@ const END_TRIGGER_NO_CODE_MESSAGES: Readonly> = { 'payload out.', } satisfies Record<(typeof END_TRIGGERS)[number], string>; -const CANCEL_END_PLACEMENT_MESSAGE = +export const CANCEL_END_PLACEMENT_MESSAGE = "A cancel end belongs directly inside an 'attempt' block: it gives that " + 'block up, and the engine refuses one anywhere else. Wrap the steps to ' + `give up in 'attempt { ... }', or end this path with a plain 'end'.`; -const CANCEL_HOSTLESS_MESSAGE = +export const CANCEL_HOSTLESS_MESSAGE = "A cancel is caught on the block it gives up; write 'on : cancel'. " + 'A handler with no host opens on its own trigger, and nothing opens on a ' + 'cancel.'; -const CANCEL_ALONGSIDE_MESSAGE = +export const CANCEL_ALONGSIDE_MESSAGE = 'Giving a block up ends every step still running inside it, so there is ' + "nothing left to run alongside; remove 'alongside'."; -/** On `on`, catch-all is the omitted payload, so an empty code is a mistake. */ -const EMPTY_CODE_MESSAGE = +export const EMPTY_CODE_MESSAGE = 'An empty code ("") is not a catch-all; to catch every error, leave the ' + 'payload out entirely.'; -const CANCEL_NO_CODE_MESSAGE = +export const CANCEL_NO_CODE_MESSAGE = 'A cancel handler catches nothing by name: it runs when its block is ' + 'given up; leave the payload out.'; -const CANCEL_NOT_RAISED_MESSAGE = +export const CANCEL_NOT_RAISED_MESSAGE = 'A cancel is not raised: it is how a block gives itself up; write ' + `'end cancel' inside the 'attempt' block.`; /** Unlike a thrower, an awaiting author needs the catch surface named too. */ -const CANCEL_NOT_AWAITED_MESSAGE = +export const CANCEL_NOT_AWAITED_MESSAGE = 'A cancel is not awaited: it is how a block gives itself up; write ' + `'end cancel' inside the 'attempt' block, and ` + `'on : cancel' beside the block to say what happens then.`; -const COMPENSATION_NO_CODE_MESSAGE = +export const COMPENSATION_NO_CODE_MESSAGE = "Compensation has no code or name: 'on compensation { }' is the undo block " + 'of the subprocess or attempt block it sits in; leave the payload out.'; -const COMPENSATION_BINDINGS_MESSAGE = +export const COMPENSATION_BINDINGS_MESSAGE = "'(code: c)' bindings belong to error and escalation handlers; compensation carries no values."; -const COMPENSATION_ALONGSIDE_MESSAGE = +export const COMPENSATION_ALONGSIDE_MESSAGE = 'The work an undo block reverses has already finished, so there is no ' + "running flow to run alongside; remove 'alongside'."; -const COMPENSATION_PLACEMENT_MESSAGE = +export const COMPENSATION_PLACEMENT_MESSAGE = "An undo block belongs directly inside the 'subprocess' or 'attempt' whose " + 'work it undoes: a process cannot undo itself.'; -const COMPENSATION_DUPLICATE_MESSAGE = +export const COMPENSATION_DUPLICATE_MESSAGE = 'A subprocess or an attempt block has one undo block; merge the steps.'; -const COMPENSATION_HOST_MESSAGE = +export const COMPENSATION_HOST_MESSAGE = "Compensation cannot attach to a host: it undoes a subprocess's " + 'already-completed work through its own undo block, not through a ' + "boundary event; remove the host and write 'on compensation { ... }' " + 'directly inside the subprocess or attempt block it reverses.'; -const LINK_CATCH_FLOW_MESSAGE = +export const LINK_CATCH_FLOW_MESSAGE = "Nothing may flow into an 'await link': end the path before it with 'end', " + "'throw', 'goto', or 'emit link', because a link catch is entered only by " + "'emit link' of the same name."; /** `BpmnParse.parseIntermediateCatchEvent` refuses a link catch behind an event-based gateway. */ -const LINK_IN_RACE_MESSAGE = +export const LINK_IN_RACE_MESSAGE = "'link' cannot head a branch of an 'await' block: the engine refuses a link " + `catch after an event-based gateway; write 'await link("")' as its ` + 'own statement.'; -const ESCALATION_NO_MESSAGE_MESSAGE = +export const ESCALATION_NO_MESSAGE_MESSAGE = 'An escalation carries a code but no message.'; -/** What a header declaration writes inside its parens, both kinds together. */ -const DECLARATION_SETTINGS_ONLY_MESSAGE = +export const DECLARATION_SETTINGS_ONLY_MESSAGE = `A declaration's parens take only ${formatWordList(EVENT_BINDING_FIELDS)} ` + "settings, written 'key: value'."; -/** @param description Noun phrase with article, e.g. `'an if statement'`. */ -const settingsOnlyMessage = (description: string) => +export const settingsOnlyMessage = (description: string) => `The parens of ${description} take only settings, written 'key: value'.`; -/** @param description Noun phrase with article, e.g. `'a while loop'`. */ -const loopJoinKeyMessage = (key: string, description: string) => +export const loopJoinKeyMessage = (key: string, description: string) => `Setting '${key}' is not valid on ${description}: a loop has one gateway, so write '${ENGINE_KEY_BY_JOIN_KEY[key]}'.`; -const refusedHeadKeyMessage = (key: string, description: string): string => +export const refusedHeadKeyMessage = ( + key: string, + description: string, +): string => `Setting '${key}' is not valid on ${description}: Operaton refuses it ` + 'on an event-based gateway (BpmnParse.parseEventBasedGateway). Write it on ' + 'the branch triggers instead.'; -/** @param description Noun phrase with article; the sentence points at this one. */ -const prunedJoinMessage = (description: string, key: string) => +export const prunedJoinMessage = (description: string, key: string) => `Every branch of this ${description.replace(/^an? /, '')} ends its path, so there is no join for '${key}' to set; the setting has no effect.`; -/** - * `retryCycle`/`exclusive` fold into `BpmnParse.parseAsynchronousContinuation`, - * which declares a job only when `asyncBefore` or `asyncAfter` is also true; - * `DefaultFailedJobParseListener.parseActivity` reads a retry cycle the same - * way. Written alone, the setting reaches an element with no job to carry it. - */ -const noJobMessage = ( +export const noJobMessage = ( key: string, pairing: readonly string[], description: string, @@ -1404,14 +1311,7 @@ const noJobMessage = ( `one only when ${formatWordList(pairing)} is also set ` + '(BpmnParse.parseAsynchronousContinuation, DefaultFailedJobParseListener.parseActivity).'; -/** - * `BpmnParse.parseActivity` swaps the scope to the multi-instance body before - * `createActivityOnScope`, so a repeated step's own `jobPriority` (there is no - * `runJobPriority`) prices that per-run activity's job, the one `runAsyncBefore` - * or `runAsyncAfter` creates, never the whole-loop job the plain async flags - * create. - */ -const noPerRunJobMessage = ( +export const noPerRunJobMessage = ( key: string, pairing: readonly string[], description: string, @@ -1440,14 +1340,11 @@ function isTimerCarrier(owner: AttributeOwner): boolean { /** * `BpmnParse.parseSignalCatchEventDefinition` gives a process's signal start - * and an intermediate signal catch an `EventSubscriptionJobDeclaration` - * priced from the catcher's `jobPriority`; `EventSubscriptionEntity.eventReceived` - * creates that job when the throwing `signalEventDefinition` is async. The - * job keeps the declaration's default `exclusive`, and the retry cycle is - * read only for a timer or an async element, so the other two keys still - * need a flag. A boundary signal (`parseBoundarySignalEventDefinition`) and - * an event sub-process signal start (`parseEventDefinitionForSubprocess`) - * declare no such job, so a handler is not one of these. + * and an intermediate signal catch a subscription job priced from the + * catcher's `jobPriority`; the job keeps the default `exclusive`, and the + * retry cycle is read only for a timer or an async element, so the other two + * keys still need a flag. A boundary signal and an event sub-process signal + * start declare no such job, so a handler is not one of these. */ function isSignalSubscriptionJobCarrier(owner: AttributeOwner): boolean { return ( @@ -1459,27 +1356,14 @@ function isSignalSubscriptionJobCarrier(owner: AttributeOwner): boolean { } /** - * Ids the `astToIr` desugarer synthesizes; an author-chosen statement name - * matching one produces duplicate-id IR. ADR-0010 has the templates. Gateway - * ids bypass the desugarer's collision guard entirely; `Boundary_` runs - * through it but would be renamed with a suffix rather than flagged. - * - * The implicit start and end of a process or subprocess body are not here: - * the desugarer mints exactly `StartEvent_` and - * `EndEvent_`, and a prefix would refuse the Modeler's own default - * `StartEvent_1` (ADR-0050). - * - * A handler body's own implicit start and end, and a boundary escape's end, - * are the exception: the desugarer mints them off an id no author writes - * (`StartEvent_EventSubProcess_`, `EndEvent_EventSubProcess_`, - * `EndEvent_Boundary__`), and that coordinate is not the - * enclosing `Process`/`SubProcess` name `isReservedName` matches against, so - * only a prefix catches them. - * - * `_di$`, `BPMNDiagram_`, and `BPMNPlane_` are not the desugarer's own ids: - * `irToXml`'s `layoutProcess` mints `_di` for every shape and edge, and - * `BPMNDiagram_`/`BPMNPlane_` for the document once, so a - * step reusing one of those collides with a duplicate `xs:ID` the same way. + * Ids the `astToIr` desugarer synthesizes (ADR-0010); a statement name + * matching one produces duplicate-id IR. A process or subprocess body's own + * `StartEvent_` and `EndEvent_` are matched exactly by + * {@link mintedTerminalRole} instead, since a prefix would refuse the + * Modeler's default `StartEvent_1` (ADR-0050); a handler body's and a boundary + * escape's terminals are minted off a coordinate no author writes, so only a + * prefix catches them. `_di$`, `BPMNDiagram_` and `BPMNPlane_` are `irToXml`'s + * layout ids, which collide as a duplicate `xs:ID` the same way. */ const RESERVED_ID_PATTERNS: ReadonlyArray = [ /^Gateway_.+_(split|join|fork|loop|race)$/, @@ -1715,7 +1599,7 @@ function previousFlowStatement( * refused under a message of its own (a gateway head, a form field, a code * declaration). */ -export function settingsOnlyOwnerDescription( +function settingsOnlyOwnerDescription( owner: ParenValue['$container'], ): string | undefined { if (isProcess(owner)) return 'a process header'; @@ -1749,13 +1633,12 @@ function duplicateKey( return parts.includes(undefined) ? undefined : parts.join(':'); } -/** Seed `seen` with keys that count as present before the first item. */ function forEachDuplicate( items: Iterable, key: (item: T) => string | undefined, onDuplicate: (item: T) => void, - seen: Set = new Set(), ): void { + const seen = new Set(); for (const item of items) { const k = key(item); if (k === undefined) { @@ -1795,17 +1678,15 @@ export class BpmnScriptValidator { }; /** - * A process body takes any number of top-level starts, each opening the - * body, following another `start`, or following a statement whose flow - * always ends or redirects. A start after a live chain is refused as - * ambiguous rather than guessed: Operaton accepts a flow into a start - * (`BpmnParse.parseSequenceFlow` has no arm for that destination) and runs - * the start as a pass-through step, so the page would state an ambiguity the - * engine resolves one way at runtime. A subprocess, attempt block, or - * event-handler body takes a start first and nowhere else, since the engine - * allows one start per such scope (`BpmnParse.parseScopeStartEvent`). A - * hosted handler's body lowers inline into its host's container, so it is - * no container of its own and gets its own message. + * A start after a live chain is refused rather than guessed at: Operaton + * accepts a flow into a start (`BpmnParse.parseSequenceFlow` has no arm for + * that destination) and runs it as a pass-through step, so the page would + * state an ambiguity the engine resolves one way at runtime. A subprocess, + * attempt block or handler body takes a start first and nowhere else, since + * the engine allows one start per such scope + * (`BpmnParse.parseScopeStartEvent`); a hosted handler's body lowers inline + * into its host's container, so it is no scope of its own and gets its own + * message. */ private checkStartPosition( process: Process, @@ -1857,24 +1738,16 @@ export class BpmnScriptValidator { } /** - * What the engine makes of a process's start set. Three refusals are - * mirrored: `BpmnParse.selectInitial` rejects a second plain or timer start, - * and `BpmnParse.addEventSubscriptionDeclaration` rejects a second message - * or signal start of one name and a second conditional start of one - * condition text, all registered on the process definition. Three facts - * stay silent at deploy time and are told here instead. With no plain or - * timer start, `initial` stays null and starting the process by key throws - * (`ProcessDefinitionImpl.ensureDefaultInitialExists`). A start form binds - * to `initial` only, so a form on any other start is parsed and never shown - * (`BpmnParse.parseStartFormHandlers`). - * `BpmnParse.parseProcessDefinitionStartEvent` reads `operaton:initiator` - * off every start in document order and sets each on the process - * definition, so only the last start naming one keeps its value. - * - * A body that does not open with a `start` gets a plain start minted for - * it, and that one is in the engine's count like a written one: the - * candidates are `[implicit, ...written plain or timer starts]`, and - * everything after the first is a second default start. + * The engine methods behind the three warnings, which do not name them: with + * no plain or timer start, `initial` stays null and starting by key throws + * (`ProcessDefinitionImpl.ensureDefaultInitialExists`); a start form binds + * to `initial` only (`BpmnParse.parseStartFormHandlers`); and + * `BpmnParse.parseProcessDefinitionStartEvent` sets `operaton:initiator` + * off every start in document order, so the last one wins. A body that + * does not open with a `start` gets a plain start minted for it, which is + * in the engine's count like a written one: the candidates are + * `[implicit, ...written plain or timer starts]`, and everything after the + * first is a second default start. */ private checkDefaultStart( process: Process, @@ -1975,24 +1848,16 @@ export class BpmnScriptValidator { }; /** - * Reject a step control flow can never reach: it would lower to a - * disconnected node, which is invalid BPMN. A step named by some `goto`, a - * `start`, or an `await link` is reachable again: the last two each open a - * fresh entry of their own. A start after a live chain is - * {@link checkStartPosition}'s to refuse (a start after a start is legal, - * which `reachable` alone cannot tell); a link catch after one is refused - * here, where `reachable` is exactly "the previous statement still flows - * on", and before the `goto` re-rooting so a catch some `goto` names draws - * only the `goto` rule's error. Operaton accepts a flow into a link catch - * (`BpmnParse.parseSequenceFlow` gives it an ordinary transition); this - * surface refuses it since a modeller's link target never has an incoming - * flow, and an imported catch then prints after a dead fall-through the - * way the diagram drew it. Nested blocks are scanned - * only when their owner is reachable, so an unreachable `if` is reported - * once rather than once per step inside it, and a handler body is a fresh - * root since a handler is not part of the sequential flow. The scan is sound - * rather than exhaustive: a dead step may go unreported, a live one is never - * wrongly rejected. + * Operaton accepts a flow into a link catch (`BpmnParse.parseSequenceFlow` + * gives it an ordinary transition); it is refused here because a modeller's + * link target never has an incoming flow, and an imported catch would then + * print after a dead fall-through the way the diagram drew it. That check + * sits before the `goto` re-rooting so a catch some `goto` names draws only + * the `goto` rule's error; a start after a live chain is + * {@link checkStartPosition}'s to refuse, since a start after a start is + * legal and `reachable` alone cannot tell. The scan is sound rather than + * exhaustive: a dead step may go unreported, a live one is never wrongly + * rejected. */ private checkUnreachableStatements( process: Process, @@ -2048,17 +1913,12 @@ export class BpmnScriptValidator { } /** - * The rules that need both ends of a link pair at once. The engine keeps one - * table of link names per parsed file (`BpmnParse.eventLinkTargets`), so a - * second catch of a name is refused wherever it sits; a throw resolves - * against the catch in its own container, else the first in document order, - * so the duplicate is reported once and not through every throw beside it. - * A flow resolves only at its own level + * A throw resolves against the catch in its own container, else the first + * in document order, so a duplicate catch is reported once rather than + * through every throw beside it. A flow resolves only at its own level * (`ScopeImpl.findActivityAtLevelOfSubprocess`), so both ends must share a - * flow container, as a `goto` and its target must. A throw with no catch - * fails to deploy (`BpmnParse.parseSequenceFlow`); a catch with no throw - * deploys, and an imported diagram may carry one, so it only warns. A - * nameless end is left to the payload rules, which already report it. + * flow container, as a `goto` and its target must; a catch with no throw + * deploys, and an imported diagram may carry one, so it only warns. */ private checkLinkEvents(process: Process, accept: ValidationAcceptor): void { const throws: EmitStatement[] = []; @@ -2248,11 +2108,6 @@ export class BpmnScriptValidator { ); } - /** - * The process-header key set, and why it leaves out the engine settings - * `BpmnParse.parseProcess` still reads off `bpmn:process`, are documented on - * {@link PROCESS_HEADER_KEYS}. - */ private checkProcessAttributes( process: Process, accept: ValidationAcceptor, @@ -2517,12 +2372,7 @@ export class BpmnScriptValidator { this.checkStartPayload(start, TRIGGER_PAYLOAD[start.trigger]!, accept); }; - /** - * A host-less `on timer` writes `exclusive`, `jobPriority` and `retryCycle` - * onto the start event of its body, the element the engine builds the - * timer job from, so a key written on both the head and an authored start - * aims two values at one job and the lowering keeps the head's. - */ + /** A host-less `on timer` writes its job keys onto the start event of its body, so a key written on both aims two values at one job and the lowering keeps the head's. */ private checkTimerHeadKeys( start: StartEvent, owner: AstNode, @@ -2541,8 +2391,9 @@ export class BpmnScriptValidator { } /** - * Mirrors {@link checkCatchPayload}. Neither gets the timer shape warnings: a - * repeating start is a legitimate schedule, not a one-shot mistake. + * Mirrors {@link checkCatchPayload}. Neither gets + * {@link checkTimerRepeatsOnce}'s warning: a repeating start is a + * legitimate schedule, not a one-shot mistake. */ private checkStartPayload( start: StartEvent, @@ -2558,10 +2409,14 @@ export class BpmnScriptValidator { ); } + // `RAW_TEMPLATE` is anchored at the opening quote, so only a name that + // opens with `${` or `#{` lexes as a raw template; an opener further in + // leaves the name a plain string. Both reach here as text through + // `payloadTextOf`. if ( start.trigger === 'message' && name !== undefined && - EXPRESSION_IN_NAME.test(name) + EXPRESSION_ANYWHERE.test(name) ) { accept('error', startMessageExpressionMessage(name), { node: payloadItemOf(start.items)!, @@ -2626,12 +2481,9 @@ export class BpmnScriptValidator { }; /** - * A form reference is a key plus the binding resolving which deployed - * version of that form the engine hands the assignee. Operaton refuses to - * deploy a task naming a form both ways, or naming one with no binding at - * all, so each is an error rather than a warning. Both are reported against - * the task's name, as {@link checkBindingVersionExclusion} is: a user task - * may legitimately name no form. + * Operaton refuses to deploy a task naming a form both ways, or with no + * binding at all, so each is an error; both land on the task's name, as + * {@link checkBindingVersionExclusion}'s does. */ private checkFormReference(task: UserTask, accept: ValidationAcceptor): void { const attrs = settingsOf(task.items); @@ -2710,8 +2562,6 @@ export class BpmnScriptValidator { * or shell task, reported here instead. A field's own shape is * {@link checkField}'s business and comes first, so a value that is neither * a literal nor an expression draws that refusal alone. - * - * @param subject The message's leading noun phrase (`"Service task 'Notify'"`). */ private checkBuiltinBinding( task: ServiceTask | SendTask | BusinessRuleTask, @@ -2800,10 +2650,6 @@ export class BpmnScriptValidator { } } - /** - * The three DMN settings stand only under a `decision` binding: the code - * and topic paths never read them, so each is refused rather than dropped. - */ private checkDecisionModifiers( task: BusinessRuleTask, accept: ValidationAcceptor, @@ -3038,7 +2884,6 @@ export class BpmnScriptValidator { } } - /** @param description Sentence-starting noun phrase, e.g. `'A service task'`. */ private rejectFormBlock( forms: FormBlock[], description: string, @@ -3054,11 +2899,9 @@ export class BpmnScriptValidator { } /** - * The engine settings alone. A duplicate is the caller's to check, over every - * setting the parens hold rather than these: a second `label` is as much a - * duplicate as a second `assignee`, and a structural key never reaches here. - * - * @param description Noun phrase with article, e.g. `'a user task'`. + * A duplicate is the caller's to check, over every setting the parens hold + * rather than these: a second `label` is as much a duplicate as a second + * `assignee`, and a structural key never reaches here. */ private checkAttributeKeys( attrs: readonly Setting[], @@ -3071,11 +2914,9 @@ export class BpmnScriptValidator { } /** - * A repeated *same* key is the duplicate-key check's business, so the count + * A repeated same key is the duplicate-key check's business, so the count * is over distinct keys. * - * @param subject The message's leading noun phrase (`"Service task 'total'"`). - * @param alternative Appended to the names-none message only. * @returns Whether exactly one binding is written, so a check reading that * binding's value can stand down otherwise. */ @@ -3116,12 +2957,9 @@ export class BpmnScriptValidator { } /** - * A bare word in the parens is a flag. `sequentially` and `local` reach here - * rather than the parser because they are keywords elsewhere in the grammar - * and so lex inside any parens; without this they would be accepted and - * lower to nothing. - * - * @param description Noun phrase with article, e.g. `'a user task'`. + * `sequentially` and `local` reach here rather than the parser because they + * are keywords elsewhere in the grammar and so lex inside any parens; + * without this they would be accepted and lower to nothing. */ private checkFlags( items: ParenItem[], @@ -3263,11 +3101,7 @@ export class BpmnScriptValidator { this.checkListeners(owner, rule, accept); } - /** - * A run key on a statement with no `for` clause contradicts itself, so the - * refusal names both fixes. A kind that never takes a clause owns no run - * key, so the unknown-key check answers there. - */ + /** A kind that never takes a clause owns no run key, so the unknown-key check answers there. */ private checkRunSettings( owner: AttributeOwner, rule: AttributeBlockRule, @@ -3371,12 +3205,6 @@ export class BpmnScriptValidator { } } - /** - * The block's members split four ways: the io directions, the field - * direction, the property direction, and a word that is none of them. Which - * of them the owner takes is its row's business, and a member of a direction - * it does not take is reported against the direction word the author wrote. - */ private checkIoParameters( owner: AttributeOwner, rule: AttributeBlockRule, @@ -3519,14 +3347,10 @@ export class BpmnScriptValidator { } /** - * A field configures the implementation the binding instantiates, so a - * binding running none has nothing to inject into and the engine hands it no - * field list. One diagnostic per member: a field with no place to go is the - * mistake to fix before its value shape. Called where the member is written, - * so the block's diagnostics stay in document order. - * - * @param refusal Why no field rides here, or `undefined` where one does. The - * caller words it, since what to remove differs by what the owner wrote. + * One diagnostic per member: a field with no place to go is the mistake to + * fix before its value shape. Called where the member is written, so the + * block's diagnostics stay in document order; the caller words `refusal`, + * since what to remove differs by what the owner wrote. */ private checkField( field: IoParameter, @@ -3556,12 +3380,6 @@ export class BpmnScriptValidator { } } - /** - * The one authoring rule a repeat clause carries: Operaton rejects the - * deployment outright (`BpmnParse.checkActivityOutputParameterSupported`), - * so this is an error rather than a warning, reported once however many - * mappings the block holds. Every other shape rule is already the grammar's. - */ private checkRepeatedOutput( owner: AttributeOwner, directed: readonly IoParameter[], @@ -3570,7 +3388,9 @@ export class BpmnScriptValidator { if (!isRepeated(owner)) { return; } - const mapping = directed.find((param) => param.direction === 'output'); + const mapping = directed.find( + (param) => param.direction === OUTPUT_DIRECTION, + ); if (mapping) { accept('error', REPEATED_OUTPUT_MESSAGE, { node: mapping, @@ -3580,15 +3400,11 @@ export class BpmnScriptValidator { } /** - * A map value may hold a list holding another map, so the walk goes to any - * depth. A keyless entry compiles to unimportable XML, and an empty literal - * anywhere in the tree - the value itself, a list item, or a map entry's - * value - is `BpmnParseUtil.getElValueProvider`'s null spelling rather than - * an empty string, and it trims before that test. A script anywhere in the - * tree follows the same fence rules a script task's body does: + * The walk goes to any depth, since a map value may hold a list holding + * another map. A keyless entry compiles to unimportable XML; a script + * anywhere in the tree follows a script task's fence rules, since * `ScriptingEngines.getGlobalScriptEngine` resolves the tag the same way, - * only later, when the parameter is first evaluated rather than at - * deployment. + * only when the parameter is first evaluated rather than at deployment. */ private checkIoValueShapes( param: IoParameter, @@ -3825,34 +3641,41 @@ export class BpmnScriptValidator { /** The grammar allows an empty `Block`, so an empty branch is a warning, not an error. */ checkIfStatement = (stmt: IfStatement, accept: ValidationAcceptor): void => { this.checkGatewaySettings(stmt, accept); - this.warnIfEmptyBlock(stmt.then, "The 'if' branch has no steps.", accept); + this.checkEmptyBlock( + stmt.then, + 'warning', + "The 'if' branch has no steps.", + accept, + ); for (const elseIf of stmt.elseIfs) { - this.warnIfEmptyBlock( + this.checkEmptyBlock( elseIf.body, + 'warning', "The 'else if' branch has no steps.", accept, ); } if (stmt.elseBlock) { - this.warnIfEmptyBlock( + this.checkEmptyBlock( stmt.elseBlock, + 'warning', "The 'else' branch has no steps.", accept, ); } }; - /** - * An empty loop body is an error where an empty branch is a warning: with - * no entry node to run the conditioned flow into, the lowering writes the - * gateway with its exit alone and the condition is lost. - */ checkWhileStatement = ( stmt: WhileStatement, accept: ValidationAcceptor, ): void => { this.checkGatewaySettings(stmt, accept); - this.refuseEmptyLoopBody(stmt.body, 'while', accept); + this.checkEmptyBlock( + stmt.body, + 'error', + emptyLoopBodyMessage('while'), + accept, + ); }; checkDoWhileStatement = ( @@ -3860,7 +3683,12 @@ export class BpmnScriptValidator { accept: ValidationAcceptor, ): void => { this.checkGatewaySettings(stmt, accept); - this.refuseEmptyLoopBody(stmt.body, 'do', accept); + this.checkEmptyBlock( + stmt.body, + 'error', + emptyLoopBodyMessage('do'), + accept, + ); // The body runs before the gateway, so a body that always ends leaves // the gateway with no incoming flow; the unreachable-step scan only sees // the statements after it. @@ -3869,19 +3697,6 @@ export class BpmnScriptValidator { } }; - private refuseEmptyLoopBody( - block: Block | undefined, - keyword: string, - accept: ValidationAcceptor, - ): void { - if (block !== undefined && block.statements.length === 0) { - accept('error', emptyLoopBodyMessage(keyword), { - node: block, - property: 'statements', - }); - } - } - /** * The grammar admits a parameter or a listener in any body so that one * `Block` rule serves every statement, but no lowering reads them there: @@ -3918,11 +3733,6 @@ export class BpmnScriptValidator { this.checkCancelPair(stmt, accept); }; - /** - * A cancel end and the handler catching it are written apart, and each half - * is inert without the other: the engine deploys either alone and then stops - * at the first token reaching the end, or never enters the handler. - */ private checkCancelPair(block: SubProcess, accept: ValidationAcceptor): void { if (!block.transactional || block.name === undefined) return; @@ -3948,8 +3758,9 @@ export class BpmnScriptValidator { ): void => { this.checkGatewaySettings(stmt, accept); stmt.branches.forEach((branch, index) => { - this.warnIfEmptyBlock( + this.checkEmptyBlock( branch.body, + 'warning', `Branch ${index + 1} of the 'parallel' statement has no steps.`, accept, ); @@ -3957,13 +3768,7 @@ export class BpmnScriptValidator { this.checkFallbackBranch(stmt, accept); }; - /** - * The `else` branch runs when no sibling condition held. Two of them leave - * the second unreachable, and one beside an unconditioned branch is dead: - * that branch always runs, so nothing is left over to fall back on. With - * every branch unconditioned that is the whole statement, the sharper of the - * two diagnoses and the one reported. - */ + /** With every branch unconditioned both diagnoses hold; the whole-statement one is the sharper and the one reported. */ private checkFallbackBranch( stmt: ParallelStatement, accept: ValidationAcceptor, @@ -3997,8 +3802,9 @@ export class BpmnScriptValidator { this.checkGatewaySettings(stmt, accept); stmt.branches.forEach((branch, index) => { this.checkAttributeBlock(branch, accept); - this.warnIfEmptyBlock( + this.checkEmptyBlock( branch.body, + 'warning', `Branch ${index + 1} of the 'await' statement has no steps.`, accept, ); @@ -4018,24 +3824,18 @@ export class BpmnScriptValidator { ); }; - private warnIfEmptyBlock( + private checkEmptyBlock( block: Block | undefined, + severity: 'error' | 'warning', message: string, accept: ValidationAcceptor, ): void { - if (block === undefined) { - return; - } - if (block.statements.length === 0) { - accept('warning', message, { node: block, property: 'statements' }); + if (block !== undefined && block.statements.length === 0) { + accept(severity, message, { node: block, property: 'statements' }); } } - /** - * A branch's steps run only when the whole statement is reached, so a `goto` - * into one from outside is an error under both branching constructs. An - * unresolved `goto` is skipped: the linker already reports it. - */ + /** An unresolved `goto` is skipped: the linker already reports it. */ checkGotoStatement = ( goto: GotoStatement, accept: ValidationAcceptor, @@ -4129,11 +3929,6 @@ export class BpmnScriptValidator { ); } - /** - * Both pin which deployed version runs, so declaring both is one error. - * - * @param subject The message's leading noun phrase (`'A call'`). - */ private checkBindingVersionExclusion( owner: VersionPinnedElement, subject: string, @@ -4152,7 +3947,7 @@ export class BpmnScriptValidator { /** * Both pin the same variable-mapping delegate, so the engine's if/else-if - * would silently drop one; refusing here beats mirroring that at import. + * would drop one without a word; refusing here beats mirroring that at import. */ private checkCallMapperExclusion( call: CallActivity, @@ -4230,8 +4025,9 @@ export class BpmnScriptValidator { this.checkHandlerBindings(handler, accept); } - this.warnIfEmptyBlock( + this.checkEmptyBlock( handler.body, + 'warning', 'The event handler has no steps.', accept, ); @@ -4291,13 +4087,7 @@ export class BpmnScriptValidator { } } - /** - * The three diagnostics a condition clause raises, wherever one is written: - * the clause is required where the trigger is `condition`, a quoted string - * there is a code rather than the condition, and an expression payload - * belongs to no other trigger. At most one of the three holds for a given - * node, so they need no ordering between them. - */ + /** At most one of the three holds for a given node, so they need no ordering between them. */ private checkConditionPayload( node: CatchHeader | OnHandler | StartEvent, rule: TriggerPayloadRule, @@ -4332,14 +4122,11 @@ export class BpmnScriptValidator { } /** - * The timer a trigger's parens carry: a duration written bare, a date or a - * cycle under its key. A timer start's expression is parsed at deployment + * A timer start's expression is parsed at deployment * (`BpmnDeployer.adjustStartEventSubscriptions`, * `TimerDeclarationImpl.createStartTimerInstance`), so a bad shape there is * an error; every other carrier's is read only when its scope is entered, * so a bad shape there is a warning. - * - * @param particleOnly The message for a keyed time where the kind takes none. */ private checkTimerClause( node: CatchHeader | OnHandler | StartEvent, @@ -4454,13 +4241,7 @@ export class BpmnScriptValidator { } } - /** - * A handler scopes to a whole container, so it belongs directly in a process, - * `subprocess`, or handler body (BPMN allows nested event sub-processes) and - * never in a branch. `on compensation` is tighter still, belonging inside the - * one `subprocess` whose work it undoes; that rule only fires where the - * generic one passed, so one mistake gives one message. - */ + /** The compensation rule fires only where the generic placement passed, so one mistake gives one message. */ private checkHandlerPlacement( handler: OnHandler, accept: ValidationAcceptor, @@ -4534,13 +4315,11 @@ export class BpmnScriptValidator { } /** - * Whether this handler may name a host, and whether the host it names is one - * it could legally attach to; stops at the first violation. An unresolved - * host is skipped, the linker already reports it. A host inside the handler's - * own body is circular: the scope provider offers those steps, but such a - * step only runs after the boundary event fired, so the engine would deploy a - * path nothing can take. The narrower `escalation` and `cancel` host sets are - * Operaton's own restrictions in `BpmnParse`. + * Stops at the first violation; an unresolved host is skipped, the linker + * already reports it. The scope provider offers a step inside the handler's + * own body as a host, so the circular case is refused here; the narrower + * `escalation` and `cancel` host sets are Operaton's own + * (`BpmnParse.parseBoundaryEvents`). */ private checkHandlerHost( handler: OnHandler, @@ -4821,15 +4600,9 @@ export class BpmnScriptValidator { } /** - * The header declarations a use site names. Two of one name leave a - * reference no way to say which it means; two of one code lower to a single - * event definition, so the second name would come back as the first. Codes - * are keyed per kind: an error and an escalation are separate definitions and - * share nothing by carrying one code. - * - * A name a step also uses stays legal. A reference resolves by type, so the - * two never compete, and reserving every declared name would take the word - * from the author for nothing. + * Codes are keyed per kind: an error and an escalation are separate + * definitions and share nothing by carrying one code. A name a step also + * uses stays legal: a reference resolves by type, so the two never compete. */ private checkCodeDecls(process: Process, accept: ValidationAcceptor): void { const declaredNames = new Set(); @@ -4932,12 +4705,7 @@ function collectExpressions(process: Process): Expr[] { return AstUtils.streamAst(process).filter(isExpr).toArray(); } -/** - * Whether a name collides with the desugarer's own id namespace: the patterns, - * and given the id of the container the statement sits in, the exact start and - * end minted for it. Exported so the printer can warn about an imported - * model's id from this one list. - */ +/** Exported so the printer can warn about an imported model's id off this one list. */ export function isReservedName(name: string, containerId?: string): boolean { return ( RESERVED_ID_PATTERNS.some((re) => re.test(name)) || @@ -4947,10 +4715,10 @@ export function isReservedName(name: string, containerId?: string): boolean { } /** - * The name of the process or `subprocess`/`attempt` block whose body holds the - * statement, which seeds the implicit start and end the desugarer mints for - * that body. A handler body is not one: its implicit events are seeded from a - * coordinate-derived id no author writes. + * The process or `subprocess`/`attempt` block whose body holds the statement, + * whose name seeds the implicit start and end minted for that body. A handler + * body is not one: its implicit events are seeded from a coordinate no author + * writes. */ function enclosingContainerName(node: NamedStatement): string | undefined { const container = AstUtils.getContainerOfType( @@ -4964,7 +4732,6 @@ function capitalize(text: string): string { return text.charAt(0).toUpperCase() + text.slice(1); } -/** @param subject The message's leading noun phrase (`"Script task 'total'"`). */ function checkFencedScript( raw: string, subject: string, @@ -4986,17 +4753,9 @@ function checkFencedScript( } } -/** The shape a `name=ID` slot takes, so a code spelled that way can be declared under itself. */ -const DECLARABLE_NAME = /^[_a-zA-Z]\w*(-\w+)*$/; - -/** - * A code names a declaration rather than carrying its own text, so quoted text - * in a code position is a missing declaration. Text a `name=ID` slot could hold - * is offered as the name itself; anything else needs a name of the author's - * choosing and keeps the text as the declaration's `code`. - */ -function quotedCodeMessage(trigger: string, text: string): string { - const spellable = DECLARABLE_NAME.test(text); +export function quotedCodeMessage(trigger: string, text: string): string { + // A code spelled like a `name=ID` slot can be declared under itself. + const spellable = ID_TEXT.test(text); const declaration = spellable ? `${trigger} ${text}` : `${trigger} (code: ${JSON.stringify(text)})`; @@ -5007,11 +4766,6 @@ function quotedCodeMessage(trigger: string, text: string): string { ); } -/** - * The mirror of {@link quotedCodeMessage}: a message, signal, or link name is - * the text the engine matches on, so it carries its own text rather than - * referring to a declaration. - */ function barewordNameMessage(trigger: string, text: string): string { return ( `A ${trigger} name is the text the engine matches by name, not a declared ` + @@ -5023,12 +4777,8 @@ function unknownTriggerMessage(word: string, legal: readonly string[]): string { return `Unknown event kind '${word}'; write ${formatWordList(legal)}.`; } -/** - * A declaration and a step both open with a word, so a mistyped step keyword - * followed by a name parses as a declaration and lands here rather than at the - * parser's declaration-or-step guidance. The message names both readings. - */ -function unknownDeclarationKindMessage(word: string): string { +/** A mistyped step keyword followed by a name parses as a declaration and lands here rather than at the parser's declaration-or-step guidance, so the message names both readings. */ +export function unknownDeclarationKindMessage(word: string): string { return ( `Unknown declaration kind '${word}'; write ` + `${formatWordList([...DECLARED_CODE_TRIGGERS])}, or a step keyword if a ` + @@ -5036,7 +4786,7 @@ function unknownDeclarationKindMessage(word: string): string { ); } -function onTriggerMessage(word: string): string { +export function onTriggerMessage(word: string): string { if (word === 'conditional') { return CONDITIONAL_TYPO_MESSAGE; } @@ -5046,7 +4796,7 @@ function onTriggerMessage(word: string): string { return unknownTriggerMessage(word, ON_TRIGGERS); } -function startTriggerMessage(word: string): string { +export function startTriggerMessage(word: string): string { if (word === 'error' || word === 'escalation') { return ( `A process cannot start on an ${word}: the engine ignores the trigger ` + @@ -5068,7 +4818,7 @@ function startTriggerMessage(word: string): string { } /** Only the triggers that interrupt by nature reach this; each says why. */ -function alongsideMessage(trigger: string): string { +export function alongsideMessage(trigger: string): string { if (trigger === 'compensation') return COMPENSATION_ALONGSIDE_MESSAGE; if (trigger === 'cancel') return CANCEL_ALONGSIDE_MESSAGE; return ( @@ -5077,7 +4827,7 @@ function alongsideMessage(trigger: string): string { ); } -function endTriggerMessage(word: string): string { +export function endTriggerMessage(word: string): string { if (THROW_TRIGGERS_SET.has(word)) { const article = /^[aeiou]/.test(word) ? 'An' : 'A'; return ( @@ -5098,14 +4848,7 @@ function endTriggerMessage(word: string): string { ); } -/** - * `RAW_TEMPLATE` is anchored at the opening quote, so only a name that *opens* - * with `${` or `#{` lexes as a raw template; an opener further in leaves the - * name a plain string. Both reach here as text through `payloadTextOf`. - */ -const EXPRESSION_IN_NAME = /\$\{|#\{/; - -function startMessageExpressionMessage(name: string): string { +export function startMessageExpressionMessage(name: string): string { return ( `A message start name cannot contain an expression ("${name}"): the ` + 'engine rejects one there, because a process that has not started yet ' + @@ -5115,7 +4858,7 @@ function startMessageExpressionMessage(name: string): string { ); } -function throwTriggerMessage(word: string): string { +export function throwTriggerMessage(word: string): string { if (word === 'compensate') { return COMPENSATE_TYPO_MESSAGE; } @@ -5128,7 +4871,7 @@ function throwTriggerMessage(word: string): string { return unknownTriggerMessage(word, THROW_TRIGGERS); } -function emitTriggerMessage(word: string): string { +export function emitTriggerMessage(word: string): string { if (word === 'error') { return "An error always aborts its path; write 'throw error'."; } @@ -5141,7 +4884,7 @@ function emitTriggerMessage(word: string): string { return unknownTriggerMessage(word, EMIT_TRIGGERS); } -function catchTriggerMessage(word: string): string { +export function catchTriggerMessage(word: string): string { if (word === 'compensate') { return COMPENSATE_TYPO_MESSAGE; } @@ -5188,10 +4931,6 @@ export function isAttemptBlock(node: AstNode | undefined): node is SubProcess { return node !== undefined && isSubProcess(node) && node.transactional; } -/** - * Whether a cancel end ends `block` itself. The enclosing container is the - * scope the engine reads, so an end in an `if` branch of the block counts. - */ function hasCancelEndInScope(block: SubProcess): boolean { for (const node of AstUtils.streamAst(block)) { if ( @@ -5227,7 +4966,7 @@ function cancelHostMessage(host: Statement): string { ); } -function cancelEndWithoutHandlerMessage(name: string): string { +export function cancelEndWithoutHandlerMessage(name: string): string { return ( `'${name}' gives itself up but nothing catches it: the engine stops with ` + `an error the first time that end is reached. Write 'on ${name}: cancel ` + @@ -5235,7 +4974,7 @@ function cancelEndWithoutHandlerMessage(name: string): string { ); } -function cancelHandlerWithoutEndMessage(name: string): string { +export function cancelHandlerWithoutEndMessage(name: string): string { return ( `Nothing inside '${name}' gives it up, so this handler never runs: write ` + `'end cancel' on the path that should give the block up, or ` + @@ -5244,13 +4983,12 @@ function cancelHandlerWithoutEndMessage(name: string): string { } /** - * The noun phrase the diagnostics name a statement by, off the one table that - * spells every element kind out, so an `attempt` block is named for the head - * its author wrote rather than for the rule it shares. The fallback covers the - * statements with no row, which no diagnostic reaches. + * Every caller hands a `SubProcess` or a resolved host, which the scope offers + * only for the named kinds, and each of those has a row; the row names an + * `attempt` block for the head its author wrote rather than the rule it shares. */ function describeStatementKind(stmt: Statement): string { - return attributeBlockRuleOf(stmt)?.description ?? 'not an activity'; + return attributeBlockRuleOf(stmt)!.description; } function startTriggerInBlockMessage(block: SubProcess): string { @@ -5278,7 +5016,7 @@ function escalationHostMessage(host: Statement): string { ); } -function selfAttachedHostMessage(hostName: string): string { +export function selfAttachedHostMessage(hostName: string): string { return ( `A boundary event cannot attach to a step inside its own escape path: ` + `'${hostName}' only runs after this handler has already fired, so it ` + @@ -5286,7 +5024,7 @@ function selfAttachedHostMessage(hostName: string): string { ); } -function hostedHandlerStartMessage(name: string): string { +export function hostedHandlerStartMessage(name: string): string { return ( `'start ${name}' cannot open a handler that names a host: the body runs ` + "inside the host's own container and is entered from the boundary event, " + @@ -5295,7 +5033,7 @@ function hostedHandlerStartMessage(name: string): string { ); } -function noDefaultStartMessage(name: string): string { +export function noDefaultStartMessage(name: string): string { return ( `Process '${name}' has no default start: with only message, signal, or ` + 'condition starts, the engine can create an instance only by triggering ' + @@ -5303,12 +5041,12 @@ function noDefaultStartMessage(name: string): string { ); } -const FORM_NEVER_OFFERED_MESSAGE = +export const FORM_NEVER_OFFERED_MESSAGE = "The engine offers a start form only on the process's default start, its " + 'plain or timer start; this form is on a different start and is never ' + 'shown.'; -const INITIATOR_SHADOWED_MESSAGE = +export const INITIATOR_SHADOWED_MESSAGE = 'The engine keeps one initiator per process: whichever start is parsed ' + 'last wins, so this setting is never written. Move it to the last ' + 'start, or drop it.'; @@ -5319,13 +5057,7 @@ const NAME_SCOPED_TRIGGERS: ReadonlySet = new Set([ 'signal', ]); -/** - * Why a second catch of one thing on one scope is refused, per trigger. The - * error arm is the one the engine does not refuse: `addErrorEventDefinition` - * sorts the definitions and the first match wins, so the rule is this - * surface's. - */ -const HANDLER_DUPLICATE_RULE: Readonly> = { +export const HANDLER_DUPLICATE_RULE: Readonly> = { message: 'Operaton keeps one message subscription per name and scope (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails', signal: @@ -5350,17 +5082,15 @@ function subscriptionScopeOf(handler: OnHandler): AstNode | undefined { } /** - * Which catches of one scope a handler is compared against. A message or - * signal name is unique per scope among the catches that are not process - * starts (`BpmnParse.hasMultipleEventDefinitionsWithSameName` keys the - * start flag too, and `parseEventDefinitionForSubprocess` clears it), so - * the boundary and the event sub-process collide while a message or signal - * start of the same name sits beside either; an escalation, error, or cancel + * A message or signal name is unique per scope among the catches that are not + * process starts (`BpmnParse.hasMultipleEventDefinitionsWithSameName` keys the + * start flag, and `parseEventDefinitionForSubprocess` clears it), so a + * boundary and an event sub-process collide; an escalation, error or cancel * catch is compared against its own kind alone - * (`BpmnParse.addEscalationEventDefinition` branches on - * `isSubProcessScope`). A boundary on a repeated host subscribes on the - * multi-instance body, one scope above the host's own - * (`BpmnParse.getMultiInstanceScope`), so a name inside such a host stays free. + * (`BpmnParse.addEscalationEventDefinition` branches on `isSubProcessScope`). + * A boundary on a repeated host subscribes on the multi-instance body, one + * scope above the host's own (`BpmnParse.getMultiInstanceScope`), so a name + * inside such a host stays free. */ function catchKindOf(handler: OnHandler): string { if (handler.host?.ref === undefined) return 'body'; @@ -5422,17 +5152,12 @@ function readsExternalTask(ref: VarRef): boolean { } /** - * Whether `ref` reads a process variable, which is what the undeclared-variable - * warning, a definition lookup and a rename all key on. - * Every other position names something else: an `out` mapping's source is - * evaluated in the called process's scope; the value of a - * {@link NON_VARIABLE_ATTR_KEYS} setting is plain text (the direct value - * alone, a nested reference is a variable); a declaration's parens hold the - * text a code and its message are made of; a gateway head's parens take - * settings alone; a code or a name position names an event; and - * `externalTask` inside a mapping is the engine's own object. The answer is - * read off the container chain alone, so it also holds for the stand-in - * completion builds for a reference not yet typed, whose `ref` is empty. + * Whether `ref` reads a process variable, which the undeclared-variable + * warning, a definition lookup and a rename all key on. Only the direct value + * of a {@link NON_VARIABLE_ATTR_KEYS} setting is plain text; a reference + * nested in one is a variable. The answer is read off the container chain + * alone, so it also holds for the stand-in the completion provider builds for + * a reference not yet typed, whose `ref` is empty. */ export function isVariableUse(ref: VarRef): boolean { const container = ref.$container; @@ -5518,12 +5243,6 @@ function formDefaultShape( } } -/** - * Why no external extra rides here, or `undefined` where the parens bind a - * `topic`. Asked on a kind whose row takes the extras alone. - * - * @param item The extra as written (`'a property line'`). - */ function topicRefusalOf( rule: AttributeBlockRule, settings: readonly Setting[], @@ -5538,7 +5257,6 @@ function topicRefusalOf( ); } -/** Whether the parens name a binding the engine injects a field into. */ function namesFieldBinding(attrs: readonly Setting[]): boolean { return attrs.some((attr) => FIELD_BINDING_KEY_SET.has(attr.key)); } @@ -5552,7 +5270,6 @@ function isFieldValue(value: IoValue | undefined): boolean { return value !== undefined && (isLiteralString(value) || isRawExpr(value)); } -/** The distinct binding keys written on an element, in document order. */ function bindingKeysOf( attrs: readonly Setting[], keys: readonly string[], @@ -5564,7 +5281,6 @@ function bindingKeysOf( ]; } -/** @param subject The message's leading noun phrase (`"Service task 'total'"`). */ function checkAtMostOneBinding( attrs: readonly Setting[], keys: readonly string[], @@ -5582,12 +5298,6 @@ function checkAtMostOneBinding( } } -/** - * An implementation is what makes the engine really send a thrown message, so - * no other kind has one to run, and a message without one is legal and common. - * - * @param subject The leading noun phrase (`'a thrown'`/`'an emitted'`). - */ function checkThrowEmitBinding( stmt: ThrowStatement | EmitStatement, subject: 'a thrown' | 'an emitted', @@ -5619,13 +5329,7 @@ function checkThrowEmitBinding( ); } -/** - * There is no catch-all on the throwing side, so for every trigger but - * `compensation` an omitted and an empty code are the same mistake; - * `compensation` names nothing, so carrying a code at all is the mistake. - * - * @param subject The leading noun phrase (`'A thrown'`/`'An emitted'`). - */ +/** There is no catch-all on the throwing side, so an omitted and an empty code are the same mistake. */ function checkThrowEmitCode( stmt: ThrowStatement | EmitStatement, subject: 'A thrown' | 'An emitted', @@ -5732,11 +5436,9 @@ const STRING_OR_PROPERTY_READ = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|\.\s*([A-Za-z_$][\w$]*)/g; /** - * Every `.prop` a raw template reads, held to the keyword rule a rendered - * accessor is under, plus `true`/`false`/`null`: the scanner registers all of - * them as operators or literal tokens wherever they stand, so `${order.and}` - * and `${order.true}` both fail to parse at deployment, though the grammar - * would never let either spell a variable or an accessor name. Text between + * `true`, `false` and `null` are held to the keyword rule too: a raw template + * is plain text, so unlike a rendered accessor it can spell them as a `.prop` + * read, and the scanner still returns their literal tokens there. Text between * two templates is never scanned as an expression, so a word there is text. */ function checkRawTemplateNames( @@ -5828,11 +5530,7 @@ function nonBooleanShapeOf( return undefined; } -/** - * The body of the nearest branch enclosing `node` and the keyword its statement - * is written with. Both branch nodes have `body` as their only `Block`-typed - * property, so a `Block` directly under one is that branch's body. - */ +/** Both branch nodes have `body` as their only `Block`-typed property, so a `Block` directly under one is that branch's body. */ function findEnclosingBranch( node: AstNode, ): { body: Block; keyword: 'parallel' | 'await' } | undefined { @@ -5853,7 +5551,7 @@ function findEnclosingBranch( return undefined; } -function intoBranchMessage( +export function intoBranchMessage( subject: string, jump: string, keyword: 'parallel' | 'await', @@ -5862,7 +5560,7 @@ function intoBranchMessage( return `'${subject}' jumps into a branch of ${article} '${keyword}' statement from outside that branch; a branch's steps run only when the whole '${keyword}' statement is reached, not via an external '${jump}'.`; } -function linkThrowNeverRunsMessage(item: string): string { +export function linkThrowNeverRunsMessage(item: string): string { return ( `${item} has no effect on an emitted link: the engine creates no activity ` + "for a link throw, so nothing written on it runs. Put it on the 'await " + diff --git a/packages/language/src/bpmn-script-value-converter.ts b/packages/language/src/bpmn-script-value-converter.ts index 444b6c3..049198e 100644 --- a/packages/language/src/bpmn-script-value-converter.ts +++ b/packages/language/src/bpmn-script-value-converter.ts @@ -1,12 +1,9 @@ /** - * Reads every `RAW_TEMPLATE` token the way Langium reads `STRING`: quotes - * dropped and the escapes `\"`, `\\`, `\n`, `\r`, `\t` resolved, so - * `"${fn(\"a\")}"` becomes `${fn("a")}`. Langium's `DefaultValueConverter` - * unquotes only the terminal named `STRING`; a string-typed terminal under - * any other name comes back verbatim, and a body kept with its escapes would - * reach the engine with a `\` outside a JUEL string and gain one more each - * time the printer quotes it. - * The printer's quoting is the exact inverse of this read. + * Reads a `RAW_TEMPLATE` token the way Langium reads `STRING`, quotes dropped + * and escapes resolved, so `"${fn(\"a\")}"` becomes `${fn("a")}`. Langium's + * `DefaultValueConverter` unquotes only the terminal named `STRING`; kept + * verbatim, the body would reach the engine with a `\` outside a JUEL string + * and gain one more each time the printer quotes it. */ import { diff --git a/packages/language/src/bpmn-script.langium b/packages/language/src/bpmn-script.langium index d1a67d8..7491fe5 100644 --- a/packages/language/src/bpmn-script.langium +++ b/packages/language/src/bpmn-script.langium @@ -1,24 +1,17 @@ -// The BPMNscript grammar: code-like structured statements. +// The BPMNscript grammar. A process body is a sequence of statements running +// top to bottom, so sequence flow is never written; gateways come from the +// structured statements and there is no `gateway` keyword. // -// A process body is a sequence of statements executing top-to-bottom (implicit -// sequence flow); control flow is `if`/`else if`/`else`, `while`, `do while`, -// `parallel`, `await { ... }`, and `goto `. `start`/`end` may be written but -// are synthesized by the desugarer when absent. There is no flat node/edge -// surface and no `gateway` keyword: gateways are derived from the structured -// constructs. +// The AST property `name` is the cross-reference key, which is what lets +// `goto target=[Statement:ID]` resolve through Langium's default NameProvider; +// the BPMN label is the separate `label` setting. // -// The AST property `name` holds the DSL identifier used as the cross-reference -// target key, which is what lets `goto target=[Statement:ID]` resolve through -// Langium's default NameProvider. The human-readable BPMN label is the separate -// `label` property. -// -// Reserved words are exactly this file's keywords, read back out of the grammar -// by `reservedWordsOf` in `vocabulary.ts`; a variable named like one is written +// Reserved words are exactly this file's keywords (`reservedWordsOf` in +// `vocabulary.ts` reads them back out); a variable named like one is written // with the quoted "${...}" raw-string fallback. The trigger kinds, catch-binding // fields, timer particles, setting keys, parameter directions, the `enum` form -// type, and listener events are deliberately soft: they lex as plain `ID` and -// are checked in position, so `var message: string` and `if (priority > 5)` -// stay legal and a step may be named after any business concept. A +// type and listener events are soft: they lex as plain `ID` and are checked in +// position, so `var message: string` and `if (priority > 5)` stay legal. A // statement-opening word cannot be soft, because after `start S` it would be // swallowed as `start`'s trigger rather than opening its own statement. @@ -27,9 +20,8 @@ grammar BpmnScript entry Model: (processes+=Process)*; -// `process ()? {
}`. Declarations are -// gathered into `decls` and executable statements into `body`, so the desugarer -// and the validator can address them independently. +// Declarations go into `decls` and statements into `body`, so the desugarer +// and the validator address them apart. Process: 'process' name=ID SettingsParens? '{' (decls+=ProcessDecl)* @@ -59,77 +51,65 @@ Statement: Block: '{' (statements+=Statement | listeners+=Listener | params+=IoParameter)* '}'; -// The trigger clause is the one `on` and `await` use. Only the process's own -// start carries one; every other start is entered by its container and the -// validator rejects a trigger there. `name=ID` is mandatory and first, so the -// second token decides: ID is the trigger, `(` the settings, `{` the member -// block. Every statement is keyword-led, so one can never be swallowed here. +// The trigger clause is the one `on` and `await` use; only the process's own +// start carries one, and the validator rejects it elsewhere. `name=ID` is +// mandatory and first, so the second token decides: ID is the trigger, `(` the +// settings, `{` the member block. Every statement is keyword-led, so one can +// never be swallowed here. StartEvent: 'start' name=ID (trigger=ID)? SettingsParens? AttributeBlock?; -// The same clause. The grammar admits any trigger word here, so a kind -// belonging on `throw` earns a validator message rather than a parse error. +// Any trigger word parses here, so a kind belonging on `throw` earns a +// validator message rather than a parse error. EndEvent: 'end' name=ID (trigger=ID)? SettingsParens? AttributeBlock?; UserTask: 'user' name=ID RepeatClause? SettingsParens? AttributeBlock?; -// Every execution form shares this one statement shape; the validator requires -// exactly one binding attribute. +// One shape for every execution form; the validator requires exactly one +// binding attribute. ServiceTask: 'service' name=ID RepeatClause? SettingsParens? AttributeBlock?; -// A step the engine records and passes straight through; the work happens -// outside it. GenericTask: 'step' name=ID RepeatClause? SettingsParens? AttributeBlock?; -// Binds as a service task does: the engine runs it the same way. SendTask: 'send' name=ID RepeatClause? SettingsParens? AttributeBlock?; -// Waits for a named message, or to be told to continue when no name is given. ReceiveTask: 'receive' name=ID RepeatClause? SettingsParens? AttributeBlock?; -// Answered by a decision table, or bound like a service task when it is not. BusinessRuleTask: 'decide' name=ID RepeatClause? SettingsParens? AttributeBlock?; -// The whole fenced block is captured verbatim in `body`; the language tag and -// the script text are split downstream. +// The fence is one token; the language tag and the script text are split +// downstream. ScriptTask: 'script' name=ID RepeatClause? SettingsParens? AttributeBlock? body=FENCED_SCRIPT; -// The member block every statement that takes one reuses. Its members are the -// parts an element is built from rather than the settings it runs under: those -// go in the parens. Which members are legal per element kind is the validator's -// business, not the grammar's, so one fragment serves them all. -// -// On a statement that also has a body the block comes first, and the two never -// collide: no statement starts with a plain identifier, so an identifier after -// the opening brace can only be a member, and an empty block is told apart from -// an empty body by the brace that follows it. The two members opening with an -// identifier, a parameter and a mapping, are told apart at the third token. +// The member block holds what an element is built from; the settings it runs +// under go in the parens. Which members each kind takes is the validator's +// business, so one fragment serves them all. On a statement that also has a +// body the block comes first, and the two never collide: no statement starts +// with a plain identifier, so an identifier after the brace can only be a +// member, and an empty block is told from an empty body by the brace that +// follows it. A parameter and a mapping, the two members opening with an +// identifier, are told apart at the third token. fragment AttributeBlock: '{' (forms+=FormBlock | params+=IoParameter | listeners+=Listener | errorMappings+=ErrorMapping)* '}'; -// `error PaymentDeclined when `. Both words are soft: `error` is the -// trigger word every code site writes, and `when` is told from a parameter's -// `=` at the third token, so the line costs no reserved word. +// `error PaymentDeclined when `. Both words are soft: `when` is +// told from a parameter's `=` at the third token, so the line costs no +// reserved word. ErrorMapping: trigger=ID code=[CodeDecl:ID] when=ID condition=Expr; -// How many times an activity runs. `for each line in lines` runs it once per -// element bound to `line`, `for 3` a fixed number of times, and both together -// lets the count drive the runs while each still sees its element. Instances run -// at once unless `sequentially` says otherwise, and `until` stops the remaining -// ones early once its condition holds. -// -// These four words are hard: the clause sits between a statement's name and its -// optional settings, a position nothing else occupies, so only a keyword can -// tell the parser a clause has started. `in` is already hard. +// `for each line in lines`, `for 3`, or both together with the count driving +// the runs. The four words are hard: the clause sits between a statement's +// name and its optional settings, a position nothing else occupies, so only a +// keyword can tell the parser a clause has started. `in` is already hard. fragment RepeatClause: 'for' ( cardinality=Expr ('each' (element=ID)? 'in' collection=Expr)? @@ -138,39 +118,36 @@ fragment RepeatClause: (sequential?='sequentially')? ('until' '(' completion=Expr ')')?; -// Maps to `operaton:formData`, which Operaton Tasklist renders as a labeled -// input per field. A list on the owning element, so a second block is flaggable. +// A list on the owning element, so a second block is flaggable. FormBlock: 'form' '{' fields+=FormField* '}'; -// `id` is the process variable the field reads or writes, deliberately not -// `name=ID` so a field and a `var` of the same id coexist instead of colliding -// in the symbol table. The block cannot be read as the next field, which opens -// with `ID ':'`; its two member shapes are told apart at the third token (`=`), -// which ALL(*) lookahead resolves. +// `id`, not `name=ID`, so a field and a `var` of the same id coexist instead +// of colliding in the symbol table. The block cannot be read as the next +// field, which opens with `ID ':'`; its two member shapes are told apart at +// the third token (`=`). FormField: id=ID ':' type=FormFieldType (label=STRING)? ('=' defaultValue=Expr)? SettingsParens? ('{' (values+=EnumValue | params+=IoParameter)* '}')?; -// Open beyond `VarType` because an `enum` field is not a `var` type, and a soft -// word keeps `var enum: string` legal. The validator holds the word to +// Open beyond `VarType` because `enum` is not a `var` type and a soft word +// keeps `var enum: string` legal; the validator holds the word to // `FORM_FIELD_TYPES`. FormFieldType returns string: VarType | ID; -// `card "Card"`: one line per value an `enum` offers, in the order the engine -// keeps them (`FormTypes.parseFormPropertyType` fills a LinkedHashMap). +// One line per value an `enum` offers, in the order the engine keeps them +// (`FormTypes.parseFormPropertyType` fills a LinkedHashMap). EnumValue: id=ID (label=STRING)?; -// The settings list every element shares. A payload sits first and unkeyed, a -// setting carries `key: value`, and a flag is a bare word. The three are told -// apart by the token after the first: `:` makes a setting, a flag word is a -// keyword no expression can start with, and anything else is a plain value. -// At least one item: an element with no settings carries no parens at all, and -// requiring one keeps a stray word inside them a no-viable-alternative error, -// which reads as the reserved-word explanation rather than a missing `)`. +// A payload sits first and unkeyed, a setting carries `key: value`, and a flag +// is a bare word; `:` makes a setting, a flag word is a keyword no expression +// can start with, and anything else is a plain value. At least one item: an +// element with no settings carries no parens, and requiring one keeps a stray +// word inside them a no-viable-alternative error, which reads as the +// reserved-word explanation rather than a missing `)`. fragment SettingsParens: '(' items+=ParenItem (',' items+=ParenItem)* ')'; @@ -179,39 +156,31 @@ ParenItem: | {infer Flag} flag=FlagWord | {infer ParenValue} value=Expr; -// A soft word, so every setting name stays available as a step name, a variable -// name, and a bare identifier in expression position. -// -// `process` is listed explicitly because the `process` block heading makes it a -// keyword token that never lexes as `ID`; without the literal, `call C(process: -// "x")` would fail at the `:`. +// `process` is listed because the `process` heading makes it a keyword token +// that never lexes as `ID`; without it, `call C(process: "x")` would fail at +// the `:`. ParenKey returns string: ID | 'process'; -// A flag is bare to a reader but not to the lexer: each of these is a keyword -// and never lexes as `ID`, so the words writable here are named rather than -// accepted by shape. Which of them an element actually takes is the validator's -// business; naming `alongside` alone would make the other two a parse error -// instead of a diagnostic saying which element they are not valid on. +// Each flag word is a keyword that never lexes as `ID`, so the words writable +// here are named rather than accepted by shape. Naming `alongside` alone would +// make the other two a parse error instead of a diagnostic saying which +// element they are not valid on. FlagWord returns string: 'alongside' | 'sequentially' | 'local'; -// ` = `, where `direction` is soft as a setting key -// is. The value language is wider than a setting's: a nested list or map, or an -// inline script. IoParameter: direction=ID name=ID '=' value=IoValue; -// `[` and `{` uniquely predict a list and a map, since no expression starts with +// `[` and `{` predict a list and a map, since no expression starts with // either, so `items[0]` on the right of a parameter is still an accessor. IoValue: Expr | ListLiteral | MapLiteral | ScriptLiteral; -// An ordered list of values, `[a, b]`. A trailing comma is rejected. +// A trailing comma is rejected. ListLiteral: '[' (items+=IoValue (',' items+=IoValue)*)? ']'; -// `{ k: v, "other key": w }`. A trailing comma is rejected. MapLiteral: '{' (entries+=MapEntry (',' entries+=MapEntry)*)? '}'; @@ -222,19 +191,14 @@ MapEntry: MapKey returns string: ID | STRING; -// The same fenced block a script task uses, so the tag travels with the code. ScriptLiteral: body=FENCED_SCRIPT; -// An engine lifecycle callback on the enclosing element, `on (class: -// "...")`; a `timeout` one also carries a timer clause. The `on` word is the -// statement layer's: at statement position it catches a BPMN event, inside a -// member block it registers a callback. A callback carries settings, an -// optional inline script, and a block of parameters configuring the binding it -// names, so it can nest neither a form nor another callback. Which directions -// the block takes is the validator's business, as it is for an element's own -// block. The block cannot be read as the next member: nothing else a member -// block holds starts with `{`. +// `on` at statement position catches a BPMN event; inside a member block it +// registers a lifecycle callback, `on (class: "...")`, a `timeout` one +// also carrying a timer clause. Its block holds parameters alone, so a +// callback nests neither a form nor another callback, and cannot be read as +// the next member: nothing else a member block holds starts with `{`. Listener: 'on' event=ListenerEvent (particle=ID time=(STRING | RAW_TEMPLATE))? @@ -242,14 +206,14 @@ Listener: script=FENCED_SCRIPT? ('{' params+=IoParameter* '}')?; -// `start` and `end` are named explicitly because the statements of the same name -// make them keyword tokens that never lex as `ID`. +// `start` and `end` are keyword tokens that never lex as `ID`, so they are +// named here. ListenerEvent returns string: ID | 'start' | 'end'; -// if / else if / else -> exclusive gateway (desugared by `astToIr`). The parens -// after the condition hold the split's settings and, `join`-prefixed, the -// join's; the whole chain is one split, so an `else if` head takes none. +// One exclusive gateway per chain, so the parens after the condition hold the +// split's settings and, `join`-prefixed, the join's, and an `else if` head +// takes none. IfStatement: 'if' '(' condition=Expr ')' SettingsParens? then=Block (elseIfs+=ElseIf)* @@ -258,108 +222,75 @@ IfStatement: ElseIf: 'else' 'if' '(' condition=Expr ')' body=Block; -// Pre-test loop -> XOR gateway + back-edge (never standardLoopCharacteristics). -// A loop has one gateway, so its parens take no `join` spelling; the validator -// says so. +// An XOR gateway and a back-edge, never standardLoopCharacteristics. One +// gateway, so the parens take no `join` spelling; the validator says so. WhileStatement: 'while' '(' condition=Expr ')' SettingsParens? body=Block; -// Post-test loop -> body runs first, then the conditioned back-edge. DoWhileStatement: 'do' body=Block 'while' '(' condition=Expr ')' SettingsParens?; -// parallel -> AND fork/join, or an OR fork/join when any branch carries a -// condition. An unheaded branch always runs, a conditioned one when its -// condition holds, and the `else` branch only when none held. Left out, the -// fallback goes straight to the join when every branch is conditioned, and is -// not written at all beside an unheaded branch, which the engine takes every -// time. The parens after the keyword hold the fork's settings and, -// `join`-prefixed, the join's; a branch head takes none. +// An AND fork and join, or an OR pair when any branch carries a condition. +// The parens after the keyword hold the fork's settings and, `join`-prefixed, +// the join's; a branch head takes none. ParallelStatement: 'parallel' SettingsParens? '{' branches+=ParallelBranch (branches+=ParallelBranch)+ '}'; -// `if` and `else` are already keywords, so the head costs no reserved word. ParallelBranch: ('if' '(' condition=Expr ')' | otherwise?='else')? body=Block; -// goto -> explicit jump to any targetable statement, keyed on `name`. GotoStatement: 'goto' target=[Statement:ID]; -// A nested flow container whose body reuses `Block`, so `start`/`end` parse -// inside it exactly as at process scope. -// // `attempt` is the same container marked as work that may be given up. It // serializes to `bpmn:transaction`, the only tag under which Operaton accepts a -// `cancel` end or handler; `BpmnParse` gives that tag the same behavior class as -// an embedded sub-process, so nothing rolls back on its own and the two heads -// share one rule and one node type. +// `cancel` end or handler; `BpmnParse` gives that tag the same behavior class +// as an embedded sub-process, so the two heads share one rule and one node +// type. SubProcess: (transactional?='attempt' | 'subprocess') name=ID RepeatClause? SettingsParens? AttributeBlock? body=Block; -// A call to another process by id. The member block is inline rather than the -// `AttributeBlock` fragment, which admits a `form` block, meaningless on a call, -// and has no place to carry mappings. `in`/`out` are keyword tokens while -// `input`/`output` lex as identifiers, so the two member kinds never collide. +// The member block is inline rather than `AttributeBlock`, which admits a +// `form` block and has no place for mappings. `in`/`out` are keyword tokens +// while `input`/`output` lex as identifiers, so the two member kinds never +// collide. CallActivity: 'call' name=ID RepeatClause? SettingsParens? ('{' (mappings+=VariableMapping | params+=IoParameter | listeners+=Listener)* '}')?; -// Read like a function call at the process boundary: `in` carries a value into -// the called process, `out` carries one back. As with every `=` here, the left -// side is the receiving name in the receiving scope; omitting `source` copies a -// variable under the same name and `all='*'` copies every variable. A `local` -// mapping reads and writes variables belonging to the call step rather than the -// whole process. The `*` keyword reuses the multiplication token below, so the -// lexer is unchanged. +// The left side is the receiving name in the receiving scope; omitting +// `source` copies a variable under the same name and `all='*'` copies every +// variable. The `*` keyword reuses the multiplication token below. VariableMapping: direction=('in'|'out') (local?='local')? (all?='*' | target=ID ('=' source=Expr)?); -// `on ` is a catch block for one event kind, sitting at the end of the -// body it guards; `throw` ends its path and `emit` keeps going. The grammar -// accepts any payload with any trigger; which shapes pair is the validator's job. -// -// The parenthesized items hold the payload, the catch bindings and the flags at -// once. A catch binding is a `code:`/`message:` setting carrying a bare name, -// `error(X, code: c)`, which declares that name instead of reading it; nothing -// in the shape says so, and the validator is what tells the two apart. -// -// The optional `host` names the single step the handler attaches to, so -// `on Review: timer("PT2H")` catches only while `Review` runs. The colon is -// what says the first word is a host rather than the trigger, and it decides at -// token 2, before either reading of `on Pack error` has to be committed to. +// A catch block for one event kind. Any payload parses with any trigger, and a +// catch binding, `error(X, code: c)`, is a setting carrying a bare name that +// nothing in the shape marks as a declaration; which shapes pair and which +// settings bind are the validator's job. The colon after an optional `host` +// decides at token 2 that the first word is a host rather than the trigger, +// before either reading of `on Pack error` has to be committed to. OnHandler: 'on' (host=[Statement:ID] ':')? trigger=ID SettingsParens? AttributeBlock? body=Block; -// `name` is the optional goto-target id, synthesized when omitted. One token of -// lookahead places it: `ID` predicts `name`, and anything else ends the -// statement, since every statement is keyword-led and can never be swallowed -// as `name`. +// `name` is the optional goto-target id, synthesized when omitted. `ID` +// predicts it and anything else ends the statement, since every statement is +// keyword-led and can never be swallowed as `name`. ThrowStatement: 'throw' trigger=ID (name=ID)? SettingsParens? AttributeBlock?; -// Fires the event and keeps going, the one difference from `throw` above. EmitStatement: 'emit' trigger=ID (name=ID)? SettingsParens? AttributeBlock?; -// `await` blocks the main flow in one of two shapes, told apart by the token -// after the keyword: an `ID` is the trigger of the single-catch form, and `(` -// or `{` opens the block form. -// -// `await ?` pauses until the trigger fires, then continues. The -// name sits after the trigger, placed by the one token of lookahead -// `ThrowStatement`'s comment describes; the id is synthesized when omitted. -// -// `await { ... }` waits on several triggers at once and continues down the one -// that fires first; the others are cancelled. A `RaceBranch` carries no name of -// its own: it is not a statement, so nothing can `goto` one branch of a race. -// The parens before the block hold the gateway's settings and, `join`-prefixed, -// the join's; a branch's own parens belong to its catch event. -// -// One rule for both shapes, so the keyword commits the parser before the payload -// is read: with a rule apiece a mistyped payload leaves no viable alternative -// and the parser blames `await`, the token the author got right. The inferred -// node types stay separate, so each keeps its own validator entry and lowering. +// Two shapes told apart by the token after the keyword: an `ID` is the trigger +// of the single catch, `(` or `{` opens the race. One rule for both, so the +// keyword commits the parser before the payload is read: with a rule apiece a +// mistyped payload leaves no viable alternative and the parser blames `await`, +// the token the author got right. The inferred node types stay separate, so +// each keeps its own validator entry and lowering. A `RaceBranch` is not a +// statement and carries no name, so nothing can `goto` one branch of a race; +// the parens before the block hold the gateway's settings and, `join`-prefixed, +// the join's. AwaitStatement: 'await' ( {infer IntermediateCatchEvent} @@ -368,40 +299,31 @@ AwaitStatement: SettingsParens? '{' branches+=RaceBranch (branches+=RaceBranch)+ '}' ); -// `IntermediateCatchEvent`'s header plus a body, the two told apart as a -// subprocess tells them apart: no statement starts with a plain identifier. +// A catch header plus a body, told apart as a subprocess tells them apart: no +// statement starts with a plain identifier. RaceBranch: trigger=ID SettingsParens? AttributeBlock? body=Block; -// `error OUT_OF_STOCK(message: "Out of stock")`: an element like any other, -// declaring the name a `throw error(...)` or an `on error(...)` refers to. The -// message it carries is the one piece of event data usage alone cannot express, -// since two throws of a code share one root element. -// -// `kind` is soft, so `var error: string` and `on error` keep working, and which -// words it accepts is the validator's business. The other header declaration -// opens with `var`, and no statement can be swallowed here, since every -// statement opens with a hard keyword. +// `error OUT_OF_STOCK(message: "Out of stock")` declares the name every code +// site refers to; the message is the one piece of event data usage cannot +// express, since two throws of a code share one root element. `kind` is soft, +// so `var error: string` and `on error` keep working; no statement can be +// swallowed here, since every statement opens with a hard keyword. CodeDecl: kind=ID name=ID SettingsParens?; -// The expression sub-language, a JUEL native subset. -// -// These are parser rules and never a terminal: validation, hovers, and -// jump-to-def all depend on a condition being a real AST rather than an opaque -// string. Binary operators use Langium's left-associative tree-rewriting idiom -// `{infer X.left=current}`, so `a + b + c` builds a left-leaning tree rather -// than overwriting `left`/`right`. -// -// Each precedence level infers its own node type rather than one shared -// `Binary`, so a consumer can tell a comparison from an arithmetic expression by -// `$type`. All five share the `left`/`op`/`right` shape and one render arm. +// The expression sub-language, a JUEL native subset, as parser rules rather +// than a terminal: validation, hover and jump-to-definition all need a real +// AST. Binary operators use Langium's left-associative idiom +// `{infer X.left=current}`, so `a + b + c` builds a left-leaning tree. Each +// precedence level infers its own node type, so a consumer tells a comparison +// from an arithmetic expression by `$type`; all five share the +// `left`/`op`/`right` shape. Expr: Ternary; -// With no `?` the node collapses to the underlying Or expression: Langium emits -// the inner type rather than a wrapper. +// With no `?` the node collapses to the inner expression rather than a wrapper. Ternary infers Expr: LogicalOr ({infer Ternary.condition=current} '?' whenTrue=LogicalOr ':' whenFalse=LogicalOr)?; @@ -423,7 +345,6 @@ Additive infers Expr: Multiplicative infers Expr: Unary ({infer Multiplicative.left=current} op=('*'|'/'|'%') right=Unary)*; -// Unary `!x` / `-x`. With no operator the node collapses to the Primary. Unary infers Expr: {infer Unary} op=('!'|'-') operand=Unary | Primary; @@ -437,7 +358,6 @@ Primary infers Expr: | Paren | RawExpr; -// A dedicated terminal, so `1000` is a LiteralInt rather than a VarRef. LiteralInt: value=INT; @@ -453,18 +373,14 @@ LiteralBool: LiteralNull: value='null'; -// `order`, `order.total`, `items[0]`, `map['k']`, and the bare word a -// code-carrying event names, `throw error(OUT_OF_STOCK)`. -// -// One rule for both, because nothing tells them apart at this level: -// `error(OUT_OF_STOCK)` and `condition(ready)` are the same tokens inside the -// same `SettingsParens`, and a cross-reference alternative in `ParenItem` makes -// Chevrotain report ambiguous alternatives and parse the condition as a -// reference. So every identifier in every expression is a reference, and what -// separates a code from a variable is the scope `BpmnScriptScopeProvider` -// resolves it in: the enclosing process's declarations in a code position, and -// nothing anywhere else. A reference that was never meant to resolve is dropped -// by `BpmnScriptDocumentValidator` before it becomes a diagnostic. +// A variable, `order.total`, and the bare word a code-carrying event names, +// `throw error(OUT_OF_STOCK)`, in one rule: `error(OUT_OF_STOCK)` and +// `condition(ready)` are the same tokens inside the same `SettingsParens`, and +// a cross-reference alternative in `ParenItem` makes Chevrotain report +// ambiguous alternatives and parse the condition as a reference. So every +// identifier in every expression is a reference, `BpmnScriptScopeProvider` +// resolves it only in a code position, and `BpmnScriptDocumentValidator` drops +// the link failure everywhere else. VarRef: ref=[CodeDecl:ID] (accessors+=Accessor)*; @@ -475,17 +391,16 @@ Paren: '(' inner=Expr ')'; // The escape hatch for expressions outside the JUEL subset and for variables -// colliding with a keyword, valid anywhere an Expr is; the renderer emits `raw` -// as written. It is told apart from a plain LiteralString purely by content, a -// quoted string whose body begins with `${` or `#{`, the two openers the engine -// evaluates, which the RAW_TEMPLATE terminal captures. +// colliding with a keyword, told from a LiteralString by content alone: a +// quoted string opening with `${` or `#{`, the two openers the engine +// evaluates. RawExpr: raw=RAW_TEMPLATE; hidden terminal WS: /\s+/; -// BPMN-style identifiers with internal hyphens (`invoice-approval`); a hyphen is -// allowed only between word characters, never trailing. +// A hyphen is allowed only between word characters (`invoice-approval`), never +// trailing. terminal ID: /[_a-zA-Z]\w*(-\w+)*/; // DECIMAL must be declared before INT so the lexer prefers the longer match for @@ -493,17 +408,14 @@ terminal ID: /[_a-zA-Z]\w*(-\w+)*/; terminal DECIMAL returns number: /[0-9]+\.[0-9]+/; terminal INT returns number: /[0-9]+/; -// A whole markdown-style fenced block opened by a language tag, matched as one -// token so the body stays opaque and code-like text such as `if (a) { }` is -// never lexed as DSL. `[\s\S]` matches across newlines without a dotall flag -// and `*?` ends the match at the first closing fence. Nothing delimits the tag -// from the body, so the downstream split derives that boundary from the leading -// letter run and the newline after it. - +// One token, so code-like text such as `if (a) { }` inside the fence is never +// lexed as DSL; `[\s\S]*?` matches across newlines up to the first closing +// fence. Nothing delimits the tag from the body, so the downstream split takes +// the leading letter run as the tag. terminal FENCED_SCRIPT: /```[a-zA-Z]+[\s\S]*?```/; -// Declared before STRING: the two regexes overlap on a body beginning with -// `${` or `#{`, so declaration order is what makes that input a RawExpr. +// Before STRING: the two regexes overlap on a body opening with `${` or `#{`, +// and declaration order is what makes that input a RawExpr. terminal RAW_TEMPLATE: /"[$#]\{(\\.|[^"\\])*"|'[$#]\{(\\.|[^'\\])*'/; terminal STRING: /"(\\.|[^"\\])*"|'(\\.|[^'\\])*'/; diff --git a/packages/language/src/expression-render.ts b/packages/language/src/expression-render.ts index 31164dd..04a70d9 100644 --- a/packages/language/src/expression-render.ts +++ b/packages/language/src/expression-render.ts @@ -1,7 +1,7 @@ /** - * Renders a parsed JUEL-subset expression AST back to its canonical `${...}` - * body string. It lives here rather than in `transform` so it carries no - * dependency on that package; `astToIr` imports it the other way round. + * Renders a parsed JUEL-subset expression back to its `${...}` text. It lives + * here rather than in `transform` because `astToIr` imports it; the dependency + * runs transform -> language and never the reverse. */ import type { Expr, Accessor } from './generated/ast.js'; @@ -24,10 +24,10 @@ import { } from './generated/ast.js'; /** - * The digits of an integer literal with its sign, or `undefined` for any other - * expression. Bare, `-5` parses as a `-` unary over `5`, and every setting the - * engine reads with `Integer.parseInt` or `Long.parseLong` takes that as the - * one integer it is rather than as an expression. + * The digits of an integer literal with its sign, else `undefined`. A bare + * `-5` parses as a `-` unary over `5`, and every setting the engine reads with + * `Integer.parseInt` or `Long.parseLong` takes it as one integer, not an + * expression. */ export function integerLiteralText(node: Expr): string | undefined { if (isLiteralInt(node)) { @@ -39,7 +39,6 @@ export function integerLiteralText(node: Expr): string | undefined { return undefined; } -/** A {@link RawExpr} is already a complete `${...}` or `#{...}` and comes back as written. */ export function renderExpression(node: Expr): string { if (isRawExpr(node)) { return node.raw; @@ -47,16 +46,12 @@ export function renderExpression(node: Expr): string { return `\${${renderExpressionInner(node)}}`; } -/** - * The inner text without the `${...}` wrapper. Parentheses are emitted only - * where the author wrote them: a faithful structural render, not a - * minimal-parenthesization printer. - */ +/** The text inside the `${...}` wrapper. Parentheses are emitted only where the author wrote them. */ export function renderExpressionInner(node: Expr): string { if (isRawExpr(node)) { - // JUEL has no `${` token once inside an expression, so a raw operand is - // spliced in by its body. A composite raw has no one body to splice and is - // left as written for the validator to refuse. + // JUEL has no `${` token inside an expression, so a raw operand is spliced + // in by its body; a composite has no one body and is left for the + // validator to refuse. const body = singleTemplateBody(node.raw); return body === undefined ? node.raw : `(${body})`; } @@ -67,7 +62,6 @@ export function renderExpressionInner(node: Expr): string { `${renderExpressionInner(node.whenFalse)}` ); } - // All five binary precedence levels share the same `left op right` shape. if ( isLogical(node) || isEquality(node) || @@ -90,10 +84,9 @@ export function renderExpressionInner(node: Expr): string { return String(node.value); } if (isLiteralString(node)) { - // The lexer stripped the author's quotes. This produces JUEL text, so it - // must double the backslash before escaping the quote, same as `juel.ts` - // in `@bpmn-script/transform` does: those are the only two escapes - // operaton-juel's `Scanner.nextString` accepts. + // JUEL text, so the backslash is doubled before the quote is escaped, as + // `escapeQuoted` in `@bpmn-script/transform`'s `juel.ts` does: those are + // the only two escapes operaton-juel's `Scanner.nextString` accepts. return `"${node.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; } if (isLiteralBool(node) || isLiteralNull(node)) { @@ -109,8 +102,7 @@ function renderAccessor(accessor: Accessor): string { if (accessor.prop !== undefined) { return `.${accessor.prop}`; } - // The grammar guarantees `prop` XOR `index`, which TS cannot prove here; - // guard so a third accessor form throws instead of rendering `undefined`. + // The grammar guarantees `prop` XOR `index`, which TS cannot prove here. if (accessor.index === undefined) { throw new Error( 'renderAccessor: accessor has neither a `prop` nor an `index` (unexpected accessor shape)', @@ -120,12 +112,10 @@ function renderAccessor(accessor: Accessor): string { } /** - * The body of a raw template that is exactly one template, `${body}` or - * `#{body}` with no second opener and no `}` before the last, else - * `undefined`: `${a} and ${b}` and `${a} b}` are composites the engine - * evaluates to text around the one template each holds. A `}` or opener - * inside a JUEL string literal is string text (`Scanner.nextString`), so - * `${map['}']}` is one template. + * The body of a raw template that is exactly one template, else `undefined`: + * `${a} and ${b}` and `${a} b}` are composites the engine evaluates to text + * around the template. A `}` or opener inside a JUEL string literal is string + * text (`Scanner.nextString`), so `${map['}']}` is one template. */ export function singleTemplateBody(raw: string): string | undefined { if (!/^[$#]\{[^]*\}$/.test(raw)) return undefined; diff --git a/packages/language/src/index.ts b/packages/language/src/index.ts index b5f625e..214a318 100644 --- a/packages/language/src/index.ts +++ b/packages/language/src/index.ts @@ -1,4 +1,5 @@ export * from './bpmn-script-module.js'; +export { FLAG_WORD_RULE, SETTING_KEY_RULE } from './bpmn-script-completion.js'; export * from './bpmn-script-validator.js'; export * from './expression-render.js'; export * from './variable-symbol-provider.js'; diff --git a/packages/language/src/paren-items.ts b/packages/language/src/paren-items.ts index c44e1f0..8dd2e16 100644 --- a/packages/language/src/paren-items.ts +++ b/packages/language/src/paren-items.ts @@ -1,14 +1,12 @@ /** - * Readers over an element's parenthesised settings. - * - * The payload, the settings and the flags share one list, so which of them an - * item is comes from its node type rather than from a property per kind. - * Reading them anywhere else would spell the union a second time. + * Readers over an element's parenthesised items. The payload, the settings + * and the flags share one list and are told apart by node type, so every + * reader lives here rather than spelling that union again at each site. */ import { DECLARED_CODE_TRIGGERS, - EVENT_BINDING_FIELDS, + EVENT_BINDING_FIELD_SET, namesACode, TIMER_PARTICLE_BY_KIND, } from './vocabulary.js'; @@ -31,42 +29,33 @@ import { type Setting, } from './generated/ast.js'; -/** The `key: value` settings, in source order. */ export function settingsOf(items: ParenItem[]): Setting[] { return items.filter(isSetting); } -/** The bare flag words, in source order. */ export function flagsOf(items: ParenItem[]): Flag[] { return items.filter(isFlag); } /** - * The unkeyed value: an event's payload, and the expression a `condition` - * carries. A second one is a duplicate the validator reports rather than a - * shape the grammar rejects, so this reads the first. + * The unkeyed value: an event's payload, or a `condition`'s expression. A + * second one is a duplicate the validator reports, so this reads the first. */ export function payloadItemOf(items: ParenItem[]): ParenValue | undefined { return items.find(isParenValue); } /** - * A catch binding: a `code:`/`message:` setting on a handler, told from an - * engine setting by its key rather than by its shape. Only a bare name declares - * anything, so a value that is not one leaves `variable` undefined, which is - * what makes `code: "LITERAL"` a setting that reads rather than a binding. + * A `code:`/`message:` setting on a handler, told from an engine setting by + * its key alone. Only a bare name declares a variable, so `code: "LITERAL"` + * leaves `variable` undefined and reads rather than binds. */ -export interface CaughtBinding { +interface CaughtBinding { readonly field: string; readonly variable: string | undefined; readonly node: Setting; } -const EVENT_BINDING_FIELD_SET: ReadonlySet = new Set( - EVENT_BINDING_FIELDS, -); - -/** Every catch binding on a handler, in source order. */ export function caughtBindingsOf(items: ParenItem[]): CaughtBinding[] { return settingsOf(items) .filter((setting) => EVENT_BINDING_FIELD_SET.has(setting.key)) @@ -80,10 +69,10 @@ export function caughtBindingsOf(items: ParenItem[]): CaughtBinding[] { } /** - * A paren key that carries a catch binding or a timer clause rather than a - * setting, so the key check passes over it. Only a handler catches a code or a - * message, and there {@link caughtBindingsOf} has already read it; only a timer - * names a date or a cycle. Anywhere else those words are ordinary unknown keys. + * A key that is a catch binding or a timer clause rather than a setting, so + * the key check passes over it. Only a handler catches a code or a message and + * only a timer names a date or a cycle; anywhere else the words are unknown + * keys. */ export function isStructuralParenKey(owner: AstNode, key: string): boolean { if (isOnHandler(owner) && EVENT_BINDING_FIELD_SET.has(key)) return true; @@ -96,7 +85,6 @@ const TIMER_PARTICLE_KEYS: ReadonlySet = new Set([ TIMER_PARTICLE_BY_KIND.cycle, ]); -/** An element's engine settings, which is every setting bar the structural keys. */ export function configuredSettingsOf( owner: AstNode & { items?: ParenItem[] }, ): Setting[] { @@ -106,24 +94,18 @@ export function configuredSettingsOf( } /** - * The trigger word of the event whose bare payload slot `node` sits in, or - * `undefined` where it sits anywhere else. Every identifier in every expression - * parses as the same reference, so this is the one thing separating - * `error(OUT_OF_STOCK)` from `condition(ready)`, and the scope provider, the - * linker, the validator and the suppression all ask it here rather than each - * deciding for itself. - * - * A nested identifier answers `undefined` without a check of its own: - * `ParenValue` has the payload as its only child, so an operand of - * `error(a > b)` is contained by the operator node instead. + * The trigger word of the event whose bare payload slot `node` sits in, else + * `undefined`. Every identifier in every expression parses as the same + * reference, so this is what separates `error(OUT_OF_STOCK)` from + * `condition(ready)` for the scope provider, the linker, the validator and the + * suppression alike. An operand of `error(a > b)` is contained by the operator + * node, not the `ParenValue`, so a nested identifier answers `undefined` on + * its own. */ function payloadTriggerOf(node: AstNode): string | undefined { - // Completion asks about a stand-in for the reference being typed rather than - // about a parsed node (`completionForCrossReference` in Langium's - // `DefaultCompletionProvider` builds one), and the stand-in's container is - // where the caret sits: the `VarRef` a typed prefix has already built, or the - // event itself where nothing is written yet. A parsed reference is never held - // in a `ref` slot, so only the stand-in takes this step. + // Only the stand-in Langium's `completionForCrossReference` builds is held + // in a `ref` slot; its container is where the caret sits, the `VarRef` a + // typed prefix has built or the event itself where nothing is written yet. if (node.$containerProperty === 'ref' && node.$container !== undefined) { const caret = node.$container; return payloadTriggerOf(caret) ?? triggerWordOf(caret); @@ -134,7 +116,6 @@ function payloadTriggerOf(node: AstNode): string | undefined { : undefined; } -/** The soft trigger word of an event statement, or `undefined` on any other node. */ export function triggerWordOf(owner: AstNode): string | undefined { return 'trigger' in owner && typeof owner.trigger === 'string' ? owner.trigger @@ -142,10 +123,10 @@ export function triggerWordOf(owner: AstNode): string | undefined { } /** - * The trigger word under which `node` names a declared code, or `undefined` - * where it names none: the unkeyed value of an `error` or `escalation` event - * does, and so does a mapping's code slot, `error E when ...`, whose own - * trigger word heads the line. + * The trigger word under which `node` names a declared code, else + * `undefined`: the unkeyed value of an `error` or `escalation` event, or a + * mapping's code slot, `error E when ...`, whose own trigger word heads the + * line. */ export function codeTriggerOf(node: AstNode): string | undefined { const trigger = isErrorMapping(node) @@ -157,14 +138,12 @@ export function codeTriggerOf(node: AstNode): string | undefined { } /** - * The stand-in completion builds for the code slot (see - * {@link payloadTriggerOf}) is a mapping node with no trigger of its own, - * held by the parsed mapping whose slot is being typed, so its word is read - * off that container. + * The completion stand-in for the code slot is a mapping with no trigger of + * its own, held by the parsed mapping being typed, so its word is read off + * that holder. The generated type says a mapping is held by an element, which + * only the stand-in departs from, hence the widening. */ function mappingTriggerOf(mapping: ErrorMapping): string | undefined { - // Widened: the generated type says a mapping is held by an element, which - // only the stand-in departs from. const holder: AstNode | undefined = mapping.$container; if ( mapping.trigger === undefined && @@ -177,10 +156,9 @@ function mappingTriggerOf(mapping: ErrorMapping): string | undefined { } /** - * The trigger word under which `node` writes a name of its own instead: a - * message or a signal keys the subscription the engine correlates on, so its - * payload carries text rather than referring to a declaration the way a code - * does, and a bare word there is a missing pair of quotes. + * The trigger word under which `node` writes a name rather than a code: a + * message or a signal keys the engine's subscription by text, so a bare word + * there is a missing pair of quotes. */ export function nameTriggerOf(node: AstNode): string | undefined { const trigger = payloadTriggerOf(node); @@ -191,17 +169,15 @@ export function nameTriggerOf(node: AstNode): string | undefined { : undefined; } -/** {@link codeTriggerOf} where the word itself does not matter. */ export function isCodePosition(node: AstNode): boolean { return codeTriggerOf(node) !== undefined; } /** - * The code a declaration keys by: its `code` setting, or its own name where - * none is written. The setting is what carries a code no name could spell, - * `error OrderFailed(code: "order.failed")`. `undefined` where a `code` setting - * is written but is not quoted text, or is empty, both of which the validator - * reports on their own. + * The code a declaration keys by: its `code` setting, which carries a code no + * name could spell (`error OrderFailed(code: "order.failed")`), else its own + * name. `undefined` for a written setting that is empty or not quoted text, + * both of which the validator reports on its own. */ export function declaredCodeOf(decl: CodeDecl): string | undefined { const setting = settingsOf(decl.items).find((item) => item.key === 'code'); @@ -213,10 +189,9 @@ export function declaredCodeOf(decl: CodeDecl): string | undefined { } /** - * The trigger payload written bare in the parens: a message or signal name, an - * error or escalation code, or a timer duration. A code may be written as a - * plain word so it can name an error declaration, so both spellings read back - * as the same text. A condition is an expression rather than text and is read + * The payload as text: a message or signal name, a code, or a timer duration. + * A code is a plain word naming its declaration and reads back as the same + * text a quoted one would; a condition is an expression, not text, and is read * through {@link payloadItemOf} instead. */ export function payloadTextOf(items: ParenItem[]): string | undefined { @@ -228,11 +203,7 @@ export function payloadTextOf(items: ParenItem[]): string | undefined { return undefined; } -/** - * Whether the parens lead with quoted text. A word names a declaration and a - * `${...}` template is an expression, so only this shape is the author writing - * a value where the position wants something else. - */ +/** Only quoted text is a value written where the position wants something else: a word names a declaration and a `${...}` template is an expression. */ export function hasQuotedPayload(items: ParenItem[]): boolean { const value = payloadItemOf(items)?.value; return value !== undefined && isLiteralString(value); @@ -245,7 +216,6 @@ export interface TimerPayload { readonly node: Setting | ParenValue; } -/** The timer particle a keyed payload names, with the text it carries. */ export function timerParticleOf( items: ParenItem[], ): (TimerPayload & { node: Setting }) | undefined { @@ -259,11 +229,7 @@ export function timerParticleOf( return undefined; } -/** - * The whole timer clause: a duration written bare, or the date or cycle a key - * names. A bare duration answers the `after` particle it would be written with - * elsewhere, so every caller reads one shape. - */ +/** A bare duration answers the `after` particle it would carry elsewhere, so every caller reads one shape. */ export function timerPayloadOf(items: ParenItem[]): TimerPayload | undefined { const keyed = timerParticleOf(items); if (keyed !== undefined) return keyed; @@ -275,28 +241,21 @@ export function timerPayloadOf(items: ParenItem[]): TimerPayload | undefined { : { particle: TIMER_PARTICLE_BY_KIND.duration, time, node: bare }; } -/** A time is quoted text or a raw template, both read as the text they carry. */ function timeTextOf(value: Expr): string | undefined { if (isLiteralString(value)) return value.value; if (isRawExpr(value)) return value.raw; return undefined; } -/** The bare flag word `word`, where it is written, for a diagnostic on it. */ export function flagOf(items: ParenItem[], word: string): Flag | undefined { return flagsOf(items).find((flag) => flag.flag === word); } -/** Whether a bare flag word is written in the parens. */ export function hasFlag(items: ParenItem[], word: string): boolean { return flagOf(items, word) !== undefined; } -/** - * Whether the parens hold a payload that is an expression rather than text. - * Only a condition takes one, so this is what separates `condition(a > b)` from - * `error(OUT_OF_STOCK)` now that both are written the same way. - */ +/** Only a condition's payload is an expression rather than text, which is what separates `condition(a > b)` from `error(OUT_OF_STOCK)`. */ export function hasExpressionPayload(items: ParenItem[]): boolean { return ( payloadItemOf(items) !== undefined && payloadTextOf(items) === undefined diff --git a/packages/language/src/variable-symbol-provider.ts b/packages/language/src/variable-symbol-provider.ts index 2348151..4305734 100644 --- a/packages/language/src/variable-symbol-provider.ts +++ b/packages/language/src/variable-symbol-provider.ts @@ -28,9 +28,9 @@ export interface VariableSymbol { export type VariableTable = Map; /** - * One place a variable is declared: the AST node and the property holding - * its name, so a rename can find the name's own leaf. A catch binding's site - * is the `VarRef` its setting carries, under `ref`. + * One place a variable is declared, as the node and the property holding its + * name so a rename finds the name's own leaf. A catch binding's site is the + * `VarRef` its setting carries, under `ref`. */ export interface DeclaringSite { node: AstNode; @@ -39,7 +39,6 @@ export interface DeclaringSite { type: VarType; } -/** Injected as `references.VariableSymbolProvider`. */ export interface VariableSymbolProvider { collect(process: Process): VariableTable; declaringSites(process: Process): DeclaringSite[]; @@ -52,11 +51,7 @@ interface RepeatSlots { element?: string; } -/** - * Whether `node` carries a repeat clause. Only the count and the collection - * decide it: `sequential` is `false` on every repeatable statement, written or - * not, so its value says nothing about whether a clause was written. - */ +/** `sequential` is `false` on every repeatable statement, written or not, so only the count and the collection say a clause was written. */ export function isRepeated(node: AstNode): node is AstNode & RepeatSlots { return ( ('cardinality' in node && node.cardinality !== undefined) || @@ -65,10 +60,10 @@ export function isRepeated(node: AstNode): node is AstNode & RepeatSlots { } /** - * Every declaring site of the process, in precedence order: a header `var`, - * then a form field, then a catch binding, then an `input`/`output` - * parameter and a repeated statement's element in source order. A type - * disagreement between two sites of one name is the validator's job. + * Every declaring site, in precedence order: a header `var`, a form field, a + * catch binding, then an `input`/`output` parameter or a repeated statement's + * element in source order. A type disagreement between two sites of one name + * is the validator's job. */ export function declaringSites(process: Process): DeclaringSite[] { const sites: DeclaringSite[] = []; @@ -97,8 +92,6 @@ export function declaringSites(process: Process): DeclaringSite[] { for (const node of AstUtils.streamAst(process)) { if (!isOnHandler(node)) continue; for (const { variable, node: setting } of caughtBindingsOf(node.items)) { - // A setting whose value is not a plain name reads a variable rather - // than declaring one, so it seeds nothing. if (variable === undefined) continue; sites.push({ node: setting.value, @@ -111,9 +104,8 @@ export function declaringSites(process: Process): DeclaringSite[] { // Both hold whatever was mapped or collected, so their type is open. for (const node of AstUtils.streamAst(process)) { if (isIoParameter(node)) { - // A field names a property of the delegate the element binds, set as - // that object is built, and a property is text handed to Tasklist or - // a worker, so neither declares anything the process can read. + // A field is set on the bound delegate and a property is text for + // Tasklist or a worker; neither is a process variable. if ( node.direction !== FIELD_DIRECTION && node.direction !== PROPERTY_DIRECTION @@ -139,16 +131,14 @@ export class DefaultVariableSymbolProvider implements VariableSymbolProvider { collect(process: Process): VariableTable { const table: VariableTable = new Map(); - // The first site of a name wins, so the table's type follows the - // precedence `declaringSites` lists in. + // The first site of a name wins, in the order `declaringSites` lists. for (const site of this.declaringSites(process)) { if (!table.has(site.name)) { table.set(site.name, { name: site.name, type: site.type }); } } - // Seeded last, so an author who declares one of these names keeps its - // type. A count alone (`for 3`) sets them too, so this is not read off - // the sites. + // Seeded last, so a declared name of the same spelling keeps its type. A + // count alone (`for 3`) sets them too, so this is not read off the sites. if (AstUtils.streamAst(process).some(isRepeated)) { for (const name of LOOP_VARIABLES) { if (!table.has(name)) { diff --git a/packages/language/src/vocabulary.ts b/packages/language/src/vocabulary.ts index eeb983d..f3d700a 100644 --- a/packages/language/src/vocabulary.ts +++ b/packages/language/src/vocabulary.ts @@ -1,15 +1,18 @@ /** * The words BPMNscript accepts and where each one is legal. The grammar takes * any word in these positions; the validator rejects one out of place and the - * completion provider offers the ones that fit, both from these tables. + * completion provider offers the ones that fit, both from these tables and in + * list order. * - * Every trigger list is a literal tuple, so a table keyed by one either carries - * a row per word or fails to compile. + * A trigger or constraint list is a literal tuple. A table keyed by one is + * annotated `Record` so any word can be looked up, and + * `satisfies` the tuple so a missing row fails to compile. */ import type { AstNode, Grammar } from 'langium'; -import { AstUtils, GrammarAST } from 'langium'; +import { AstUtils, GrammarAST, GrammarUtils } from 'langium'; import { isSubProcess } from './generated/ast.js'; +import { BpmnScriptGrammar } from './generated/grammar.js'; import type { BusinessRuleTask, CallActivity, @@ -33,11 +36,9 @@ import type { const RESERVED_WORDS_BY_GRAMMAR = new WeakMap>(); /** - * The keywords that cannot be written where a plain name belongs, read out of - * the grammar so the set stays correct as keywords change. A keyword's lexer - * token is named after its literal value, so these strings match a token type - * name as well as the source text; operators cannot be mistaken for a name and - * are excluded. Memoized per grammar, which every caller shares. + * A keyword's lexer token is named after its literal value, so these strings + * match a token type name as well as the source text. Operators are left out; + * they cannot be mistaken for a name. */ export function reservedWordsOf(grammar: Grammar): ReadonlySet { let words = RESERVED_WORDS_BY_GRAMMAR.get(grammar); @@ -64,7 +65,6 @@ export function formatPlainWordList( return `${words.slice(0, -1).join(', ')}, ${conjunction} ${words[words.length - 1]}`; } -/** The same clause with every word quoted: `'a'`, `'b'`, or `'c'`. */ export function formatWordList(words: readonly string[]): string { return formatPlainWordList(words.map((w) => `'${w}'`)); } @@ -99,11 +99,10 @@ export const BOOLEAN_ENGINE_KEYS: readonly EngineKey[] = [ ]; /** - * The engine execution settings Operaton reads off any flow node, gateways - * included: `BpmnParse.parseExclusiveGateway`, `parseInclusiveGateway`, + * The settings Operaton reads off any flow node, gateways included: + * `BpmnParse.parseExclusiveGateway`, `parseInclusiveGateway`, * `parseParallelGateway` and `parseEventBasedGateway` each go through - * `parseAsynchronousContinuationForActivity` for the async flags and - * `createActivityOnScope` for `jobPriority`, and + * `parseAsynchronousContinuationForActivity` and `createActivityOnScope`, and * `DefaultFailedJobParseListener.parseActivity` reads the retry cycle on the * same four. */ @@ -124,31 +123,26 @@ export const LOOP_VARIABLES = [ 'loopCounter', ] as const; -/** `key` spelled for a second carrier in the same parens: `join` + `asyncBefore` is `joinAsyncBefore`. */ -export function prefixedSettingKey(prefix: string, key: string): string { +function prefixedSettingKey(prefix: string, key: string): string { return prefix + key.charAt(0).toUpperCase() + key.slice(1); } -/** `key` spelled for the synthesized join gateway: `join` + `asyncBefore` is `joinAsyncBefore`. */ export function joinSettingKey(key: string): string { return prefixedSettingKey('join', key); } -/** The join gateway's spelling of each engine setting, keyed by the split's. */ export const JOIN_KEY_BY_ENGINE_KEY: Readonly> = Object.fromEntries(ENGINE_KEYS.map((key) => [key, joinSettingKey(key)])); -/** The join spellings alone, in {@link ENGINE_KEYS} order. */ export const JOIN_ENGINE_KEYS: readonly string[] = Object.values( JOIN_KEY_BY_ENGINE_KEY, ); -/** `key` spelled for the per-run carrier: `run` + `asyncBefore` is `runAsyncBefore`. */ export function runSettingKey(key: string): string { return prefixedSettingKey('run', key); } -/** An engine key under each spelling a parens carries it, the value shape being the same under all three. */ +/** An engine key under each spelling a parens carries it; the value shape is the same under all three. */ export const engineSpellings = (key: string): string[] => [ key, joinSettingKey(key), @@ -156,40 +150,27 @@ export const engineSpellings = (key: string): string[] => [ ]; /** - * The four settings a repetition writes on its `multiInstanceLoopCharacteristics` - * element rather than on the repeated activity itself. - * `BpmnParse.parseAsynchronousContinuationForActivity` hands that element to - * `parseAsynchronousContinuation`, which reads the three async settings onto - * each run, and `DefaultFailedJobParseListener.parseActivity` reads the retry - * cycle there the same way; `BpmnParse.parseActivity` makes the multi-instance - * body activity that `parseMultiInstanceLoopCharacteristics` builds for the - * repeated element the scope before `createActivityOnScope` runs, so the - * plain `jobPriority` in the same parens already prices each run's activity, - * not the whole loop's, which is why there is no `runJobPriority`. + * The settings a repetition writes on its `multiInstanceLoopCharacteristics` + * element, which `BpmnParse.parseAsynchronousContinuationForActivity` and + * `DefaultFailedJobParseListener.parseActivity` read onto each run. There is + * no `runJobPriority`: `BpmnParse.parseActivity` makes the multi-instance body + * the scope before `createActivityOnScope` runs, so the plain `jobPriority` in + * the same parens already prices each run. */ export const RUN_ENGINE_KEYS: readonly string[] = ENGINE_KEYS.filter( (key) => key !== 'jobPriority', ).map((key) => runSettingKey(key)); -/** - * The fifth binding: `BpmnParse.parseServiceTaskLike` dispatches on this key's - * value before it looks at any code attribute. - */ +/** `BpmnParse.parseServiceTaskLike` dispatches on this key's value before it looks at any code attribute. */ export const TYPE_BINDING_KEY = 'type'; -/** - * The binding handing the step to an external worker, and so the one the - * external extras ride: `taskPriority` in the parens, a `property` line and an - * `error ... when` mapping in the block. - */ export const EXTERNAL_BINDING_KEY = 'topic'; /** - * Exactly one of these binds a service task. `topic` delegates to an external - * worker polling the engine rather than the engine invoking the binding, and - * `type` selects a behaviour the engine builds itself. - * {@link BUSINESS_RULE_BINDING_KEYS} inherits it since `parseBusinessRuleTask` - * without a `decisionRef` reaches `parseServiceTaskLike`. + * Exactly one of these binds a service task; `topic` hands the step to an + * external worker polling the engine instead of the engine invoking anything. + * `parseBusinessRuleTask` without a `decisionRef` reaches the same + * `parseServiceTaskLike`, so {@link BUSINESS_RULE_BINDING_KEYS} inherits it. */ export const SERVICE_TASK_BINDING_KEYS: readonly string[] = [ 'class', @@ -199,14 +180,10 @@ export const SERVICE_TASK_BINDING_KEYS: readonly string[] = [ TYPE_BINDING_KEY, ]; -/** - * What a thrown or emitted message binds: a thrown message has no member - * block, so the fields a built-in behaviour requires cannot be written on it. - */ +/** A thrown message has no member block, so the fields a built-in `type` requires cannot be written on it. */ export const THROW_BINDING_KEYS: readonly string[] = SERVICE_TASK_BINDING_KEYS.filter((key) => key !== TYPE_BINDING_KEY); -/** The one trigger whose throw or emit carries an implementation binding. */ export const THROW_BINDING_TRIGGER = 'message'; /** The two `operaton:type` values `parseServiceTaskLike` routes to a built-in behaviour. */ @@ -215,11 +192,10 @@ export const TYPE_BINDING_VALUES = ['mail', 'shell'] as const; export type BuiltinTaskType = (typeof TYPE_BINDING_VALUES)[number]; /** - * The field names each built-in behaviour class declares, in declaration - * order. `ClassDelegateUtil.applyFieldDeclaration` throws "Field definition - * uses unexisting field '' on class ..." for any other name, reached - * through `instantiateDelegate` from both `parseEmailServiceTask` and - * `parseShellServiceTask`. + * In declaration order. `ClassDelegateUtil.applyFieldDeclaration` throws + * "Field definition uses unexisting field '' on class ..." for any + * other name, reached through `instantiateDelegate` from both + * `parseEmailServiceTask` and `parseShellServiceTask`. */ export const BUILTIN_FIELD_NAMES: Readonly< Record @@ -248,13 +224,7 @@ export interface RequiredFieldGroup { readonly error: string; } -/** - * The field combinations `validateFieldDeclarationsForEmail` and - * `validateFieldDeclarationsForShell` require before a mail or shell task - * deploys, in the order the engine checks them. A mail task needs `to` and one - * of `text`/`html`; `cc`/`bcc` satisfy neither. A shell task needs only - * `command`. - */ +/** In the order {@link BUILTIN_FIELD_VALIDATOR} checks them; `cc`/`bcc` satisfy neither mail group. */ export const BUILTIN_REQUIRED_FIELDS: Readonly< Record > = { @@ -293,19 +263,13 @@ export const SHELL_FLAG_FIELDS: readonly string[] = [ /** The spellings `ShellActivityBehavior.readFields` compares a flag with. */ export const SHELL_FLAG_LITERALS: readonly string[] = ['true', 'false']; -/** - * A decision step binds to a decision table or, as a service task does, to - * code. The engine requires one, and a decision key wins when both are written. - */ +/** The engine requires one, and `decision` wins when both are written. */ export const BUSINESS_RULE_BINDING_KEYS: readonly string[] = [ ...SERVICE_TASK_BINDING_KEYS, 'decision', ]; -/** - * What lands in `resultVariable`, and what `operaton:mapDecisionResult` holds: - * one entry, one row, one column, or every row. - */ +/** What `operaton:mapDecisionResult` holds: one entry, one row, one column, or every row. */ export const DECISION_RESULT_MAPPINGS = [ 'singleEntry', 'singleResult', @@ -313,23 +277,19 @@ export const DECISION_RESULT_MAPPINGS = [ 'resultList', ] as const; -/** The service-task rule minus `topic`; a fenced body binds a listener too. */ -export const LISTENER_BINDING_KEYS: readonly string[] = [ - 'class', - 'expression', - 'delegate', -]; +/** Outside this list, a fenced script body binds a listener too. */ +export const LISTENER_BINDING_KEYS: readonly string[] = + SERVICE_TASK_BINDING_KEYS.filter( + (key) => key !== EXTERNAL_BINDING_KEY && key !== TYPE_BINDING_KEY, + ); /** - * The bindings an injected field reaches. Operaton hands the field list to - * the class a `class` binding names, to the delegate a `delegate` expression - * resolves, and to the two built-in behaviours a `type` selects - * (`BpmnParse.parseEmailServiceTask` and `parseShellServiceTask` both go - * through `instantiateDelegate`), and to no other: an `expression` binding is - * constructed from its expression and its result variable alone, a `topic` - * hands the work to an external worker the engine injects nothing into, and a - * `decision` binding runs no implementation at all. A task and both listener - * kinds split the same way, a listener binding no `type`. + * The bindings Operaton hands a field list to: the class, the bean a + * `delegate` expression resolves, and the two built-in behaviours + * (`BpmnParse.parseEmailServiceTask` and `parseShellServiceTask` go through + * `instantiateDelegate`). An `expression` is built from its expression and + * result variable alone, a `topic` hands the work to a worker the engine + * injects nothing into, and a `decision` runs no implementation. */ export const FIELD_BINDING_KEYS: readonly string[] = [ 'class', @@ -338,10 +298,9 @@ export const FIELD_BINDING_KEYS: readonly string[] = [ ]; /** - * A call activity's variable-mapping delegate computes its in/out mapping in - * code, keyed by the IR mapper kind each spells. It is not `class`/`delegate` - * because Operaton hands this binding no field list, which is exactly what - * {@link FIELD_BINDING_KEYS} keys on. + * A call's variable-mapping delegate, keyed by the IR mapper kind. It is not + * spelled `class`/`delegate` because Operaton hands it no field list, which + * is what {@link FIELD_BINDING_KEYS} keys on. */ export const CALL_MAPPER_KEY_BY_KIND = { class: 'mapper', @@ -349,16 +308,15 @@ export const CALL_MAPPER_KEY_BY_KIND = { } as const; /** How a call or a decision step pins which deployed version the engine runs. */ -export const CALL_BINDING_VALUES: readonly string[] = ['latest', 'deployment']; +export const CALL_BINDING_VALUES = ['latest', 'deployment'] as const; + +export type CallBindingValue = (typeof CALL_BINDING_VALUES)[number]; /** - * The settings a process header takes, in the order they are offered and - * printed. Each attaches to the `bpmn:process` element itself rather than to - * any node inside it. `BpmnParse.parseProcess` also reads `jobPriority`, - * `taskPriority`, and `isStartableInTasklist` off the same element, and, - * through `parseScope`, a process-level execution listener and - * `potentialStarter`; this set leaves them out on purpose, since giving them - * authoring surface is a separate addition, not a gap in this list. + * In the order they are offered and printed. `BpmnParse.parseProcess` also + * reads `jobPriority`, `taskPriority` and `isStartableInTasklist` off the + * process element, and `parseScope` a process-level listener and + * `potentialStarter`; those are left out on purpose, not gaps. */ export const PROCESS_HEADER_KEYS: readonly string[] = [ 'label', @@ -369,38 +327,55 @@ export const PROCESS_HEADER_KEYS: readonly string[] = [ 'candidateStarterGroups', ]; -export const IO_DIRECTIONS: readonly string[] = ['input', 'output']; +export const INPUT_DIRECTION = 'input'; +export const OUTPUT_DIRECTION = 'output'; + +export const IO_DIRECTIONS: readonly string[] = [ + INPUT_DIRECTION, + OUTPUT_DIRECTION, +]; + +/** + * A user task's settings that are the `operaton:` attribute of the same name, + * value verbatim; the IR field is named the same. In print order. + */ +export const USER_TASK_VERBATIM_KEYS = [ + 'assignee', + 'formKey', + 'candidateGroups', + 'candidateUsers', + 'dueDate', + 'followUpDate', + 'priority', +] as const; /** - * The third direction a member of a block is written with. It names a property - * of the class, delegate, or built-in behaviour the element binds, set once as - * that implementation is instantiated, so it is neither read from nor written - * to a process variable. See {@link FIELD_BINDING_KEYS} for where one is legal. + * A field names a property of the bound implementation, set once as it is + * instantiated, so it neither reads nor writes a process variable; + * {@link FIELD_BINDING_KEYS} says where one is legal. */ export const FIELD_DIRECTION = 'field'; /** - * The fourth direction: a member of a form field's block or an external - * task's, written to `operaton:properties` as text. On a form field - * `DefaultFormHandler.parseProperties` reads it keyed by `id` and hands it to - * Tasklist; on an external task `BpmnParseUtil.parseOperatonExtensionProperties` - * reads it keyed by `name` and hands it to the worker. It declares no process - * variable. + * Written to `operaton:properties` as text and declares no process variable. + * A form field's are read by `DefaultFormHandler.parseProperties` keyed by + * `id` and handed to Tasklist, an external task's by + * `BpmnParseUtil.parseOperatonExtensionProperties` keyed by `name` and + * handed to the worker. */ export const PROPERTY_DIRECTION = 'property'; /** * Read by `BpmnParse.parsePriority` inside `parseExternalServiceTask` and - * nowhere else on a step; the process header's own `operaton:taskPriority` is - * a separate site this surface does not carry. An integer or an expression. + * nowhere else on a step; an integer or an expression. */ export const TASK_PRIORITY_KEY = 'taskPriority'; /** - * The name `VariableScopeElResolver` resolves to the external task entity in + * `VariableScopeElResolver` resolves this name to the external task entity in * an expression evaluated on its execution, which is where a mapping's - * condition runs (`ExternalTaskEntity.evaluateThrowBpmnError`). A process - * variable everywhere else, so it is admitted inside a mapping alone. + * condition runs (`ExternalTaskEntity.evaluateThrowBpmnError`); it is a + * process variable everywhere else, so it is admitted inside a mapping alone. */ export const EXTERNAL_TASK_EL_NAME = 'externalTask'; @@ -408,27 +383,28 @@ export const EXTERNAL_TASK_EL_NAME = 'externalTask'; export const ERROR_MAPPING_HEAD = 'error'; export const ERROR_MAPPING_WHEN = 'when'; +export type TimerKind = 'duration' | 'date' | 'cycle'; + /** * The particle a timer clause is written with, keyed by the BPMN timer - * definition it selects (`timeDuration`, `timeDate`, `timeCycle`). Read in - * both directions, so the pairing cannot drift. + * definition it selects (`timeDuration`, `timeDate`, `timeCycle`). */ -export const TIMER_PARTICLE_BY_KIND: Readonly< - Record<'duration' | 'date' | 'cycle', string> -> = { +export const TIMER_PARTICLE_BY_KIND: Readonly> = { duration: 'after', date: 'at', cycle: 'every', }; -/** The particles alone, in the order diagnostics and completion list them. */ export const TIMER_PARTICLES: readonly string[] = Object.values( TIMER_PARTICLE_BY_KIND, ); export const EVENT_BINDING_FIELDS: readonly string[] = ['code', 'message']; -/** The fields the `trigger`'s event carries: a code always, a message where its rule says so. */ +export const EVENT_BINDING_FIELD_SET: ReadonlySet = new Set( + EVENT_BINDING_FIELDS, +); + export function eventBindingFieldsFor(trigger: string): readonly string[] { return EVENT_BINDING_FIELDS.filter( (field) => field !== 'message' || TRIGGER_PAYLOAD[trigger]?.message, @@ -452,11 +428,10 @@ export const TASK_LISTENER_EVENTS = [ ] as const; /** - * The types a form field takes, in vendor-neutral spellings: `number` becomes - * the Operaton `long` at export, and `json`/`any` have no `operaton:formField` - * representation at all. `enum` is a form type and not a `var` type, so the - * grammar's field type rule admits it beside `VarType` and the validator holds - * the word to this list. + * Vendor-neutral spellings: `number` becomes the Operaton `long` at export, + * and `json`/`any` have no `operaton:formField` representation. `enum` is a + * form type and not a `var` type, so the grammar admits it beside `VarType` + * and the validator holds the word to this list. */ export const FORM_FIELD_TYPES = [ 'string', @@ -467,10 +442,9 @@ export const FORM_FIELD_TYPES = [ ] as const; /** - * The process variable a form field of `type` binds: an `enum` stores the - * chosen value's id as a string (`EnumFormType.convertValue`), every other - * form type is a `VarType` of the same name, and a word outside the list is - * no type at all. + * An `enum` stores the chosen value's id as a string + * (`EnumFormType.convertValue`); every other form type is the `VarType` of + * the same name. */ export function formFieldVariableType(type: string): VarType | undefined { const found = FORM_FIELD_TYPES.find((t) => t === type); @@ -478,11 +452,10 @@ export function formFieldVariableType(type: string): VarType | undefined { } /** - * The names a form field's `operaton:constraint` takes. The first six are the - * validators `ProcessEngineConfigurationImpl.initFormFieldValidators` - * registers; `FormValidators.createValidator` reads a class name or an - * expression off `validator`'s `config` and fails the deployment on any other - * name. + * The first six are the validators + * `ProcessEngineConfigurationImpl.initFormFieldValidators` registers; + * `FormValidators.createValidator` reads a class name or an expression off + * `validator`'s `config` and fails the deployment on any other name. */ export const FORM_CONSTRAINT_NAMES = [ 'required', @@ -501,13 +474,10 @@ export function isFormConstraintName( } /** - * The field types each constraint is legal on. * `AbstractNumericValidator.validate` throws on any submitted value that is * not a Java number and `AbstractTextValueValidator.validate` on any that is * not a string, so the bounds fit one type each; the rest never read the - * value's type. The `satisfies` clause forces a row per name in - * {@link FORM_CONSTRAINT_NAMES}, while the annotation keeps the lookup open to - * a word of any origin. + * value's type. */ export const FORM_CONSTRAINT_TYPES: Readonly< Record @@ -525,15 +495,25 @@ export const FORM_CONSTRAINT_TYPES: Readonly< >; /** - * The text a bound's `config` may hold, bare or quoted in the script. A - * `number` field is an Operaton `long`, so `AbstractNumericValidator.validate` - * parses a `min`/`max` config with `Long.parseLong`, and - * `MinLengthValidator.validate` a length with `Integer.parseInt`; neither - * reads an expression, and a decimal bound deploys and then fails every - * submission with `FormFieldConfigurationException`. + * `AbstractNumericValidator.validate` parses a `min`/`max` config with + * `Long.parseLong` and `MinLengthValidator.validate` a length with + * `Integer.parseInt`; neither reads an expression, and a decimal bound + * deploys and then fails every submission with + * `FormFieldConfigurationException`. */ export const FORM_BOUND_TEXT = /^-?\d+$/; +/** The grammar's `ID` terminal as the lexer runs it, unanchored. */ +export const ID_TERMINAL: RegExp = GrammarUtils.terminalRegex( + BpmnScriptGrammar().rules.find( + (rule): rule is GrammarAST.TerminalRule => + GrammarAST.isTerminalRule(rule) && rule.name === 'ID', + )!, +); + +/** A whole string the lexer reads as one `ID` token. */ +export const ID_TEXT = new RegExp(`^${ID_TERMINAL.source}$`, ID_TERMINAL.flags); + /** * What `StringUtil.isExpression` counts as one: an opening at the start of * the trimmed text. A body with an opening further in is a constant to the @@ -541,6 +521,13 @@ export const FORM_BOUND_TEXT = /^-?\d+$/; */ export const EXPRESSION_OPEN = /^\s*[$#]\{/; +/** + * An opening anywhere in the text. `ExpressionManager.createExpression` hands + * JUEL the string as it stands, and JUEL evaluates a composite such as + * `a-${b}` as text around an expression; either opener marks an expression. + */ +export const EXPRESSION_ANYWHERE = /[$#]\{/; + /** * The words JUEL's `Scanner.addKeyToken` registers as operators besides * `true`, `false` and `null`, which the grammar spells as literals. A variable @@ -564,12 +551,10 @@ export const JUEL_RESERVED_WORDS = [ ] as const; /** - * `true`, `false` and `null`, which the grammar spells as literals so a - * variable or a rendered `.prop` accessor can never carry one. A raw template - * is plain text, so `${order.true}` reaches `Scanner.nextIdentifier`, which - * returns the `Symbol.TRUE` keyword token there too; `Parser.parseDotToken`'s - * `consumeToken(IDENTIFIER)` then refuses it. The raw-template scan checks the - * union of this and {@link JUEL_RESERVED_WORDS}. + * The grammar spells these as literals, so only a raw template can carry one + * as a name: `${order.true}` reaches `Scanner.nextIdentifier`, which returns + * the keyword token, and `Parser.parseDotToken`'s `consumeToken(IDENTIFIER)` + * refuses it. */ export const JUEL_LITERAL_WORDS = ['true', 'false', 'null'] as const; @@ -580,7 +565,6 @@ export const JUEL_LITERAL_WORDS = ['true', 'false', 'null'] as const; */ export const DATE_PATTERN_KEY = 'pattern'; -/** Everything a form field's parens take, in the order diagnostics list them. */ export const FORM_FIELD_SETTING_KEYS: readonly string[] = [ ...FORM_CONSTRAINT_NAMES, DATE_PATTERN_KEY, @@ -588,8 +572,7 @@ export const FORM_FIELD_SETTING_KEYS: readonly string[] = [ /** * Soft trigger words: they lex as plain `ID`s rather than keywords, so an - * unrecognized one is a validator diagnostic, not a parse error. The order is - * the order diagnostics list them in. + * unrecognized one is a validator diagnostic, not a parse error. */ export const ON_TRIGGERS = [ 'error', @@ -603,9 +586,9 @@ export const ON_TRIGGERS = [ ] as const; /** - * Every kind with a terminal form. A timer fires off the clock and a condition - * off data, so neither has anything to throw, and a cancel is written on the - * end that gives up an `attempt` block. + * A timer fires off the clock and a condition off data, so neither has + * anything to throw; a cancel is written on the `end` that gives up an + * `attempt` block. */ export const THROW_TRIGGERS = [ 'error', @@ -616,9 +599,9 @@ export const THROW_TRIGGERS = [ ] as const; /** - * Every kind with a continuing form; an error always ends its path. A link - * continues at its catch rather than at the next statement here, and `throw` - * has no link form because a link throw is intermediate, never an end. + * An error always ends its path, so it has no continuing form. A link + * continues at its catch rather than at the next statement, and a link throw + * is intermediate, never an end, so `throw` has no link form. */ export const EMIT_TRIGGERS = [ 'escalation', @@ -629,9 +612,9 @@ export const EMIT_TRIGGERS = [ ] as const; /** - * The triggers legal behind an event-based gateway. `link` is left out: - * `BpmnParse.parseIntermediateCatchEvent` addErrors a link catch there, so a - * plain `await` is the only place one may stand. + * `link` is left out: `BpmnParse.parseIntermediateCatchEvent` refuses a link + * catch behind an event-based gateway, so a plain `await` is the only place + * one may stand. */ export const RACE_TRIGGERS = [ 'message', @@ -641,20 +624,18 @@ export const RACE_TRIGGERS = [ ] as const; /** - * The kinds with a blocking inline catch form: {@link RACE_TRIGGERS} plus - * `link`, which only a plain `await` may head. Error and escalation travel - * outward, compensation runs through a subprocess's own `on compensation` - * body, and a cancel is caught by `on : cancel`. + * Error and escalation travel outward, compensation runs through a + * subprocess's own `on compensation` body, and a cancel is caught by + * `on : cancel`, so none of those has an inline catch. */ export const CATCH_TRIGGERS = [...RACE_TRIGGERS, 'link'] as const; /** - * The triggers `BpmnParse.parseProcessDefinitionStartEvent` dispatches a start - * behaviour for. It sets `NoneStartEventActivityBehavior` on every - * process-level start regardless, then only checks for a timer, message, - * signal, or conditional definition; an error, escalation, or compensation - * definition there is never inspected, so it starts as if none were written. - * Those three stay off rather than emitting XML the engine disregards. + * `BpmnParse.parseProcessDefinitionStartEvent` looks only for a timer, + * message, signal or conditional definition; an error, escalation or + * compensation definition on a process-level start is never inspected, so it + * starts as if none were written. Those three stay off rather than emitting + * XML the engine disregards. */ export const START_TRIGGERS = [ 'message', @@ -664,9 +645,8 @@ export const START_TRIGGERS = [ ] as const; /** - * The two kinds an end event carries rather than raises. A terminate stops - * every running path of its scope at once; a cancel gives up the `attempt` - * block it sits in and hands the flow to that block's handler. + * Carried by an end event rather than raised: a terminate stops every running + * path of its scope at once, a cancel gives up the `attempt` block it sits in. */ export const END_TRIGGERS = ['terminate', 'cancel'] as const; @@ -688,17 +668,8 @@ export interface TriggerPayloadRule { readonly hostless: boolean; } -/** - * An error always interrupts, so it has no `alongside`. `message`/`signal` are - * name-keyed subscriptions, so the name is required. `compensation` reverses - * finished work: nothing to catch by name, no flow to run alongside. `cancel` - * mirrors it, legal only on a host where compensation is legal only without - * one. `link` opens no `on` handler: it keys the engine's per-file link table - * by name alone, so it has no handler, boundary, or start form. The - * `satisfies` clause forces a row per word in {@link ON_TRIGGERS} plus `link`, - * while the annotation keeps the lookup open to a word of any origin. - */ export const TRIGGER_PAYLOAD: Readonly> = { + // An error always interrupts. error: { code: 'optional', timer: false, @@ -717,6 +688,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { boundary: true, hostless: true, }, + // A message and a signal are name-keyed subscriptions. message: { code: 'required', timer: false, @@ -753,6 +725,8 @@ export const TRIGGER_PAYLOAD: Readonly> = { boundary: true, hostless: true, }, + // Compensation reverses finished work: nothing to catch by name, no flow to + // run alongside. compensation: { code: 'forbidden', timer: false, @@ -762,6 +736,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { boundary: false, hostless: true, }, + // A cancel is caught on its `attempt` host alone. cancel: { code: 'forbidden', timer: false, @@ -771,6 +746,8 @@ export const TRIGGER_PAYLOAD: Readonly> = { boundary: true, hostless: false, }, + // A link keys the engine's per-file link table by name alone, so it opens + // no `on` handler of any form. link: { code: 'required', timer: false, @@ -783,36 +760,26 @@ export const TRIGGER_PAYLOAD: Readonly> = { } satisfies Record<(typeof ON_TRIGGERS)[number] | 'link', TriggerPayloadRule>; /** - * The triggers whose payload refers to a declaration rather than carrying a - * name of its own: `throw error(OUT_OF_STOCK)` names a code declared in the - * process header, while `message("OrderReceived")` names the subscription the - * engine keys on and declares nothing. This is the pair a bare word in the - * parens is resolved as a cross-reference under, and {@link namesACode} is the - * wider question, taking `message` and `signal` too. + * `throw error(OUT_OF_STOCK)` names a code declared in the process header and + * is resolved as a cross-reference; `message("OrderReceived")` names the + * subscription the engine keys on and declares nothing. */ export const DECLARED_CODE_TRIGGERS: ReadonlySet = new Set([ 'error', 'escalation', ]); -/** - * Whether the trigger names a code, as opposed to reading a timer, a condition, - * or nothing at all. The completion snippets that scaffold a `"CODE"` string - * offer exactly the words of their statement that pass. - */ export function namesACode(trigger: string): boolean { const code = TRIGGER_PAYLOAD[trigger]?.code; return code === 'required' || code === 'optional'; } /** - * Fence-tag aliases and the canonical Operaton `scriptFormat` they normalize - * to; the printer emits the canonical tag, so `js` round-trips to `javascript`. - * `juel` is the one engine the jar always registers - * (`META-INF/services/javax.script.ScriptEngineFactory`, - * `ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE`); every other tag resolves a - * JSR-223 engine off the classpath at first evaluation. `ecmascript` is the - * name Operaton also accepts for the JavaScript engine. + * Fence tags and the canonical `scriptFormat` each normalizes to; the printer + * emits the canonical one, so `js` round-trips to `javascript`. `juel` is the + * one engine the jar always registers + * (`ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE`); every other tag resolves a + * JSR-223 engine off the classpath at first evaluation. */ export const SCRIPT_FORMAT_ALIASES: Readonly> = { juel: 'juel', @@ -828,28 +795,22 @@ export const SCRIPT_FORMAT_ALIASES: Readonly> = { }; /** - * The one lookup the validator and every lowering site use: `juel`, - * `JavaScript`, and `ECMAScript` all resolve, matching - * `ScriptingEngines.getScriptEngineForLanguage`, which lowercases the - * language before looking an engine up. + * Case-insensitive, as `ScriptingEngines.getScriptEngineForLanguage` + * lowercases the language before looking an engine up. */ export function scriptFormatOf(tag: string): string | undefined { return SCRIPT_FORMAT_ALIASES[tag.toLowerCase()]; } /** - * Split a raw `FENCED_SCRIPT` token into the fence tag - * {@link SCRIPT_FORMAT_ALIASES} is keyed by and the code body. The tag is the - * maximal run of ASCII letters after the opening fence, and one line terminator - * right after it is dropped; nothing else is touched, so indentation and - * trailing newlines inside the body survive verbatim, except that every - * remaining `\r\n` is normalized to `\n`: the terminal matches `\r` like any - * other character, so a CRLF-checked-out source would otherwise carry a - * carriage return into the written `` body that an LF checkout - * never would. + * The tag is the run of ASCII letters after the opening fence, one line + * terminator after it is dropped, and the body is otherwise verbatim except + * that every `\r\n` becomes `\n`: the terminal matches `\r` like any other + * character, so a CRLF checkout would otherwise write a carriage return into + * `` that an LF checkout never would. */ export function splitFencedScript(raw: string): { tag: string; code: string } { - const inner = raw.slice(3, -3); // strip the opening/closing ``` delimiters + const inner = raw.slice(3, -3); const tag = /^[a-zA-Z]+/.exec(inner)?.[0] ?? ''; const rest = inner.slice(tag.length); const afterOpeningLine = rest.startsWith('\r\n') @@ -883,51 +844,40 @@ export interface AttributeBlockRule { /** The element kind as a noun phrase with article, for diagnostics. */ readonly description: string; /** - * The keys this kind owns, in the order they are offered. `label` and - * `documentation` are both present wherever the element lowers to a BPMN - * node carrying a diagram name; an `on` handler, a `throw`, and an `emit` - * have no `label` key, so either written there is an unknown key rather - * than a dropped one. An `await`'s own name is a grammar identifier for - * `goto`, not this key: a link pair's element name is stamped from the link - * name instead. + * The keys this kind owns. `label` and `documentation` appear wherever the + * element lowers to a BPMN node with a diagram name; a handler, a `throw`, + * an `emit`, an `await` and a branch of one take neither, so either written + * there is an unknown key, not a dropped one. */ readonly own: readonly string[]; /** {@link own} and the engine settings together, for membership tests. */ readonly keys: ReadonlySet; /** - * The bare words legal in the parens. Every flag word lexes as a flag - * wherever parens are written, being a keyword elsewhere in the grammar, so a - * kind that takes none still needs the empty row to refuse them. + * The bare words legal in the parens. A flag word lexes as one wherever + * parens are written, so a kind that takes none needs the empty row to + * refuse them. */ readonly flags: readonly string[]; readonly forms: boolean; - /** - * Whether the statement takes a `for` clause, and so its parens the - * {@link RUN_ENGINE_KEYS}: the settings of the job each run gets, written - * on the `multiInstanceLoopCharacteristics` element the clause lowers to. - */ + /** Whether the statement takes a `for` clause, and so its parens the {@link RUN_ENGINE_KEYS}. */ readonly repeats: boolean; readonly parameters: boolean; /** - * Whether the kind lowers to an element with an implementation to inject - * into. It answers for the element's own block: a listener's block follows - * the listener's own binding, so a task listener carries a field on a kind - * this says `false` for. + * Answers for the element's own block: a listener's block follows the + * listener's own binding, so a task listener carries a field on a kind this + * says `false` for. */ readonly fields: boolean; readonly taskListeners: boolean; /** * Whether the kind takes an external task's extras: `taskPriority` in the * parens, and `property` and `error ... when` lines in the block, each - * checked against the binding actually written. A thrown message can bind a - * topic too, and this surface writes none of them for it; - * `BpmnParse.parseServiceTaskLike` reads the priority and the mappings off - * the message definition (the property list off the event element). + * checked against the binding written. A thrown message can bind a topic + * too, but this surface writes none of them for it. */ readonly externalExtras: boolean; } -/** Derive `keys` from `own` and `repeats` so they cannot disagree. */ function withKeys(spec: Omit): AttributeBlockRule { return { ...spec, @@ -940,9 +890,9 @@ function withKeys(spec: Omit): AttributeBlockRule { } /** - * `process` is required on a call, and `binding`/`version` are mutually - * exclusive version-pinning discriminators for whichever key they sit beside. - * All of it is checked separately. + * Required keys (`process` on a call) and pairings (`binding`/`version` + * beside the key they pin) are checked separately; the table holds only what + * is legal. */ export const ATTRIBUTE_BLOCK_RULES: Readonly< Record @@ -971,19 +921,13 @@ export const ATTRIBUTE_BLOCK_RULES: Readonly< }), UserTask: withKeys({ description: 'a user task', + // Offered in print order, the form-reference triple right after `formKey`. own: [ 'label', 'documentation', - 'assignee', - 'formKey', - 'formRef', - 'binding', - 'version', - 'candidateGroups', - 'candidateUsers', - 'dueDate', - 'followUpDate', - 'priority', + ...USER_TASK_VERBATIM_KEYS.flatMap((key) => + key === 'formKey' ? [key, 'formRef', 'binding', 'version'] : [key], + ), ], flags: [], forms: true, @@ -1176,26 +1120,17 @@ export const ATTRIBUTE_BLOCK_RULES: Readonly< }; export interface GatewayStatementRule { - /** The statement as a noun phrase with article, for diagnostics. */ readonly description: string; /** * Whether the statement synthesizes a join gateway beside its split, and so - * takes the {@link JOIN_ENGINE_KEYS}. A loop is one gateway with a - * back-edge, so its parens take the {@link ENGINE_KEYS} alone. + * takes the {@link JOIN_ENGINE_KEYS}; a loop is one gateway with a + * back-edge. */ readonly join: boolean; - /** - * The {@link ENGINE_KEYS} the head does not take: the validator refuses - * them and completion leaves them out. - */ + /** The {@link ENGINE_KEYS} the head does not take. */ readonly refuses: readonly string[]; } -/** - * The statements whose parens carry a gateway's engine settings. The head - * takes the {@link ENGINE_KEYS} for the split, the loop gateway, or the - * event-based gateway, less what its row refuses. - */ export const GATEWAY_STATEMENT_RULES: Readonly< Record< | 'IfStatement' @@ -1240,7 +1175,6 @@ export function listenerEventsFor(rule: AttributeBlockRule): readonly string[] { : EXECUTION_LISTENER_EVENTS; } -/** The directions a member of this kind's block is written with. */ export function parameterDirectionsFor( rule: AttributeBlockRule, ): readonly string[] { @@ -1252,10 +1186,9 @@ export function parameterDirectionsFor( } /** - * The `attempt` head takes the same block as `subprocess`, so only the noun - * differs. It stays out of {@link ATTRIBUTE_BLOCK_RULES} because that map is - * keyed by AST type and the two heads share one, so a diagnostic enumerating - * the kinds a setting is legal on adds it back alongside the map's rows. + * Out of {@link ATTRIBUTE_BLOCK_RULES} because that map is keyed by AST type + * and `attempt` shares `SubProcess`; a diagnostic enumerating the kinds a + * setting is legal on adds it back beside the map's rows. */ export const ATTEMPT_BLOCK_RULE: AttributeBlockRule = { ...ATTRIBUTE_BLOCK_RULES.SubProcess, diff --git a/packages/language/test/completion.test.ts b/packages/language/test/completion.test.ts index 0533979..7c09408 100644 --- a/packages/language/test/completion.test.ts +++ b/packages/language/test/completion.test.ts @@ -1,13 +1,8 @@ /** - * Completion for the BPMNscript language server, driven through the real - * `CompletionProvider` on the shared services so the DI wiring is exercised too. - * - * The default Langium completion inserts bare keywords, leaving the caret at - * `process|`, a position the grammar continues with an id then `{`, where - * nothing is suggestible. The custom provider emits LSP snippet items for the - * structural keywords instead, so accepting one scaffolds the whole construct - * and drops the caret inside the body. Non-structural keywords still fall - * through to plain keyword completion. + * `BpmnScriptCompletionProvider` through the real `CompletionProvider` on the + * shared services, so the DI wiring is exercised too. A row pins the whole + * offered list at a caret; a scaffold row accepts an item the way an editor + * does and parses and validates the result. */ import { beforeAll, describe, expect, test } from 'vitest'; @@ -71,7 +66,7 @@ async function labelsAt( return (await completionItems(text, line, character)).map((i) => i.label); } -/** The text an LSP client would actually insert (textEdit wins over insertText). */ +/** What an LSP client inserts: `textEdit` wins over `insertText`. */ function inserted(item: CompletionItem): string { if (item.textEdit && 'newText' in item.textEdit) { return item.textEdit.newText; @@ -80,10 +75,9 @@ function inserted(item: CompletionItem): string { } /** - * The text an editor leaves behind when a snippet is accepted and every tab - * stop is tabbed past: choices collapse to their first option, defaults to - * their default, bare stops to `fillStop`, and a `\$` escape to the EL `$` it - * stands for. + * The text an editor leaves once every tab stop is tabbed past: a choice + * collapses to its first option, a default to itself, a bare stop to + * `fillStop`, and a `\$` escape to the EL `$` it stands for. */ function accepted( item: CompletionItem, @@ -97,10 +91,9 @@ function accepted( } /** - * The same text with a step typed into every bare tab stop, which is what the - * author does next: each one opens a body, and a container is only complete - * once something runs in it. The stop's number keeps the steps apart, so two - * branches of one scaffold do not collide on a name. + * A step typed into every bare tab stop, since each opens a body that is only + * complete once something runs in it; the stop's number keeps two branches of + * one scaffold from colliding on a name. */ const typedInto = (stop: string) => `user Step${stop}`; @@ -119,11 +112,7 @@ async function build(text: string, validation: boolean) { return document; } -/** - * The lexer and parser errors `text` produces, so a scaffold can be checked as - * accepted. Both arrays matter: an unlexable character never reaches the parser, - * so parser errors alone would report a rejected input as clean. - */ +/** Both arrays: an unlexable character never reaches the parser, so parser errors alone would report a rejected input as clean. */ async function parseErrors(text: string): Promise { const { parseResult } = await build(text, false); return [...parseResult.lexerErrors, ...parseResult.parserErrors].map( @@ -131,11 +120,7 @@ async function parseErrors(text: string): Promise { ); } -/** - * The errors `text` raises once validated. Warnings are left out: a scaffold - * writes an example value naming a variable the author has yet to declare, and - * saying so is the editor doing its job rather than a defect in the scaffold. - */ +/** Errors only: a scaffold's example value names a variable the author has yet to declare, and that warning is the editor doing its job. */ async function validationErrors(text: string): Promise { const document: LangiumDocument = await build(text, true); return withTextMessages(document.diagnostics ?? []) @@ -143,7 +128,6 @@ async function validationErrors(text: string): Promise { .map((d) => d.message); } -/** A program with `|` marking the caret, split into text and position. */ function caretAt(program: string) { const offset = program.indexOf('|'); const lines = program.slice(0, offset).split('\n'); @@ -155,10 +139,9 @@ function caretAt(program: string) { } /** - * One offered completion, as `[label, detail, inserted text]`, with the kind - * where it is neither of the two the harness derives: an item that inserts more - * than its label is a `Snippet` and one that inserts its label is a `Keyword`, - * so only a cross-reference row spells its own. + * One offered completion. The kind is derived, a `Snippet` where the item + * inserts more than its label and a `Keyword` otherwise, so only a + * cross-reference row spells its own. */ type Item = readonly [ label: string, @@ -171,10 +154,9 @@ const CONSTRUCT = 'BPMNscript construct'; const SETTING = 'BPMNscript setting'; const EVENT_WORD = 'BPMNscript event word'; const LISTENER_EVENT = 'BPMNscript listener event'; -/** Langium's own caption for a keyword it completes with no help from us. */ +/** Langium's caption for a keyword it completes on its own. */ const KEYWORD = 'Keyword'; -/** The words an expression position offers on its own. */ const LITERALS: Item[] = [ ['true', KEYWORD, 'true'], ['false', KEYWORD, 'false'], @@ -191,16 +173,16 @@ const CONDITION: Item = [ 'a data-change watchdog', 'condition(${1:amount > 100})', ]; -/** A duration is the bare payload the `timer` snippet already scaffolds. */ +/** A duration is the bare payload the `timer` snippet scaffolds, so only the keyed forms are offered. */ const TIMER_KEYS: Item[] = [ ['at', SETTING, 'at: "${1:2026-08-01T09:00:00}"'], ['every', SETTING, 'every: "${1:R/PT10M}"'], ]; -/** Opens the flow, so it belongs above the first step rather than after one. */ +/** Offered in the header, above the first step. */ const START: Item = ['start', CONSTRUCT, 'start ${1:name}']; -/** Every statement a body position opens, in the order they are offered. */ +/** Every statement a body position offers, in order. */ const STATEMENTS: Item[] = [ START, ['end', CONSTRUCT, 'end ${1:name}'], @@ -246,10 +228,6 @@ const STATEMENTS: Item[] = [ ], ]; -/** - * The process-scope declarations, offered alongside the statements. A label is - * a setting on the process head rather than a declaration, so it is not here. - */ const HEADER_DECLS: Item[] = [ [ 'var', @@ -266,7 +244,6 @@ const HEADER_DECLS: Item[] = [ const PROCESS_BODY: Item[] = [...HEADER_DECLS, ...STATEMENTS]; -/** Two declared variables, and the items completion offers for them. */ const VARS = 'var amount: number\n var ok: boolean'; const VARIABLES: Item[] = [ ['amount', 'number', 'amount', CompletionItemKind.Variable], @@ -283,9 +260,9 @@ const REPEAT_FORMS: Item[] = [ ]; /** - * The words a handler at process level catches, host-less or on a user task. - * `compensation` joins them only directly inside a subprocess body, `cancel` - * only on an attempt block as host, and a service task drops `escalation`. + * What a handler at process level catches, host-less or on a user task. + * `compensation` joins only directly inside a subprocess body, `cancel` only + * on an attempt block as host, and a service task drops `escalation`. */ const ON_TRIGGERS: Item[] = [ ['error', EVENT_WORD, 'error'], @@ -323,7 +300,6 @@ const ENGINE_SETTINGS: Item[] = [ ['retryCycle', SETTING, 'retryCycle: "${1:R3/PT10M}"'], ]; -/** The join gateway's settings: the same values under the prefixed keys. */ const JOIN_SETTINGS: Item[] = [ ['joinAsyncBefore', SETTING, 'joinAsyncBefore: ${1|true,false|}'], ['joinAsyncAfter', SETTING, 'joinAsyncAfter: ${1|true,false|}'], @@ -332,11 +308,7 @@ const JOIN_SETTINGS: Item[] = [ ['joinRetryCycle', SETTING, 'joinRetryCycle: "${1:R3/PT10M}"'], ]; -/** - * The per-run settings a repeated statement's parens take after the engine - * ones: the same values under the `run` keys, a job priority having no per-run - * carrier. - */ +/** The per-run keys; a job priority has no per-run carrier. */ const RUN_SETTINGS: Item[] = [ ['runAsyncBefore', SETTING, 'runAsyncBefore: ${1|true,false|}'], ['runAsyncAfter', SETTING, 'runAsyncAfter: ${1|true,false|}'], @@ -344,13 +316,12 @@ const RUN_SETTINGS: Item[] = [ ['runRetryCycle', SETTING, 'runRetryCycle: "${1:R3/PT10M}"'], ]; -/** The head of a statement with a join: the split's keys, then the join's. */ const SPLIT_AND_JOIN_PARENS: Item[] = [...ENGINE_SETTINGS, ...JOIN_SETTINGS]; -/** A loop has one gateway, so its parens take the bare keys alone. */ +/** A loop has one gateway, so no join keys. */ const LOOP_PARENS: Item[] = ENGINE_SETTINGS; -/** An await block's head leaves out the one key the validator refuses there. */ +/** Without the one key the validator refuses on an await head. */ const AWAIT_PARENS: Item[] = [ ...ENGINE_SETTINGS.filter(([label]) => label !== 'asyncAfter'), ...JOIN_SETTINGS, @@ -375,7 +346,7 @@ const LISTENER_KEYWORD: Item = [ const FORM_KEYWORD: Item = ['form', KEYWORD, 'form']; -/** A form field's parens, in the order the constraints are registered. */ +/** In the order the constraints are registered. */ const FORM_FIELD_PARENS: Item[] = [ ['required', SETTING, 'required: true'], ['readonly', SETTING, 'readonly: true'], @@ -393,24 +364,23 @@ const PROPERTY: Item = [ 'property ${1:name} = "${2:value}"', ]; -/** The one field type each setting is legal on; `pattern` has no row and fits a date. */ +/** The one field type a setting is legal on; `pattern` has no row and fits a date. */ const fieldTypeTaking = (key: string): string => FORM_CONSTRAINT_TYPES[key]?.[0] ?? 'date'; -/** A mapping's code is a declaration, so the scaffold writes it bare. */ +/** The code is a declaration, so the scaffold writes it bare. */ const ERROR_MAPPING: Item = [ 'error', 'raise a declared error when a reported failure matches', 'error ${1:CODE} when ${2:condition}', ]; -/** What the brace block of an element holds, its settings having moved out. */ const BLOCK_MEMBERS: Item[] = [FORM_KEYWORD, ...PARAMETERS, LISTENER_KEYWORD]; -/** The block of a service task bound to a class, which the engine injects fields into. */ +/** A class binding takes injected fields. */ const CLASS_BLOCK_MEMBERS: Item[] = [...PARAMETERS, FIELD, LISTENER_KEYWORD]; -/** The block of a service task handed to an external worker by topic. */ +/** A topic binding takes properties and error mappings. */ const TOPIC_BLOCK_MEMBERS: Item[] = [ ...PARAMETERS, PROPERTY, @@ -418,7 +388,7 @@ const TOPIC_BLOCK_MEMBERS: Item[] = [ ERROR_MAPPING, ]; -/** The three ways a listener binds; also the whole listener parens. */ +/** The three ways a listener binds, and so its whole parens. */ const BINDINGS: Item[] = [ ['class', SETTING, 'class: "${1:com.example.Delegate}"'], ['expression', SETTING, 'expression: "${1:\\${bean.method(execution)}}"'], @@ -463,7 +433,6 @@ const SERVICE_PARENS: Item[] = [ ...ENGINE_SETTINGS, ]; -/** A repeated statement's parens offer the run keys after the engine ones. */ const REPEATED_SERVICE_PARENS: Item[] = [...SERVICE_PARENS, ...RUN_SETTINGS]; const CALL_PARENS: Item[] = [ @@ -510,7 +479,6 @@ const DECIDE_PARENS: Item[] = [ const CODE_BINDING: Item = ['code', SETTING, 'code: ${1:code}']; -/** A handler catching an error binds what the event carries. */ const HANDLER_PARENS: Item[] = [ CODE_BINDING, ['message', SETTING, 'message: ${1:message}'], @@ -519,14 +487,14 @@ const HANDLER_PARENS: Item[] = [ const ALONGSIDE: Item = ['alongside', 'BPMNscript flag', 'alongside']; -/** An escalation carries no message, and its handler may run alongside the host. */ +/** An escalation carries no message and may run alongside. */ const ESCALATION_HANDLER_PARENS: Item[] = [ CODE_BINDING, ...ENGINE_SETTINGS, ALONGSIDE, ]; -/** A declaration's fields carry the event's text, so both are quoted. */ +/** A declaration's fields carry text, so both are quoted. */ const DECLARATION_FIELDS: Item[] = [ ['code', SETTING, 'code: "${1:code}"'], ['message', SETTING, 'message: "${1:message}"'], @@ -534,7 +502,6 @@ const DECLARATION_FIELDS: Item[] = [ const SETTING_VALUE = 'BPMNscript setting value'; -/** The two built-in behaviours, quoted as the `type` setting reads them. */ const TYPE_VALUES: Item[] = [ ['"mail"', SETTING_VALUE, '"mail"'], ['"shell"', SETTING_VALUE, '"shell"'], @@ -705,8 +672,8 @@ describe('the completions offered at a caret', () => { ...STATEMENTS, ], ], - // `Decoy` (another process) and `Inner` (a nested subprocess body) are both - // named statements the scope must keep out. + // `Decoy` (another process) and `Inner` (a nested body) are named + // statements the scope keeps out. [ "the host position offers the container's activities and nothing from another scope", 'process p {\n user Review\n service Ship\n subprocess Sub {\n user Inner\n }\n on |\n}\nprocess q { user Decoy }', @@ -752,8 +719,6 @@ describe('the completions offered at a caret', () => { 'process p {\n service S(|)\n}', SERVICE_PARENS, ], - // Revert: drop `isRepeated` from `settingFormsFor` and the unrepeated - // service task above is offered the run keys. [ 'a repeated service task offers the run keys after the engine ones', 'process p {\n service S for 3(|)\n}', @@ -814,7 +779,6 @@ describe('the completions offered at a caret', () => { 'process p {\n user T {\n input x = 1\n |\n }\n}', BLOCK_MEMBERS, ], - // A closed listener must not capture the caret that follows it. [ 'a user block offers its whole member set after a closed listener', 'process p {\n user T {\n on create(class: "com.example.L")\n |\n }\n}', @@ -835,8 +799,7 @@ describe('the completions offered at a caret', () => { 'process p {\n service S(topic: "t") {\n property k = "v"\n |\n }\n}', TOPIC_BLOCK_MEMBERS, ], - // The same brace opens the body, and a member written there belongs to - // nothing, so the body position offers the statements alone. + // The same brace opens the body, so the body position offers no member. [ 'a subprocess body offers the statements and no member', 'process p {\n subprocess S {\n |\n }\n}', @@ -867,8 +830,8 @@ describe('the completions offered at a caret', () => { 'process p {\n user T {\n on create(class: "com.example.L") {\n |\n }\n }\n}', [FIELD], ], - // The engine refuses a mapping on an event sub-process and a boundary - // event carries none, so neither handler form offers a parameter. + // The engine refuses a parameter on an event sub-process and a boundary + // event carries none. [ 'a host-less handler block offers no parameter', 'process p {\n start S\n on error {\n |\n } {\n end Failed\n }\n}', @@ -921,8 +884,8 @@ describe('the completions offered at a caret', () => { ['any', KEYWORD, 'any'], ], ], - // Every identifier in an expression is a reference to a code declaration, - // so the scope is what keeps the declared codes out of this list. + // Every identifier in an expression is a reference to a code declaration; + // the scope is what keeps the declared codes out of this list. [ 'an expression position offers no code declaration, only the literal words', 'process p {\n error PAYMENT_DECLINED(message: "x")\n if (|) {\n user A\n }\n}', @@ -952,8 +915,7 @@ describe('the completions offered at a caret', () => { ], ], ], - // With nothing after the head word the mapping does not parse, so the - // slot is told by the word alone. + // With nothing after the head word the mapping does not parse. [ "a mapping's code slot offers the declared errors before the rest of the line is written", 'process p {\n error PAYMENT_DECLINED(message: "x")\n escalation PAYMENT_REVIEW\n service S(topic: "t") {\n error |\n }\n}', @@ -966,8 +928,7 @@ describe('the completions offered at a caret', () => { ], ], ], - // The caret's own node is the preceding member here, not the service - // task, so the owner has to be climbed rather than read off directly. + // The caret's node is the preceding member, not the service task. [ "a mapping's code slot offers the declared errors after a preceding member too", 'process p {\n error PAYMENT_DECLINED(message: "x")\n escalation PAYMENT_REVIEW\n service S(topic: "t") {\n property k = "v"\n error |\n }\n}', @@ -986,8 +947,7 @@ describe('the completions offered at a caret', () => { [], ], // A bare variable is the prefix of the expression the author goes on to - // write, so these rows pin the offer and not its validity; the two - // variable items sort ahead of the literal words in the editor. + // write, so these rows pin the offer and not its validity. [ 'a condition offers the declared variables beside the literal words', `process p {\n ${VARS}\n if (|) {\n user A\n }\n}`, @@ -998,7 +958,7 @@ describe('the completions offered at a caret', () => { `process p {\n ${VARS}\n if (am|) {\n user A\n }\n}`, [VARIABLES[0]!], ], - // The parameter's own name is a variable too, seeded after the declared ones. + // The parameter's own name is a variable too. [ "an io parameter's value offers the variables", `process p {\n ${VARS}\n user T {\n input total = |\n }\n}`, @@ -1028,8 +988,7 @@ describe('the completions offered at a caret', () => { `process p {\n ${VARS}\n call C(process: "q") {\n in x = |\n }\n}`, [...LITERALS, ...VARIABLES], ], - // An out mapping's source is read in the called process, whose variables - // this one does not know. + // An out mapping's source is read in the called process. [ "a call's out mapping offers no variable", `process p {\n ${VARS}\n call C(process: "q") {\n out x = |\n }\n}`, @@ -1062,10 +1021,8 @@ describe('the completions offered at a caret', () => { expected.map(([label, detail, insert]) => [label, detail, insert]), ); for (const [index, item] of items.entries()) { - // An item that inserts more than its own label has to say it is a - // snippet, or the editor writes the placeholders out literally. One that - // inserts its label carries the kind its row names, a keyword unless the - // row says otherwise. + // Without the snippet format the editor writes the placeholders out + // literally. const scaffolds = inserted(item) !== item.label; expect(item.insertTextFormat === InsertTextFormat.Snippet).toBe( scaffolds, @@ -1079,7 +1036,7 @@ describe('the completions offered at a caret', () => { }); }); -/** The rows of `items` that scaffold something, with the program they land in. */ +/** The rows of `items` that scaffold something, each with the program it lands in. */ function scaffolds( where: string, items: Item[], @@ -1090,11 +1047,7 @@ function scaffolds( .map(([label]) => [`\`${label}\` in ${where}`, label, program] as const); } -/** - * A setting the element it belongs to cannot do without. Each is offered - * alongside the rest, so a host that already writes one would collide with the - * row under test: they are checked in a host that writes none instead. - */ +/** A binding is checked in a host that writes none, since a second one would collide with it. */ const REQUIRED_BINDINGS = new Set([ 'class', 'expression', @@ -1110,26 +1063,18 @@ const binds = ([label]: Item) => REQUIRED_BINDINGS.has(label); /** A built-in behaviour needs its fields, so its host writes the mail ones. */ const bindsBuiltin = ([label]: Item) => label === 'type'; -/** - * A form reference and the binding pinning its version are legal only - * together, so each half is checked in a host writing the other one. - */ +/** A form reference and its version pin are legal only together, so each half is checked in a host writing the other. */ const FORM_REFERENCE_MODIFIERS = new Set(['binding', 'version']); const pinsAForm = ([label]: Item) => FORM_REFERENCE_MODIFIERS.has(label); const namesAForm = ([label]: Item) => label === 'formRef'; -/** The settings a decision step carries only beside `decision`, so their host binds one. */ +/** Legal only beside `decision`, so their host binds one. */ const DECISION_MODIFIERS = new Set(['binding', 'version', 'mapDecisionResult']); const pinsADecision = ([label]: Item) => DECISION_MODIFIERS.has(label); -/** - * The declaration a statement scaffold needs in the header. Every scaffold - * writes its code as the same placeholder name, and a code reaches only the - * declarations of its own kind, so which kind is declared follows the - * statement. - */ +/** Every scaffold writes the same placeholder code, and a code reaches only its own kind, so the declared kind follows the statement. */ const declarationNaming = (statement: string): string => statement.includes('escalation') ? 'escalation CODE' @@ -1172,7 +1117,7 @@ describe('a scaffold parses and validates once accepted', () => { USER_PARENS.filter(namesAForm), (setting) => `process p {\n user T(${setting}, binding: latest)\n}`, ), - // `taskPriority` rides a `topic` binding alone, so the host binds one. + // `taskPriority` rides a `topic` binding alone. ...scaffolds( 'the parens of a service task', SERVICE_PARENS.filter((item) => !binds(item)), @@ -1235,7 +1180,6 @@ describe('a scaffold parses and validates once accepted', () => { (member) => `process p {\n service S(class: "com.example.D") {\n${member}\n }\n}`, ), - // The code is declared in the header, where the mapping's scaffold resolves it. ...scaffolds( 'a topic-bound service block', [PROPERTY, ERROR_MAPPING], @@ -1269,7 +1213,6 @@ describe('a scaffold parses and validates once accepted', () => { CATCH_TRIGGERS, (trigger) => `process p {\n user U\n await ${trigger}\n}`, ), - // Each setting lands on a field of the one type it fits. ...FORM_FIELD_PARENS.map( ([label]) => [ @@ -1298,7 +1241,7 @@ describe('a scaffold parses and validates once accepted', () => { }); }); -/** Keyed by description, which is the noun phrase a host row spells as well. */ +/** Keyed by description, the noun phrase a host row spells too. */ const RULE_BY_DESCRIPTION = new Map( [...Object.values(ATTRIBUTE_BLOCK_RULES), ATTEMPT_BLOCK_RULE].map((rule) => [ rule.description, @@ -1315,7 +1258,7 @@ describe('an element offers exactly the settings it takes', () => { settings, 'asyncBefore: true, ', ); - // The fixture emits a signal, which carries no implementation binding. + // An emitted signal carries no implementation binding. expect(await labelsAt(text, line, character)).toEqual([ ...(kind === 'emit' ? [] : rule.own), ...ENGINE_KEYS, @@ -1328,19 +1271,14 @@ describe('an element offers exactly the settings it takes', () => { const labels = (items: readonly Item[]): string[] => items.map(([label]) => label); -/** - * The keywords whose grammar rule continues with a mandatory token: each is a - * prefix rather than an offer, so it cannot parse alone. - */ +/** Each continues with a mandatory token, so it cannot parse alone. */ const PREFIX_WORDS: ReadonlySet = new Set(['goto', 'form']); /** - * A row is a program with one slot, so the caret and the spliced item come - * from the same text. A parse error is forgiven for a bare {@link PREFIX_WORDS} - * item and no other. A bare event word is spliced with the name its rule - * requires. Warnings are the editor doing its job on an example value and do - * not count; an error does, and one row reports every refused item with the - * sentence the validator answered. + * One slot per program, so the caret and the spliced item come from the same + * text. A parse error is forgiven for a bare {@link PREFIX_WORDS} item alone, + * a bare event word is spliced with the name its rule requires, and one row + * reports every refused item with the sentence the validator answered. */ describe('offered is a subset of accepted', () => { test.each string, string[]]>([ @@ -1510,7 +1448,8 @@ describe('offered is a subset of accepted', () => { `process p {\n start S\n subprocess Sub {\n start Inner\n on ${x} {\n end Failed\n }\n }\n}`, [...labels(ON_TRIGGERS), 'compensation'], ], - // A host alone is the prefix of `on Host: trigger`, so the row completes it. + // A host alone is the prefix of `on Host: trigger`, so the row completes + // it. [ 'a host-less handler in a subprocess body offers the hosts of that body and none outside it', (x) => diff --git a/packages/language/test/helpers/block-hosts.ts b/packages/language/test/helpers/block-hosts.ts index 3db3c32..7d87b2e 100644 --- a/packages/language/test/helpers/block-hosts.ts +++ b/packages/language/test/helpers/block-hosts.ts @@ -6,7 +6,7 @@ * these rows, so a new kind is added in one place. */ -import { ENGINE_KEYS } from '@bpmn-script/language'; +import { ENGINE_KEYS, type EngineKey } from '@bpmn-script/language'; /** * A triple-backtick fence, assembled without a literal fence in the test source @@ -15,7 +15,7 @@ import { ENGINE_KEYS } from '@bpmn-script/language'; export const FENCE = '`' + '`' + '`'; /** A value each engine key takes, in the shape its lowering reads. */ -const VALID_ENGINE_VALUE: Readonly> = { +const VALID_ENGINE_VALUE: Readonly> = { asyncBefore: 'true', asyncAfter: 'true', exclusive: 'false', @@ -25,7 +25,7 @@ const VALID_ENGINE_VALUE: Readonly> = { /** `keys` as one parens' worth of items, each spelled through `keyOf`. */ export const engineItems = ( - keys: readonly string[], + keys: readonly EngineKey[], keyOf: (key: string) => string = (key) => key, ): string => keys.map((key) => `${keyOf(key)}: ${VALID_ENGINE_VALUE[key]}`).join(', '); diff --git a/packages/language/test/hover.test.ts b/packages/language/test/hover.test.ts index 8c9949f..149fc4b 100644 --- a/packages/language/test/hover.test.ts +++ b/packages/language/test/hover.test.ts @@ -1,13 +1,4 @@ -/** - * Hover content, driven through the real `HoverProvider` with - * `expectHover` from `langium/test`. - * - * Langium's default hover resolves the caret to a declaration through - * `References.findDeclarations` before `getAstNodeHoverContent` ever runs, so - * a `goto` target, a handler's host, and a thrown or caught code exercise the - * same content function as the name they resolve to, with no row for the - * position itself. - */ +/** Hover through the real `HoverProvider`, with `expectHover` from `langium/test`. */ import { describe, test, beforeAll } from 'vitest'; import { EmptyFileSystem } from 'langium'; @@ -20,7 +11,7 @@ beforeAll(() => { hover = expectHover(createBpmnScriptServices(EmptyFileSystem).BpmnScript); }); -/** A row of the table: a title, a program with one `<|>` caret, and the expected hover text (`undefined` for none). */ +/** A program with one `<|>` caret and the hover text there, `undefined` for none. */ type Row = readonly [title: string, text: string, expected: string | undefined]; const ROWS: Row[] = [ diff --git a/packages/language/test/navigation.test.ts b/packages/language/test/navigation.test.ts index 7b6ae56..85bb07d 100644 --- a/packages/language/test/navigation.test.ts +++ b/packages/language/test/navigation.test.ts @@ -1,14 +1,6 @@ /** - * Definition, references, rename and document highlight, driven through the - * real LSP providers on the shared services. - * - * Langium's four providers all resolve the caret through - * `References.findDeclarations` and walk the sites through - * `References.findReferences`, so one table per request is enough to pin the - * `References` override: a variable is keyed on the sites that declare it, - * everything else on the resolved cross-reference the default already handles. - * - * `langium/test` has no rename helper, so the rename rows drive + * Definition, references, rename and document highlight through the real LSP + * providers. `langium/test` has no rename helper, so the rename rows drive * `RenameProvider` by hand and assert the whole document after the edits: a * rename that touched the declaration alone cannot pass on the text. */ @@ -66,7 +58,6 @@ async function renamed(text: string, newName: string): Promise { return TextDocument.applyEdits(document.textDocument, edits); } -/** A row: a title, a program with one `<|>` caret, the new name, and the whole program afterwards. */ type RenameRow = readonly [ title: string, text: string, @@ -161,7 +152,7 @@ const RENAMES: RenameRow[] = [ ], ]; -/** A row: a title and a program with one `<|>` caret and the `<|...|>` ranges the request answers with. */ +/** A program with one `<|>` caret and the `<|...|>` ranges the request answers with. */ type Row = readonly [title: string, text: string]; const DEFINITIONS: Row[] = [ diff --git a/packages/language/test/parsing.test.ts b/packages/language/test/parsing.test.ts index 4d8027b..a1bde2e 100644 --- a/packages/language/test/parsing.test.ts +++ b/packages/language/test/parsing.test.ts @@ -1,12 +1,6 @@ /** - * Parsing test suite for the BPMNscript grammar, driven in isolation through - * Langium's `parseHelper`. - * - * Beyond the grammar surface these pin the edge cases Langium 4 makes easy to - * get wrong: keyword versus ID in expression position, parser-rule expressions, - * attribute-key versus identifier disambiguation, and duplicate attribute keys - * reaching the AST. Whether a duplicate key is an error is the validator - * suite's question, not this one's; where a `goto` may resolve to is the + * The grammar, driven through Langium's `parseHelper`. Whether a shape is + * legal is the validator suite's question; where a reference resolves is the * scoping suite's. */ @@ -32,12 +26,10 @@ import type { } from '@bpmn-script/language'; import { createBpmnScriptServices, - isModel, renderExpression, settingsOf, } from '@bpmn-script/language'; -/** Every statement the repeat clause attaches to. */ type Repeatable = | UserTask | ServiceTask @@ -58,11 +50,9 @@ beforeAll(() => { }); /** - * A parsed subtree written as `Type(prop=value, ...)`, with a cross-reference - * as `->target`. Only the properties the source actually set appear: a slot - * left empty, a flag left off, and an empty list are all dropped. One string - * therefore pins the filled slots, the empty ones, and any node the parser - * invented along the way. + * A parsed subtree as `Type(prop=value, ...)`, a cross-reference as `->target`. + * An empty slot, an unset flag and an empty list are dropped, so one string + * pins the filled slots, the empty ones, and any node the parser invented. */ function shape(value: unknown): string { if (Array.isArray(value)) { @@ -89,14 +79,12 @@ function shape(value: unknown): string { return JSON.stringify(value); } -/** Parse `source`, assert it parsed cleanly, and return the root `Model`. */ async function parseModel(source: string): Promise { const document = await parse(source); expect(formatParseFailure(document)).toBeUndefined(); return document.parseResult.value; } -/** Every lexer and parser error `source` produces, in the order raised. */ async function parseErrors(source: string): Promise { const { lexerErrors, parserErrors } = (await parse(source)).parseResult; return [...lexerErrors, ...parserErrors].map((e) => e.message); @@ -116,48 +104,19 @@ async function expectProcess(source: string, process: string) { expect(shape(model.processes[0]!)).toBe(process); } -/** - * Parse a bare expression by wrapping it as the condition of an `if`, then - * return the parsed condition AST node. - */ +/** The AST of `expr` parsed as the condition of an `if`. */ async function parseCondition(expr: string) { const model = await parseModel(`process p { if (${expr}) { user A } }`); return (model.processes[0]!.body[0] as IfStatement).condition; } -/** Parse `source` and return the statement at `index` in the first process. */ async function statementAt(source: string, index = 0): Promise { return (await parseModel(source)).processes[0]!.body[index] as T; } -/** A row of the shape tables: a title, a program, and the AST it parses to. */ type Row = readonly [title: string, source: string, expected: string]; describe('Parsing - process header', () => { - test('a realistic multi-construct process parses with zero lexer/parser errors', async () => { - const document = await parseModel( - ` -process invoice(label: "Invoice Approval") { - var amount: number - - start Begin - user Review(label: "Review invoice", assignee: "demo") - if (amount > 1000) { - user Senior(label: "Senior approval", assignee: "manager") - } else { - service Auto(label: "Auto-approve", class: com.example.invoice.AutoApproveDelegate) - } - while (rejected) { - user Fix(label: "Fix issues") - } - end Done -} -`.trim(), - ); - expect(isModel(document)).toBe(true); - expect(document.processes).toHaveLength(1); - }); - test.each([ [ 'the process id and its label land in their own slots', @@ -187,8 +146,6 @@ process invoice(label: "Invoice Approval") { }`, 'Process(name="p", decls=[VarDecl(name="a", type="string"), VarDecl(name="b", type="number"), VarDecl(name="c", type="boolean"), VarDecl(name="d", type="date"), VarDecl(name="e", type="json"), VarDecl(name="f", type="any")], body=[StartEvent(name="S")])', ], - // The header settings ride on the process head; only `var` and the code - // declarations sit inside the braces, and they interleave freely. [ 'a code declaration parses beside a var declaration in either order', `process p(versionTag: "1.4", label: "Lbl") { @@ -251,31 +208,11 @@ describe('Parsing - control flow and containers', () => { `process p { parallel { { if (a) { user A } else { user B } } { user C } } }`, '[ParallelStatement(branches=[ParallelBranch(body=Block(statements=[IfStatement(condition=VarRef(ref=->a), then=Block(statements=[UserTask(name="A")]), elseBlock=Block(statements=[UserTask(name="B")]))])), ParallelBranch(body=Block(statements=[UserTask(name="C")]))])]', ], - [ - 'a branch body holding only an end event parses', - `process p { parallel { { end A } { end B } } }`, - '[ParallelStatement(branches=[ParallelBranch(body=Block(statements=[EndEvent(name="A")])), ParallelBranch(body=Block(statements=[EndEvent(name="B")]))])]', - ], [ 'a labeled subprocess carries its body statements', `process p { subprocess Handle(label: "Handle order") { user Review(assignee: "demo") } }`, '[SubProcess(name="Handle", items=[Setting(key="label", value=LiteralString(value="Handle order"))], body=Block(statements=[UserTask(name="Review", items=[Setting(key="assignee", value=LiteralString(value="demo"))])]))]', ], - [ - 'explicit start/end inside a subprocess body parse as ordinary events', - `process p { subprocess S { start In user A end Out } }`, - '[SubProcess(name="S", body=Block(statements=[StartEvent(name="In"), UserTask(name="A"), EndEvent(name="Out")]))]', - ], - [ - 'a subprocess nests inside a subprocess', - `process p { subprocess Outer { subprocess Inner { user A } } }`, - '[SubProcess(name="Outer", body=Block(statements=[SubProcess(name="Inner", body=Block(statements=[UserTask(name="A")]))]))]', - ], - [ - 'a subprocess nests inside an if block', - `process p { if (a) { subprocess S { user A } } }`, - '[IfStatement(condition=VarRef(ref=->a), then=Block(statements=[SubProcess(name="S", body=Block(statements=[UserTask(name="A")]))]))]', - ], [ 'an empty subprocess body parses with no statements and no label', `process p { subprocess S { } }`, @@ -291,21 +228,6 @@ describe('Parsing - control flow and containers', () => { `process p { attempt A for each line in lines (label: "Book and pay", asyncBefore: true) { user U } }`, '[SubProcess(transactional=true, name="A", element="line", collection=VarRef(ref=->lines), items=[Setting(key="label", value=LiteralString(value="Book and pay")), Setting(key="asyncBefore", value=LiteralBool(value="true"))], body=Block(statements=[UserTask(name="U")]))]', ], - [ - 'an attempt nests in a subprocess body and in another attempt', - `process p { subprocess S { attempt A { attempt B { user U } } } }`, - '[SubProcess(name="S", body=Block(statements=[SubProcess(transactional=true, name="A", body=Block(statements=[SubProcess(transactional=true, name="B", body=Block(statements=[UserTask(name="U")]))]))]))]', - ], - [ - 'an attempt nests in a handler body', - `process p { on error(E) { attempt A { user U } } }`, - '[OnHandler(trigger="error", items=[ParenValue(value=VarRef(ref=->E))], body=Block(statements=[SubProcess(transactional=true, name="A", body=Block(statements=[UserTask(name="U")]))]))]', - ], - [ - 'the body after an attempt head parses as its own statements, in order', - `process p { attempt A { user U end E } }`, - '[SubProcess(transactional=true, name="A", body=Block(statements=[UserTask(name="U"), EndEvent(name="E")]))]', - ], [ 'a full call activity carries its attributes and every mapping shape', `process p { @@ -325,16 +247,6 @@ describe('Parsing - control flow and containers', () => { }`, '[CallActivity(name="Fulfilment", items=[Setting(key="label", value=LiteralString(value="Fulfil order")), Setting(key="process", value=LiteralString(value="fulfilment-process")), Setting(key="binding", value=VarRef(ref=->deployment)), Setting(key="businessKey", value=RawExpr(raw="${execution.processBusinessKey}"))], mappings=[VariableMapping(direction="in", all=true), VariableMapping(direction="in", target="orderId"), VariableMapping(direction="in", target="total", source=Additive(left=VarRef(ref=->amount), op="+", right=VarRef(ref=->tax))), VariableMapping(direction="in", local=true, target="vip", source=VarRef(ref=->vipFlag)), VariableMapping(direction="out", target="shipmentId"), VariableMapping(direction="out", target="shipped", source=VarRef(ref=->confirmed))])]', ], - [ - 'an integer attribute value is a LiteralInt', - `process p { call C(version: 3) }`, - '[CallActivity(name="C", items=[Setting(key="version", value=LiteralInt(value=3))])]', - ], - [ - 'a raw-template attribute value is a RawExpr', - `process p { call C(version: "\${v}") }`, - '[CallActivity(name="C", items=[Setting(key="version", value=RawExpr(raw="${v}"))])]', - ], [ 'a minimal call with only `process` parses and carries no mappings', `process p { call X(process: "p") }`, @@ -345,16 +257,6 @@ describe('Parsing - control flow and containers', () => { `process p { call X { } }`, '[CallActivity(name="X")]', ], - [ - 'a call nests inside an if block', - `process p { if (a) { call X(process: "p") } }`, - '[IfStatement(condition=VarRef(ref=->a), then=Block(statements=[CallActivity(name="X", items=[Setting(key="process", value=LiteralString(value="p"))])]))]', - ], - [ - 'a call nests inside a subprocess body', - `process p { subprocess S { call X(process: "p") } }`, - '[SubProcess(name="S", body=Block(statements=[CallActivity(name="X", items=[Setting(key="process", value=LiteralString(value="p"))])]))]', - ], [ 'a call carries variable mappings and parameters together', `process p { call C(process: "p") { in a input b = 1 } }`, @@ -366,8 +268,6 @@ describe('Parsing - control flow and containers', () => { }); describe('Parsing - gateway settings', () => { - // Revert: drop `SettingsParens?` from one of the five rules and its row is a - // parse error. test.each([ [ 'an if head carries the split settings and the join settings; an else if head carries none', @@ -482,8 +382,7 @@ describe('Parsing - the event layer', () => { '[UserTask(name="Review"), OnHandler(host=->Review, trigger="message", items=[ParenValue(value=LiteralString(value="Cancelled")), Flag(flag="alongside")], body=Block)]', ], // The host and the trigger are both bare IDs, so every trigger word is - // pinned on its own: the word before the colon is the host, the one after - // it the trigger. + // pinned on its own. ...( [ 'error', @@ -618,26 +517,6 @@ describe('Parsing - the event layer', () => { }`, '[StartEvent(name="S", trigger="timer", items=[ParenValue(value=LiteralString(value="PT1H"))]), UserTask(name="U"), EndEvent(name="E", trigger="terminate")]', ], - [ - 'an on handler nests inside a subprocess body', - `process p { subprocess S { on error(X) { } } }`, - '[SubProcess(name="S", body=Block(statements=[OnHandler(trigger="error", items=[ParenValue(value=VarRef(ref=->X))], body=Block)]))]', - ], - [ - 'an on handler nests inside another on handler body', - `process p { on error(X) { on escalation(Y) { } } }`, - '[OnHandler(trigger="error", items=[ParenValue(value=VarRef(ref=->X))], body=Block(statements=[OnHandler(trigger="escalation", items=[ParenValue(value=VarRef(ref=->Y))], body=Block)]))]', - ], - [ - 'throw and emit nest inside an if block', - `process p { if (a) { throw error(X) } else { emit escalation(Y) } }`, - '[IfStatement(condition=VarRef(ref=->a), then=Block(statements=[ThrowStatement(trigger="error", items=[ParenValue(value=VarRef(ref=->X))])]), elseBlock=Block(statements=[EmitStatement(trigger="escalation", items=[ParenValue(value=VarRef(ref=->Y))])]))]', - ], - [ - 'an explicit start as the first statement of an on body parses', - `process p { on error(X) { start In } }`, - '[OnHandler(trigger="error", items=[ParenValue(value=VarRef(ref=->X))], body=Block(statements=[StartEvent(name="In")]))]', - ], [ "an unknown trigger word on a handler parses; legality is the validator's job", `process p { on banana("X") { } }`, @@ -649,9 +528,6 @@ describe('Parsing - the event layer', () => { }); describe('Parsing - soft words stay plain identifiers', () => { - // Trigger words, timer particles, attribute keys and parameter directions - // all lex as plain `ID`, so each stays available as a variable name, a step - // name, and a bare identifier in expression position. test.each([ ...( [ @@ -771,9 +647,9 @@ describe('Parsing - expressions', () => { expect(shape(await parseCondition(expr))).toBe(expected); }); - // A raw template at the top is printed as read, opener included and escapes - // resolved; one nested under an operator is a single template spliced in as a - // parenthesised operand, or a composite left as written for the validator. + // A raw template at the top prints as read; under an operator a single + // template is spliced in as a parenthesised operand and a composite is left + // as written for the validator. test.each([ ['amount > 1000', '${amount > 1000}'], ['a == null', '${a == null}'], @@ -806,11 +682,6 @@ describe('Parsing - expressions', () => { describe('Parsing - element settings and member blocks', () => { test.each([ - [ - 'a user task attribute value is a LiteralString, not a RawExpr', - `process p { user T(label: "Review", assignee: "demo") }`, - '[UserTask(name="T", items=[Setting(key="label", value=LiteralString(value="Review")), Setting(key="assignee", value=LiteralString(value="demo"))])]', - ], [ 'a service class written as a dotted bareword is a VarRef, not a string', `process p { service A(class: com.example.invoice.AutoApproveDelegate) }`, @@ -849,11 +720,6 @@ describe('Parsing - element settings and member blocks', () => { `process p { start S { form { blob: whatever "Blob" } } }`, '[StartEvent(name="S", forms=[FormBlock(fields=[FormField(id="blob", type="whatever", label="Blob")])])]', ], - [ - '`process` is still accepted as the call attribute key', - `process p { call C(process: "x") }`, - '[CallActivity(name="C", items=[Setting(key="process", value=LiteralString(value="x"))])]', - ], [ "an unknown attribute key parses; key legality is the validator's job", `process p { user T(wibble: 1) }`, @@ -864,21 +730,6 @@ describe('Parsing - element settings and member blocks', () => { `process p { call C(process: "x", binding: version) }`, '[CallActivity(name="C", items=[Setting(key="process", value=LiteralString(value="x")), Setting(key="binding", value=VarRef(ref=->version))])]', ], - [ - 'an end event carries settings', - `process p { end E(asyncBefore: true) }`, - '[EndEvent(name="E", items=[Setting(key="asyncBefore", value=LiteralBool(value="true"))])]', - ], - [ - 'a handler carries its settings before its body', - `process p { on error("E", asyncBefore: true) { user U } }`, - '[OnHandler(trigger="error", items=[ParenValue(value=LiteralString(value="E")), Setting(key="asyncBefore", value=LiteralBool(value="true"))], body=Block(statements=[UserTask(name="U")]))]', - ], - [ - 'a hosted alongside handler carries its settings before its body', - `process p { user R on R: timer("PT1H", asyncBefore: true, alongside) { user U } }`, - '[UserTask(name="R"), OnHandler(host=->R, trigger="timer", items=[ParenValue(value=LiteralString(value="PT1H")), Setting(key="asyncBefore", value=LiteralBool(value="true")), Flag(flag="alongside")], body=Block(statements=[UserTask(name="U")]))]', - ], [ 'throw, emit and await each carry trailing settings', `process p { @@ -898,11 +749,6 @@ describe('Parsing - element settings and member blocks', () => { `process p { await message subprocess S { user A } }`, '[IntermediateCatchEvent(trigger="message"), SubProcess(name="S", body=Block(statements=[UserTask(name="A")]))]', ], - [ - 'a task carries directed, named parameter entries', - `process p { user T { input a = 1 output b = "x" } }`, - '[UserTask(name="T", params=[IoParameter(direction="input", name="a", value=LiteralInt(value=1)), IoParameter(direction="output", name="b", value=LiteralString(value="x"))])]', - ], [ 'a list value keeps its items in order', `process p { service S { input a = [1, 2] } }`, @@ -937,11 +783,6 @@ ${FENCE} } }`, '[ServiceTask(name="S", params=[IoParameter(direction="input", name="a", value=ScriptLiteral(body="```groovy\\n1 + 1\\n```"))])]', ], - [ - 'a task listener binds by class', - `process p { user T { on create(class: "com.acme.L") } }`, - '[UserTask(name="T", listeners=[Listener(event="create", items=[Setting(key="class", value=LiteralString(value="com.acme.L"))])])]', - ], [ 'the start and end statement keywords are usable as listener events', `process p { service S { on start(class: "A") on end(expression: "\${b.m()}") } }`, @@ -1000,7 +841,6 @@ ${FENCE} await expectBody(source, expected); }); - // A lone block is the body, never the members. test.each<[tail: string, forms: number, bodyTypes: string[]]>([ ['{ form { } } { user U(assignee: "demo") }', 1, ['UserTask']], ['{ } { user U }', 0, ['UserTask']], @@ -1019,9 +859,8 @@ ${FENCE} }); describe("Parsing - an external task's extras", () => { - // `error when ` is `ID ID ID Expr`, told from a - // parameter's `ID ID '='` at the third token; neither word is reserved, so - // the second row keeps a misspelt `when` for the validator to report. + // Neither word of `error when ` is reserved, so a + // misspelt `when` parses for the validator to report. test.each([ [ 'an external task takes a priority, property lines and error mappings among its members', @@ -1052,41 +891,11 @@ describe('Parsing - task kinds, service bindings and fenced scripts', () => { ] as const; test.each([ - ...KINDS.map(([keyword, type]): Row => [ - `\`${keyword} X\` parses with a name and no label`, - `process p { ${keyword} X }`, - `[${type}(name="X")]`, - ]), - ...KINDS.map(([keyword, type]): Row => [ - `\`${keyword} X(label: "Label")\` carries the label as a setting`, - `process p { ${keyword} X(label: "Label") }`, - `[${type}(name="X", items=[Setting(key="label", value=LiteralString(value="Label"))])]`, - ]), ...KINDS.map(([keyword, type]): Row => [ `\`${keyword}\` after \`start S\` opens its own statement rather than filling the start's trigger slot`, `process p { start S ${keyword} X user U end E }`, `[StartEvent(name="S"), ${type}(name="X"), UserTask(name="U"), EndEvent(name="E")]`, ]), - [ - 'a decide task carries both of its attribute keys in order', - `process p { decide D(label: "Rate", decision: "riskRating", binding: latest) }`, - '[BusinessRuleTask(name="D", items=[Setting(key="label", value=LiteralString(value="Rate")), Setting(key="decision", value=LiteralString(value="riskRating")), Setting(key="binding", value=VarRef(ref=->latest))])]', - ], - [ - 'an expression binding parses; the value is a raw template', - `process p { service S(expression: "\${bean.method(execution)}") }`, - '[ServiceTask(name="S", items=[Setting(key="expression", value=RawExpr(raw="${bean.method(execution)}"))])]', - ], - [ - 'a delegate binding parses; the value is a raw template', - `process p { service S(delegate: "\${beanName}") }`, - '[ServiceTask(name="S", items=[Setting(key="delegate", value=RawExpr(raw="${beanName}"))])]', - ], - [ - 'a topic binding parses with a label', - `process p { service ship(label: "Ship it", topic: "shipping") }`, - '[ServiceTask(name="ship", items=[Setting(key="label", value=LiteralString(value="Ship it")), Setting(key="topic", value=LiteralString(value="shipping"))])]', - ], [ 'a topic binding parses without a label', `process p { service ship(topic: "shipping") }`, @@ -1101,17 +910,6 @@ ${FENCE} }`, '[ScriptTask(name="total", body="```js\\nx = 1\\n```")]', ], - [ - 'a script task carries its settings before its fence', - `process p { - script S(resultVariable: "total") ${FENCE}js -x = 1 -${FENCE} -}`, - '[ScriptTask(name="S", items=[Setting(key="resultVariable", value=LiteralString(value="total"))], body="```js\\nx = 1\\n```")]', - ], - // The backtick fence is a fresh delimiter with no overlap against the - // quote-delimited STRING / RAW_TEMPLATE terminals. [ 'a fence coexists with a class bareword and a raw template without lex ambiguity', `process p { @@ -1183,13 +981,11 @@ describe('Parsing - repeat clause', () => { await expectBody(source, expected); }); - // Every row carries settings, so both tests below see the clause survive - // them. `script` and `subprocess` are the two rules where the clause sits - // between the name and a further brace-opened body: the only shape a - // lookahead ambiguity could surface in. + // Every row carries settings, so the clause has to survive them; `script` + // and `subprocess` are the two rules where a further brace-opened body + // follows, the only shape a lookahead ambiguity could surface in. const SET = '(asyncBefore: true)'; - /** Every statement the clause attaches to, with whatever body follows it. */ const REPEATABLE: Array< [keyword: string, type: string, program: (clause: string) => string] > = [ @@ -1227,23 +1023,9 @@ describe('Parsing - repeat clause', () => { expect(settingsOf(task.items).map((a) => a.key)).toEqual(['asyncBefore']); }, ); - - test.each(REPEATABLE)( - '`%s` without the clause leaves every slot unset', - async (_keyword, type, program) => { - const task = await statementAt(program('')); - expect(task.$type).toBe(type); - expect(task.cardinality).toBeUndefined(); - expect(task.element).toBeUndefined(); - expect(task.collection).toBeUndefined(); - expect(task.sequential).toBe(false); - expect(task.completion).toBeUndefined(); - expect(settingsOf(task.items).map((a) => a.key)).toEqual(['asyncBefore']); - }, - ); }); -/** Chevrotain's own list-of-alternatives wording, which carries no guidance. */ +/** Chevrotain's list-of-alternatives wording, which carries no guidance. */ const STOCK_ALTERNATIVES = ''; const reservedWord = (word: string) => @@ -1270,9 +1052,8 @@ const VAR_PLACEMENT_GUIDANCE = 'the process, with the other declarations. Move it above the first ' + 'statement.'; -// Each row pins the whole error list, so it also proves that no further -// complaint follows and that the guidance replaced the raw wording rather than -// joining it. +// The whole error list, so a row also proves the guidance replaced the stock +// wording rather than joining it. describe('Parsing - sources the parser rejects', () => { test.each([ [ @@ -1280,9 +1061,8 @@ describe('Parsing - sources the parser rejects', () => { `process p { parallel { { user A } } }`, [STOCK_ALTERNATIVES, 'Expecting end of file but found `}`.'], ], - // Revert: give `ElseIf` or `ParallelBranch` a `SettingsParens?` and its - // row parses. The `else if` row blames `if`: with the parens breaking the - // `else if` shape, the lookahead settles on `else` opening a plain block. + // The `else if` row blames `if`: with the parens breaking the `else if` + // shape, the lookahead settles on `else` opening a plain block. [ 'an else if head takes no settings: the if head governs the whole chain', `process p { if (a) { user A } else if (b) (asyncBefore: true) { user B } }`, @@ -1356,9 +1136,6 @@ describe('Parsing - sources the parser rejects', () => { `process p { usr }`, [notAStepKeyword('usr')], ], - // The guidance reads the word already consumed as the declaration kind, so - // a word that really is one has to be left out of it: there the mistake is - // the text where the name belongs, and Chevrotain already names that. [ 'a declaration kind followed by text blames the name slot, not the kind word', `process p { error "PF" }`, @@ -1397,7 +1174,6 @@ describe('Parsing - sources the parser rejects', () => { 'process p {\n var amount: while\n start S\n}', [notATypeWord('while'), "Expecting token of type '(' but found `start`."], ], - // The slot takes any word, so it gets the guidance every `ID` slot gives. [ 'a reserved word in the type slot of a form field gets the reserved-word guidance', 'process p {\n start S\n user U { form { amount: while } }\n}', @@ -1412,8 +1188,6 @@ describe('Parsing - sources the parser rejects', () => { 'process p {\n var amount: text\n start S\n}', [notATypeWord('text'), reservedWord('start')], ], - // Nothing about a non-word token is a "word this position takes", so the - // guidance stands aside and Chevrotain's own message survives. [ 'a quoted string in the type slot keeps the stock message', 'process p {\n var amount: "text"\n start S\n}', @@ -1429,7 +1203,6 @@ describe('Parsing - sources the parser rejects', () => { }); }); -/** A statement carrying a label, as the head before it and the tail after it. */ type LabelCarrier = readonly [keyword: string, head: string, tail: string]; const LABEL_CARRIERS: readonly LabelCarrier[] = [ @@ -1448,13 +1221,12 @@ const LABEL_CARRIERS: readonly LabelCarrier[] = [ ]; /** - * Every shape the grammar once admitted beside the parenthesised settings, - * paired with the spelling that replaced it. A row asserts the whole combined - * lexer-and-parser error list: non-empty for the shape that is gone, empty for - * the one that stands. + * A slot has one spelling: a row pairs a shape the grammar refuses with the + * one it takes, and asserts the combined lexer-and-parser error list is + * non-empty for the first and empty for the second. */ -describe('Parsing - settings are written one way', () => { - test.each([ +describe('Parsing - a slot has one spelling', () => { + test.each([ ...LABEL_CARRIERS.map( ([keyword, head, tail]) => [ @@ -1493,19 +1265,6 @@ describe('Parsing - settings are written one way', () => { `process p { error "PF" message "Payment failed" start S }`, `process p { error PF(message: "Payment failed") start S }`, ], - ])('%s', async (_title, gone, kept) => { - expect(await parseErrors(gone)).not.toEqual([]); - expect(await parseErrors(kept)).toEqual([]); - }); -}); - -/** - * A trigger's payload and the non-interrupting flag are parenthesised items - * like every other, so the word-after-the-trigger spelling of each is no - * longer a shape the parser knows. One row per slot that carried one. - */ -describe('Parsing - a trigger payload is written one way', () => { - test.each([ [ 'a start names its message in the parens', `process p { start S message "M" }`, @@ -1561,8 +1320,8 @@ describe('Parsing - a trigger payload is written one way', () => { `process p { await { message "M" { user A } timer after "PT1H" { user B } } }`, `process p { await { message("M") { user A } timer("PT1H") { user B } } }`, ], - ])('%s', async (_title, gone, kept) => { - expect(await parseErrors(gone)).not.toEqual([]); - expect(await parseErrors(kept)).toEqual([]); + ])('%s', async (_title, refused, taken) => { + expect(await parseErrors(refused)).not.toEqual([]); + expect(await parseErrors(taken)).toEqual([]); }); }); diff --git a/packages/language/test/recovery-fuzz.test.ts b/packages/language/test/recovery-fuzz.test.ts index 42d6f1b..03a8ef3 100644 --- a/packages/language/test/recovery-fuzz.test.ts +++ b/packages/language/test/recovery-fuzz.test.ts @@ -1,19 +1,13 @@ /** * Validation stays total on a broken document: no check crashes, and none - * reports an element by a name the parser never filled in. - * - * Hand-written tables kept missing sites because each varied one axis: a - * keyword in an identifier slot reaches different empty slots than a deleted - * token, and neither reaches a slot whose reader only runs when something else - * in the program refers to it. So the corpus is mutated mechanically instead: - * tokenized by the language's own lexer, with a vocabulary read out of the - * grammar, then substituted, duplicated and deleted at a fixed seed. - * - * `MUTANTS` is sized to keep this a second or two, and `TIMEOUT_MS` scales - * with it so the knob is the only limit; changing `SEED` explores elsewhere. - * 50_000 runs in about twenty seconds. Past roughly 150_000 the documents the - * validation helper retains exhaust the default heap and the worker dies, so a - * wider run wants `NODE_OPTIONS=--max-old-space-size=8192`. + * reports an element by a name the parser never filled in. A hand-written + * table varies one axis per row (a keyword in an identifier slot reaches + * different empty slots than a deleted token), so the corpus is mutated + * mechanically instead: tokenized by the language's own lexer, then + * substituted, duplicated and deleted at a fixed seed. Changing `SEED` + * explores elsewhere; past roughly 150_000 mutants the documents the + * validation helper retains exhaust the default heap, so a wider run wants + * `NODE_OPTIONS=--max-old-space-size=8192`. */ import { beforeAll, describe, expect, test } from 'vitest'; @@ -28,29 +22,22 @@ const SEED = 0x5eed; const MUTANTS = 2000; /** - * A mutant costs about 0.5ms on an idle machine, but a first run on a cold - * cache and a contended CPU has been seen at fifteen times that. The budget is - * therefore sized against the contended figure, not the warm one, and is only - * meant to catch a runaway: a raised `MUTANTS` or validation that stopped being - * linear in the input. + * A mutant costs about 0.5ms warm and fifteen times that on a cold, contended + * CPU; the budget only catches a runaway. */ const TIMEOUT_MS = MUTANTS * 30; const FENCE = '`' + '`' + '`'; /** - * Well-formed sources covering every construct a check reads a slot off. A - * construct alone is not enough where the reader is a second construct - * referring to the first: a handler's trigger and a subprocess's name are only - * printed when a `goto` crosses the boundary they label, and a form field's - * type only when a `var` of the same name disagrees, so the corpus carries - * those pairs rather than the halves. - * - * A duplicate walk is the one reader a near-collision cannot reach, because a - * substitution draws from the grammar's own vocabulary and so can never - * reproduce an identifier the source already spells. The last entry therefore - * collides outright, on all three process-scoped duplicates at once, and its - * mutants reach those walks with one half of each colliding pair torn up. + * Well-formed sources covering every construct a check reads a slot off. Where + * the reader is a second construct referring to the first (a handler's trigger + * is only printed when a `goto` crosses its boundary, a field's type only when + * a `var` of the same name disagrees), the corpus carries the pair. A + * substitution draws from the grammar's vocabulary and never reproduces an + * identifier the source spells, so the last entry collides outright on all + * three process-scoped duplicates, and its mutants reach the duplicate walks + * with one half of each pair torn up. */ const CORPUS = [ `process p { error E(code: "c", message: "m") start S throw error(E) }`, @@ -75,11 +62,7 @@ const CORPUS = [ `process p(label: "x", label: "y") { var a: number var a: number start S user U user U end E }`, ]; -/** - * Park-Miller, deterministic so a failure names a mutant that can be replayed, - * and free of the bitwise operators the lint config bans. The seed must be a - * positive integer below the modulus. - */ +/** Park-Miller, so a failure names a mutant that replays, and free of the bitwise operators the lint config bans. */ function seededRandom(seed: number): () => number { const modulus = 2147483647; let state = seed % modulus; @@ -98,9 +81,8 @@ beforeAll(() => { validate = validationHelper(services); tokenize = (text) => services.parser.Lexer.tokenize(text).tokens.map((t) => t.image); - // Every keyword the grammar declares, so a new one joins the fuzz without - // anyone remembering to add it. The three terminal samples cannot be read - // out of a regular expression, so they are the one hand-written part. + // Every keyword the grammar declares; the three terminal samples cannot be + // read out of a regular expression, so they are the one hand-written part. const keywords = new Set(['name', '"s"', '1']); for (const node of AstUtils.streamAllContents(services.Grammar)) { if (GrammarAST.isKeyword(node)) keywords.add(node.value); diff --git a/packages/language/test/scoping.test.ts b/packages/language/test/scoping.test.ts index 75b96e3..fe4558a 100644 --- a/packages/language/test/scoping.test.ts +++ b/packages/language/test/scoping.test.ts @@ -1,34 +1,12 @@ /** - * Scoping and reserved-word guidance, driven through the real parser and linker - * (`parseHelper`, with `{ validation: true }` where linking must run). - * - * A `goto` resolves only within its nearest enclosing container, the `process`, - * `subprocess`, or `on` handler body it directly sits in, and a `subprocess` - * statement is itself a target by name. A cross-boundary `goto` fails to - * resolve, and the custom linker replaces Langium's stock resolution failure - * with a boundary explanation where the name exists elsewhere in the process, - * or with a plain "no step named" message where it exists nowhere at all. A - * handler has no name of its own, so it is named in a boundary message by its - * header. - * - * A hosted handler lowers inline into its host's container rather than into one - * of its own, which makes its body transparent to the container walk: a `goto` - * crosses between the handler body and the main flow in both directions, while - * a host-less handler nested inside it stays a boundary of its own. - * - * Each row of the resolution tables carries one program and the whole oracle - * {@link resolutionsOf} reads off it: every `goto` target and every handler - * host in document order, then every error-severity diagnostic. Asserting the - * complete list pins what each reference reaches, the exact boundary wording, - * and that nothing stacks a second diagnostic on a replaced one. Diagnostic - * severity follows the LSP convention: `1 = Error`, `2 = Warning`. - * - * A code reference is the third kind and asks the same question the other way - * round: every identifier in every expression is one, so what separates a code - * from a variable is the scope it is resolved in. {@link codeResolutionsOf} - * therefore reads every identifier and every diagnostic of either severity, so - * a scope reaching further than a code position shows up as a resolved - * identifier where a warning belongs. + * The scope provider and the linker, driven through `parseHelper` with + * `{ validation: true }` so linking runs. Each row is one program and the + * whole oracle read off it: every reference in document order with what it + * reaches, then every diagnostic, so a row pins the target, the exact boundary + * wording, and that nothing stacks a second diagnostic on a replaced one. + * {@link codeResolutionsOf} lists every identifier and warnings too, since a + * scope reaching past a code position shows up as a resolved identifier where + * the undeclared-variable warning belongs. */ import { beforeAll, describe, expect, test } from 'vitest'; @@ -66,17 +44,13 @@ beforeAll(() => { parse = parseHelper(services.BpmnScript); }); -/** A row of the resolution tables: a title, a program, and its whole oracle. */ type Row = readonly [ title: string, source: string, expected: readonly string[], ]; -/** - * The handler as its source header reads, since a handler has no name to be - * identified by: `on : ()`, each part where written. - */ +/** `on : ()`, each part where written, since a handler has no name. */ function headerOf(handler: OnHandler): string { const head = handler.host ? `on ${handler.host.$refText}: ${handler.trigger}` @@ -85,11 +59,7 @@ function headerOf(handler: OnHandler): string { return code ? `${head}(${code})` : head; } -/** - * A resolved node as `container/.../name:Type`, naming every flow container it - * sits in. The path is what distinguishes two same-named steps in different - * processes or containers, which is the whole question these tables ask. - */ +/** `container/.../name:Type`, so two same-named steps in different containers read apart. */ function pathOf(node: AstNode): string { const segments: string[] = []; for (let n: AstNode | undefined = node.$container; n; n = n.$container) { @@ -107,11 +77,7 @@ function targetOf(reference: Reference): string { return reference.ref ? pathOf(reference.ref) : 'unresolved'; } -/** - * Every `goto` target and handler host of `source` in document order, then - * every error-severity diagnostic. Warnings are left out: nothing here raises - * one, and an unrelated validator's warning would say nothing about scoping. - */ +/** Every `goto` target and handler host in document order, then every error. */ async function resolutionsOf(source: string): Promise { const document = await parse(source, { validation: true }); expect(document.parseResult.parserErrors.map((e) => e.message)).toEqual([]); @@ -132,7 +98,6 @@ async function resolutionsOf(source: string): Promise { return lines; } -/** The body every resolution table runs. */ async function checkRow( _title: string, source: string, @@ -141,13 +106,7 @@ async function checkRow( expect(await resolutionsOf(source)).toEqual(expected); } -/** - * Every identifier of `source` in document order with what it resolves to, - * then every diagnostic of either severity. Warnings are in because the - * undeclared-variable one is what an identifier outside a code position has to - * keep producing. A mapping's code slot is the one reference that is not an - * identifier in an expression, and reads as `error E -> ...`. - */ +/** Every identifier and mapping code slot (`error E -> ...`) in document order, then every diagnostic of either severity. */ async function codeResolutionsOf(source: string): Promise { const document = await parse(source, { validation: true }); expect(document.parseResult.parserErrors.map((e) => e.message)).toEqual([]); @@ -170,7 +129,6 @@ async function codeResolutionsOf(source: string): Promise { return lines; } -/** The body the code-resolution table runs. */ async function checkCodeRow( _title: string, source: string, @@ -179,7 +137,6 @@ async function checkCodeRow( expect(await codeResolutionsOf(source)).toEqual(expected); } -/** The trailing sentence of a boundary explanation, by the boundary crossed. */ const BOUNDARY_RULE = { subprocess: 'a goto cannot cross a subprocess boundary.', handler: @@ -187,7 +144,6 @@ const BOUNDARY_RULE = { host: 'a boundary event attaches to an activity in its own scope.', } as const; -/** The linker's replacement for the stock unresolved-reference message. */ function boundary( name: string, where: string, @@ -196,17 +152,15 @@ function boundary( return `error: '${name}' is ${where}; ${BOUNDARY_RULE[rule]}`; } -/** A `goto` naming a step nowhere in the process. */ function missingStep(name: string): string { return `error: No step named '${name}' in this process.`; } -/** A handler's host naming a step nowhere in the process. */ function missingHost(name: string): string { return `error: No step named '${name}' in this process to attach to.`; } -/** Raised on every two-process fixture below, which needs both to ask its question. */ +/** Every two-process fixture draws this; both processes are what the row needs. */ const MULTI_PROCESS = 'error: Only one process is supported per file. Move additional processes into separate files.'; @@ -289,11 +243,6 @@ describe('Scoping - container-scoped goto (subprocess boundary)', () => { boundary('Deep', `inside subprocess 'Inner'`, 'subprocess'), ], ], - [ - 'a goto to a name nowhere in the process says so', - `process p { subprocess Sub { user Inner } goto Missing }`, - ['goto Missing -> unresolved', missingStep('Missing')], - ], ])('%s', checkRow); }); @@ -365,11 +314,6 @@ describe('Scoping - container-scoped goto (event-handler boundary)', () => { ), ], ], - [ - 'a goto to a name nowhere in the process says so', - `process p { error PAYMENT_FAILED goto Missing on error(PAYMENT_FAILED) { user Inner } }`, - ['goto Missing -> unresolved', missingStep('Missing')], - ], [ 'an `on timer` handler is named by its code-less header', `process p { goto Inner on timer(at: "2026-08-01T09:00:00") { user Inner } }`, @@ -597,8 +541,7 @@ describe('Scoping - code declarations reached from a code position', () => { }); describe('Scoping - the code slot of an error mapping', () => { - // Raw conditions, so no undeclared-variable warning rides along; the - // validator's own rules for a mapping are not what these rows pin. + // Raw conditions, so no undeclared-variable warning rides along. test.each([ [ 'a mapping naming a declared error resolves it, with nothing to report', @@ -632,36 +575,3 @@ describe('Scoping - the code slot of an error mapping', () => { ], ])('%s', checkCodeRow); }); - -describe('Scoping - reserved-word guidance', () => { - /** - * A reserved word reaches the guidance down two Chevrotain paths: a - * no-viable-alternative error in expression position, and a mismatched-token - * error where the grammar expects exactly `ID`. - */ - const RESERVED_DATE = - "'date' is a reserved word and cannot be used as a plain name here. To refer to a variable named 'date', write it as a quoted raw expression: \"${date}\"."; - - test.each([ - [ - 'a reserved word in expression position points to the raw-string fallback', - `process p { if (date > deadline) { user A } }`, - [RESERVED_DATE], - ], - [ - 'a reserved word in a name position points to the raw-string fallback', - `process p { user date }`, - [RESERVED_DATE], - ], - [ - 'a plain identifier in the same expression position parses cleanly', - `process p { if (status > deadline) { user A } }`, - [], - ], - ])('%s', async (_title, source, expected) => { - const document = await parse(source); - expect(document.parseResult.parserErrors.map((e) => e.message)).toEqual( - expected, - ); - }); -}); diff --git a/packages/language/test/semantic-tokens.test.ts b/packages/language/test/semantic-tokens.test.ts index f86e5a5..1b2a58d 100644 --- a/packages/language/test/semantic-tokens.test.ts +++ b/packages/language/test/semantic-tokens.test.ts @@ -1,13 +1,7 @@ /** - * Semantic-token test suite for the soft event words. - * - * `error`, `escalation`, `code`, and `message` lex as plain `ID` under the - * grammar's soft-word convention, so the generated TextMate grammar (which - * knows only the real keywords `on`, `throw`, `emit`, `alongside`) gives them - * no highlighting. These tests drive `BpmnScriptSemanticTokenProvider` through - * the real LSP semantic-tokens request and assert it marks a soft word with the - * `keyword` token type exactly where it carries event meaning: `var message: - * string` and a plain `code` variable reference stay untouched. + * `BpmnScriptSemanticTokenProvider` through the real semantic-tokens request: + * a soft word is a `keyword` exactly where it carries meaning, and plain + * where it names a variable or a step. */ import { beforeAll, describe, expect, test } from 'vitest'; @@ -48,20 +42,18 @@ function markedTokenTypes( }); } -/** A row: a title, a process body, and the token type of every marked range. */ type Row = readonly [title: string, body: string, expected: readonly string[]]; -/** Soft words whose meaning is positional: as a variable name they are plain. */ const SOFT_WORDS_AS_VAR_NAME = [ 'at', 'priority', 'message', 'compensation', 'after', + 'terminate', ]; -/** The same, read in an expression rather than declared. */ -const SOFT_WORDS_AS_OPERAND = ['priority', 'code', 'compensation']; +const SOFT_WORDS_AS_OPERAND = ['priority', 'code', 'compensation', 'after']; describe('Semantic tokens - soft event words', () => { test.each([ @@ -75,31 +67,16 @@ describe('Semantic tokens - soft event words', () => { 'throw <|escalation|>("C")', [KEYWORD], ], - [ - 'the trigger word of an `emit` is a keyword', - 'emit <|escalation|>("C")', - [KEYWORD], - ], [ 'a catch binding highlights its field word, not the variable it introduces', 'on error("X", <|code|>: <|c|>) { }', [KEYWORD, PLAIN], ], - [ - 'a message handler highlights its trigger word', - 'on <|message|>("X") { }', - [KEYWORD], - ], [ 'a condition handler highlights its trigger word, not the variable it tests', 'on <|condition|> (<|amount|> > 100) { }', [KEYWORD, PLAIN], ], - [ - 'a timer particle in an expression is a plain operand', - 'var after: number\n if (<|after|> > 2) {\n end Done\n }', - [PLAIN], - ], [ 'an awaited event highlights its trigger word, not the time it carries', 'await <|timer|>("<|PT1H|>")', @@ -128,11 +105,6 @@ describe('Semantic tokens - soft event words', () => { 'start <|S|>\n end <|E|>', [PLAIN, PLAIN], ], - [ - 'a variable named after a trigger word stays plain where the word is also used as one', - 'var <|terminate|>: string\n end Done terminate', - [PLAIN], - ], [ 'a declaration highlights both its kind and the key of its message', '<|error|> X(<|message|>: "m")', @@ -143,16 +115,6 @@ describe('Semantic tokens - soft event words', () => { '<|escalation|> <|MANUAL_REVIEW|>', [KEYWORD, PLAIN], ], - [ - 'a compensation handler highlights its trigger word', - 'on <|compensation|> { }', - [KEYWORD], - ], - [ - 'a compensation throw highlights its trigger word', - 'throw <|compensation|>', - [KEYWORD], - ], [ 'a compensation emit highlights its trigger word, not the activity it names', 'emit <|compensation|> <|Undo|>', diff --git a/packages/language/test/symbols.test.ts b/packages/language/test/symbols.test.ts index bd8e6f0..58ccb37 100644 --- a/packages/language/test/symbols.test.ts +++ b/packages/language/test/symbols.test.ts @@ -1,12 +1,7 @@ /** - * The document outline, driven through the real `DocumentSymbolProvider` with - * `expectSymbols` from `langium/test`, over the frozen - * `boundary-events.bpmnscript` golden. - * - * The whole tree is flattened to one `Kind name` line per symbol, indented by - * depth, and asserted as a single list: an `on` handler with no children of - * its own would otherwise be invisible (its steps would float to the level - * above), which is exactly the regression this pins. + * The outline of the `boundary-events.bpmnscript` golden, flattened to one + * `Kind name` line per symbol and indented by depth, so a handler whose steps + * floated up to the process would show. */ import { readFileSync } from 'node:fs'; @@ -29,11 +24,8 @@ function flatten(symbols: DocumentSymbol[], depth: number): string[] { ]); } -// Generated once against `boundary-events.bpmnscript` and read against the -// golden's narrative before being pinned: three code declarations, every -// named statement kinded and, per handler, one line for its own header with -// its steps nested under it. `BookCarrier`'s `in`/`out` mapping lines are -// absent, a mapping having no name to be a symbol by. +// `BookCarrier`'s `in`/`out` mapping lines are absent, a mapping having no +// name to be a symbol by. const EXPECTED = [ 'Module parcel-dispatch', ' Constant ADDRESS_REJECTED', diff --git a/packages/language/test/validating.test.ts b/packages/language/test/validating.test.ts index de5135a..8d12c25 100644 --- a/packages/language/test/validating.test.ts +++ b/packages/language/test/validating.test.ts @@ -2,23 +2,156 @@ * Validation of the BPMNscript AST. * * A case is one source program and the complete list of diagnostics it raises, - * in document order, so it fails both when a check stops firing and when one - * fires that should not. A warning carries a `warning:` prefix; everything else - * is an error, whether it comes from a check, the linker, or the parser. - * - * Message prose is spelled once in the catalogue below and referenced from the - * cases, so a reworded diagnostic is one edit. + * in the order they are reported, so it fails both when a check stops firing + * and when one fires that should not. A warning carries a `warning:` prefix; + * everything else is an error, whether from a check, the linker, or the parser. */ import { beforeAll, describe, expect, test } from 'vitest'; import { EmptyFileSystem } from 'langium'; import { validationHelper, type ValidationResult } from 'langium/test'; -import type { Model } from '@bpmn-script/language'; +import type { FormField, Model } from '@bpmn-script/language'; import { + AFTER_SHAPE_MESSAGE, + alongsideMessage, + AT_SHAPE_MESSAGE, + blockMemberMessage, + booleanDefaultMessage, + BUILTIN_REQUIRED_FIELDS, + CANCEL_ALONGSIDE_MESSAGE, + CANCEL_END_PLACEMENT_MESSAGE, + CANCEL_HOSTLESS_MESSAGE, + CANCEL_NO_CODE_MESSAGE, + CANCEL_NOT_AWAITED_MESSAGE, + CANCEL_NOT_RAISED_MESSAGE, + cancelEndWithoutHandlerMessage, + cancelHandlerWithoutEndMessage, + candidateStarterMessage, + catchTriggerMessage, + COMPENSATE_TYPO_MESSAGE, + COMPENSATION_ALONGSIDE_MESSAGE, + COMPENSATION_BINDINGS_MESSAGE, + COMPENSATION_DUPLICATE_MESSAGE, + COMPENSATION_HOST_MESSAGE, + COMPENSATION_NO_CODE_MESSAGE, + COMPENSATION_PLACEMENT_MESSAGE, + COMPOSITE_OPERAND_MESSAGE, + CONDITIONAL_TYPO_MESSAGE, + constraintMisfitMessage, createBpmnScriptServices, + DEAD_LOOP_MESSAGE, + decisionModifierMessage, + DECLARATION_SETTINGS_ONLY_MESSAGE, + dueDateShapeMessage, + duplicateConditionStartMessage, + duplicateNamedStartMessage, + duplicateValueMessage, + ELEMENT_FIELD_BINDINGS, + emitTriggerMessage, + EMPTY_CODE_MESSAGE, + EMPTY_STRING_VALUE_MESSAGE, + emptyEnumMessage, + emptyFieldMessage, + emptyLoopBodyMessage, + END_CONDITION_MESSAGE, + END_TIMER_MESSAGE, + END_TRIGGER_NO_CODE_MESSAGES, + endTriggerMessage, ENGINE_KEYS, + enumDefaultMessage, + ESCALATION_NO_MESSAGE_MESSAGE, + escapedFieldLiteralMessage, + EVERY_SHAPE_MESSAGE, + fieldBindingMessage, + fieldValueMessage, + flagFalseMessage, + flagNotTrueMessage, + FORM_KEY_AND_REF_MESSAGE, + FORM_NEVER_OFFERED_MESSAGE, + FORM_REF_BINDING_MESSAGE, + FORM_REF_MISSING_MESSAGE, + formFieldDirectionMessage, + formFieldSettingsOnlyMessage, + HANDLER_DUPLICATE_RULE, + headerLiteralMessage, + HISTORY_TIME_TO_LIVE_MESSAGE, + hostedHandlerStartMessage, + hyphenNameMessage, + INITIATOR_SHADOWED_MESSAGE, + integerBoundMessage, + intoBranchMessage, + isoDateDefaultMessage, joinSettingKey, JUEL_RESERVED_WORDS, + juelKeywordMessage, + LINK_CATCH_FLOW_MESSAGE, + LINK_IN_RACE_MESSAGE, + linkThrowNeverRunsMessage, + LISTENER_FIELD_BINDINGS, + LISTENER_TIMER_PAYLOAD_MESSAGE, + literalElBindingMessage, + loopJoinKeyMessage, + MAP_DECISION_RESULT_UNREAD_MESSAGE, + MAPPING_HEAD_MESSAGE, + MAPPING_WHEN_MESSAGE, + missingBuiltinFieldMessage, + NESTED_START_FORM_MESSAGE, + NESTED_START_INITIATOR_MESSAGE, + noDefaultStartMessage, + noFieldHostMessage, + noJobMessage, + noMappingHostMessage, + nonBooleanConditionMessage, + noPerRunJobMessage, + noPropertyHostMessage, + numberDefaultMessage, + onTriggerMessage, + PARALLEL_ELSE_BESIDE_UNCONDITIONED_MESSAGE, + PARALLEL_ELSE_WITHOUT_CONDITION_MESSAGE, + PARALLEL_SECOND_ELSE_MESSAGE, + PARAMETER_HOSTS_MESSAGE, + PATTERN_LETTERS_MESSAGE, + PATTERN_VALUE_MESSAGE, + patternMisfitMessage, + priorityShapeMessage, + propertyValueMessage, + prunedJoinMessage, + quotedCodeMessage, + raceDuplicateMessage, + refusedHeadKeyMessage, + REPEAT_COUNT_MESSAGE, + REPEATED_OUTPUT_MESSAGE, + resultVariableBindingMessage, + resultVariableUnreadMessage, + RETRY_CYCLE_SHAPE_MESSAGE, + RUN_JOB_PRIORITY_MESSAGE, + runWithoutClauseMessage, + scriptListenerFieldMessage, + SECOND_DEFAULT_START_MESSAGE, + SECOND_PAREN_VALUE_MESSAGE, + selfAttachedHostMessage, + settingsOnlyMessage, + shellFieldExpressionMessage, + shellFlagValueMessage, + START_AFTER_IMPLICIT_START_MESSAGE, + START_TRIGGER_IN_HANDLER_MESSAGE, + startMessageExpressionMessage, + startTriggerMessage, + templateAsClassMessage, + throwTriggerMessage, + TIMER_PAYLOAD_MESSAGE, + timerJobKeyTwiceMessage, + topicBindingMessage, + TYPE_VALUE_MESSAGE, + unknownBuiltinFieldMessage, + unknownDeclarationKindMessage, + unknownDirectionMessage, + unknownFormFieldSettingMessage, + VALIDATOR_EMPTY_MESSAGE, + valuesOnNonEnumMessage, + VERSION_SHAPE_MESSAGE, + VERSION_TAG_LENGTH_MESSAGE, + VERSION_TAG_LITERAL_MESSAGE, } from '@bpmn-script/language'; import { BLOCK_HOSTS, @@ -45,7 +178,6 @@ beforeAll(() => { validate = validationHelper(services.BpmnScript); }); -/** Every diagnostic `source` raises, as `message` or `warning: message`. */ async function diagnosticsOf(source: string): Promise { const { diagnostics } = await validate(source); return withTextMessages(diagnostics).map((d) => @@ -53,12 +185,10 @@ async function diagnosticsOf(source: string): Promise { ); } -/** A warning's entry in an expected list; a bare message is an error. */ const warn = (message: string) => `warning: ${message}`; type Case = [title: string, source: string, expected: string[]]; -/** A concern's cases, each asserting the whole diagnostic list of its source. */ function checks(concern: string, cases: Case[]): void { describe(concern, () => { test.each(cases)('%s', async (_title, source, expected) => { @@ -69,11 +199,7 @@ function checks(concern: string, cases: Case[]): void { const capitalized = (text: string) => text[0]!.toUpperCase() + text.slice(1); -/** - * One row per `(setting, field type)` pair, each a field `f` of that type - * carrying the setting alone. An enum gets a value so its own empty-values - * warning stays out of the list. - */ +/** An enum gets a value so its own empty-values warning stays out of the list. */ function formFieldRows( concern: string, pairs: Array<[setting: string, type: string, expected: string[]]>, @@ -86,6 +212,29 @@ function formFieldRows( } // ── The messages, each spelled once ───────────────────────────────────────── +// +// A message the validator exports is imported; a helper below adapts the few +// that take an AST node or a slot to the strings a case spells. + +const nonBooleanCondition = (shape: string) => + nonBooleanConditionMessage(shape, 'condition'); +const fieldBinding = (subject: string, written?: string) => + fieldBindingMessage( + subject, + written === undefined ? [] : [written], + ELEMENT_FIELD_BINDINGS, + ); +const formField = (id: string, type: string) => ({ id, type }) as FormField; +const constraintMisfit = ( + name: string, + id: string, + type: string, + fits: string, +) => constraintMisfitMessage(name, formField(id, type), [fits]); +const patternMisfit = (id: string, type: string) => + patternMisfitMessage(formField(id, type)); +const valuesOnNonEnum = (id: string, type: string) => + valuesOnNonEnumMessage(formField(id, type)); const typeMismatch = ( name: string, @@ -94,18 +243,6 @@ const typeMismatch = ( op: string, ) => `Variable '${name}' of type '${type}' cannot be used in ${context} (operator '${op}').`; -const juelKeyword = (word: string, key: string) => - `'${word}' is a JUEL keyword (Scanner.addKeyToken), so the engine refuses any expression naming it. Reach the variable through a string key instead: "\${${key}}".`; -const hyphenName = (name: string, key: string) => - `'${name}' carries a hyphen, which JUEL scans as a minus (Scanner.nextIdentifier), so the engine reads a subtraction. Reach the variable through a string key instead: "\${${key}}".`; -const nonBooleanCondition = (shape: string) => - `A condition must be boolean, but this one is ${shape}: the engine throws 'condition expression returns non-Boolean' when it evaluates it (UelExpressionCondition.evaluate).`; -const nonBooleanUntil = (shape: string) => - `An 'until' condition must be boolean, but this one is ${shape}: the engine throws when a completion condition evaluates to anything else (MultiInstanceActivityBehavior.completionConditionSatisfied).`; -const COMPOSITE_OPERAND = - "A composite template cannot be spliced into the surrounding expression: JUEL has no '${' token once inside an expression (Scanner.nextEval), so only a raw that is exactly one '${...}' with no '}' outside a string literal in its body can be an operand. Write the whole expression as one raw template instead."; -const DEAD_LOOP = - "This loop can never repeat: every path through the 'do' body ends or redirects the flow, so the condition is never evaluated and the loop gateway would lower to a disconnected node with no incoming flow, which is invalid BPMN. End after the loop, or keep one path through the body."; const ONE_PROCESS_ONLY = 'Only one process is supported per file. ' + 'Move additional processes into separate files.'; @@ -144,16 +281,6 @@ const notValidOn = (key: string, description: string) => `Setting '${key}' is not valid on ${description}.`; const flagNotValidOn = (flag: string, description: string) => `Flag '${flag}' is not valid on ${description}.`; -const loopJoinKey = (key: string, description: string, base: string) => - `Setting '${key}' is not valid on ${description}: a loop has one gateway, so write '${base}'.`; -const settingsOnly = (description: string) => - `The parens of ${description} take only settings, written 'key: value'.`; -const AWAIT_ASYNC_AFTER = - "Setting 'asyncAfter' is not valid on an await block: Operaton refuses it " + - 'on an event-based gateway (BpmnParse.parseEventBasedGateway). Write it on ' + - 'the branch triggers instead.'; -const prunedJoin = (statement: string, key: string) => - `Every branch of this ${statement} ends its path, so there is no join for '${key}' to set; the setting has no effect.`; const bindingNotAName = (field: string) => `A catch binding names the variable the caught ${field} lands in, not a ` + `value: write '${field}: '.`; @@ -171,106 +298,18 @@ const DECISION_BINDINGS = `'class', 'expression', 'delegate', 'topic', 'type', o /** A thrown message has no member block for the fields a built-in behaviour needs. */ const THROW_BINDINGS = `'class', 'expression', 'delegate', or 'topic'`; const LISTENER_BINDINGS = `'class', 'expression', or 'delegate'`; -const TYPE_VALUE = `Setting 'type' must be 'mail' or 'shell'.`; -const missingBuiltinField = ( - subject: string, - type: string, - names: string, - error: string, - method: string, -) => - `${subject} binds type: "${type}" without a ${names} field; Operaton refuses to deploy it: "${error}" (BpmnParse.${method}).`; -const MAIL_FIELDS = 'to, from, cc, bcc, subject, text, html, and charset'; -const SHELL_FIELDS = - 'command, wait, arg1, arg2, arg3, arg4, arg5, outputVariable, errorCodeVariable, redirectError, cleanEnv, and directory'; -const unknownBuiltinField = ( - name: string, - type: string, - behaviour: string, - declared: string, -) => - `Field '${name}' is not one a ${type} task takes; the engine sets it on ${behaviour}, which declares ${declared} (ClassDelegateUtil.applyFieldDeclaration).`; -const shellFieldExpression = (name: string) => - `Field '${name}' on a shell task takes a quoted literal: Operaton reads every shell field as a fixed value (BpmnParse.validateFieldDeclarationsForShell) and fails the deployment on an expression.`; -const shellFlagValue = (name: string) => - `Field '${name}' on a shell task takes "true" or "false"; the engine reads any other spelling as false (ShellActivityBehavior.readFields).`; -const runWithoutClause = (key: string, description: string, base: string) => - `Setting '${key}' is not valid on ${description} that does not repeat: it makes one job per run, so write a 'for' clause, or '${base}' for one job around the step.`; -const RUN_JOB_PRIORITY = - "Setting 'runJobPriority' does not exist: Operaton reads a job priority off " + - "the step alone (BpmnParse.createActivityOnScope), so 'jobPriority' applies " + - "to every run's job."; -const noJob = (key: string, pairing: string, description: string) => - `Setting '${key}' on ${description} configures no job: Operaton creates ` + - `one only when ${pairing} is also set ` + - '(BpmnParse.parseAsynchronousContinuation, DefaultFailedJobParseListener.parseActivity).'; -const noPerRunJob = (key: string, pairing: string, description: string) => - `Setting '${key}' on ${description} prices the per-run job Operaton ` + - 'creates in the multi-instance body (BpmnParse.parseActivity), which ' + - `exists only when ${pairing} is also set.`; -const REPEAT_COUNT = - 'A repeat count must be a non-negative whole number, a variable, or a ' + - '"${...}" expression yielding one; the engine reads a constant as text ' + - 'with Integer.parseInt and truncates any other number with intValue() ' + - '(MultiInstanceActivityBehavior.resolveLoopCardinality).'; const emptyBinding = (key: string, noun: string) => `Setting '${key}' cannot be empty; name the ${noun}.`; -const literalElBinding = (key: string, effect: string) => - `Setting '${key}' takes a "\${...}" template or a bare name, never quoted text: ${effect}. Write '${key}: "\${...}"' or '${key}: '.`; -const NO_DELEGATE = 'a string resolves to no delegate to run'; -const EVALUATES_ITSELF = 'the string evaluates to itself and runs nothing'; -const templateAsClass = (key: string, alternative: string) => - `Setting '${key}' takes a class name, loaded as written (ClassDelegateUtil.instantiateDelegate); a "\${...}" template there is not evaluated. Write '${key}: com.example.X', or '${alternative}: "\${bean}"' to resolve one at runtime.`; const bindingRequired = (subject: string, keys: string, alternative = '') => `${subject} must declare a ${keys} setting${alternative}.`; const bindingConflict = (subject: string, written: string, keys: string) => `${subject} declares more than one binding (${written}); exactly one of ${keys} is allowed.`; -const resultVariableBinding = ( - subject: string, - binding: string, - element: string, - attribute: string, -) => - `${subject} cannot carry 'resultVariable' beside '${binding}': the engine refuses to deploy it ('resultVariableName' not supported for ${element} elements using '${attribute}'); bind with 'expression' to store the return value, or drop it.`; const MAP_DECISION_RESULT = `Setting 'mapDecisionResult' must be 'singleEntry', 'singleResult', ` + `'collectEntries', or 'resultList'.`; -const resultVariableUnread = (binding: string) => - `Setting 'resultVariable' has no effect beside '${binding}': the engine hands it to an 'expression' binding alone (BpmnParse.parseServiceTaskLike), so nothing writes the variable.`; -const MAP_DECISION_RESULT_UNREAD = - "Setting 'mapDecisionResult' has no effect without 'resultVariable': the engine applies the mapping only when storing the result into that variable (DecisionEvaluationUtil.evaluateDecision)."; -const decisionModifierWithoutDecision = (key: string) => - `Setting '${key}' stands only beside 'decision': the engine reads 'binding', 'version' and 'mapDecisionResult' on a step answering a decision table alone (BpmnParse.parseBusinessRuleTask).`; -const VERSION_SHAPE = - `Setting 'version' takes a positive whole number, quoted or not, or a "\${...}" expression yielding one; ` + - 'the engine parses the value as an integer when the step runs (BaseCallableElement.getVersion, TaskEntity.initializeFormRefFromTaskDefinition) and fails the instance on anything else.'; -const dueDateShape = (key: string) => - `Setting '${key}' takes a period starting with 'P' or an ISO date-time such as "2026-01-01T09:00:00", or a "\${...}" expression; ` + - 'the engine parses a constant with DueDateBusinessCalendar.resolveDuedate when the task is created and fails the instance on anything else.'; -const RETRY_CYCLE_SHAPE = - "Setting 'retryCycle' takes an ISO 8601 duration, an 'R/' repeat, or a comma " + - 'list of durations; a single interval the engine cannot read is logged and dropped, so the ' + - 'job keeps its default retries (ParseUtil.parseRetryIntervals), and a bad member of a list ' + - 'is stored unchecked and, when its turn comes, drops that retry to the default strategy ' + - 'with no wait (DefaultJobRetryCmd.execute).'; -const USER_PRIORITY_SHAPE = - `Setting 'priority' takes an integer or a "\${...}" expression; ` + - 'the engine parses a constant with Integer.parseInt when the task is created (TaskDecorator.initializeTaskPriority) and fails the instance on anything else.'; // The process header. -const HISTORY_TIME_TO_LIVE_SHAPE = - "Setting 'historyTimeToLive' takes a quoted number of days, 'PD' or ''; " + - 'the engine reads the attribute as text, never as an expression, and refuses to deploy anything else (ParseUtil.parseHistoryTimeToLive).'; -const VERSION_TAG_LITERAL = - "Setting 'versionTag' takes a quoted string; the engine stores the tag as written, never evaluated (BpmnParse.parseProcess)."; -const VERSION_TAG_LENGTH = - "Setting 'versionTag' is longer than 64 characters, the width of the column it is stored in (ACT_RE_PROCDEF.VERSION_TAG_), so the deployment fails."; -const candidateStarterTemplate = (key: string) => - `Setting '${key}' takes ids as written: the engine stores each entry as a candidate identity link without evaluating it (BpmnDeployer.addAuthorizations), so a "\${...}" template names the id spelled by its text.`; -const headerLiteral = (key: string) => - `Setting '${key}' takes a quoted string on a process header; the engine stores it as written, never evaluated (BpmnParse.parseProcess).`; - // Forms. const noFormBlock = (description: string) => @@ -280,111 +319,15 @@ const oneFormBlock = (description: string) => const duplicateFormField = (id: string) => `Duplicate form field '${id}'.`; const formFieldType = (id: string, type: string) => `Form field '${id}' has type '${type}', which a form cannot use. Use string, number, boolean, date, or enum.`; -const FORM_FIELD_SETTINGS = `'required', 'readonly', 'min', 'max', 'minlength', 'maxlength', 'validator', or 'pattern'`; -const formFieldSettingsOnly = (id: string, text: string) => - `Form field '${id}' takes 'key: value' settings in its parens; '${text}' is not one.`; -const unknownFormFieldSetting = (id: string, key: string) => - `Unknown form field setting '${key}' on '${id}'; write ${FORM_FIELD_SETTINGS}.`; -const constraintMisfit = ( - name: string, - id: string, - type: string, - fits: string, -) => - `Constraint '${name}' fits a ${fits} field, not the ${type} field '${id}': the engine checks a submitted ${fits} alone and fails every other submission.`; -const patternMisfit = (id: string, type: string) => - `Setting 'pattern' is the date pattern a 'date' field is parsed with; '${id}' is a ${type} field, which the engine reads no pattern off.`; -const flagFalse = (key: string) => - `A field is ${key} only while the setting is written, so '${key}: false' says nothing; leave the setting out.`; -const flagNotTrue = (key: string) => - `Setting '${key}' takes the literal true; write '${key}: true'.`; -const integerBound = (key: string) => - `Setting '${key}' takes an integer literal or a quoted integer such as "-5".`; -const PATTERN_VALUE = `Setting 'pattern' takes a non-empty quoted date pattern such as "dd/MM/yyyy".`; -const PATTERN_LETTERS = - "Setting 'pattern' may hold only SimpleDateFormat letters " + - '(G, y, Y, M, L, w, W, D, d, F, E, u, a, H, k, K, h, m, s, S, z, Z, X), ' + - 'quoted literal runs, and non-letters; the engine builds a java.text.SimpleDateFormat ' + - 'from it and throws on any other letter (DateFormType).'; -const VALIDATOR_EMPTY = - "Setting 'validator' cannot be empty; name the class or the expression it " + - 'resolves (FormValidators.createValidator).'; -const valuesOnNonEnum = (id: string, type: string) => - `Value lines belong on an 'enum' field; '${id}' is a ${type} field.`; -const emptyEnum = (id: string) => - `Enum field '${id}' offers no values, so the engine rejects every submitted value; add a value line such as 'basic "Basic"'.`; -const duplicateValue = (id: string) => `Duplicate value '${id}'.`; -const enumDefault = (id: string, value: string, ids: string) => - `The default "${value}" of enum field '${id}' names none of its values; write ${ids}.`; -const numberDefault = (id: string, text: string) => - `The default ${text} of number field '${id}' is not an integer; the engine ` + - 'converts it with Long.valueOf every time the form renders (LongFormType.convertValue) ' + - 'and throws on anything else.'; -const booleanDefault = (id: string, text: string) => - `The default ${text} of boolean field '${id}' is not "true" or "false"; the engine ` + - 'reads any other spelling as false (BooleanFormType.convertValue).'; -const isoDateDefault = (id: string, value: string) => - `The default "${value}" of date field '${id}' is an ISO date, but the engine's default ` + - 'pattern is "dd/MM/yyyy" (ProcessEngineConfigurationImpl.initFormTypes); add a \'pattern\' ' + - 'setting, or write the date to fit it.'; -const formFieldDirection = (id: string, direction: string, isEnum = false) => - `Unknown member direction '${direction}' in form field '${id}': its block takes 'property = ""' lines${isEnum ? ' and value lines' : ''}.`; -const propertyValue = (name: string) => - `Property '${name}' takes a quoted string or a "\${...}" expression; ` + - 'put the value in quotes.'; // Parameters and listeners. -const PARAMETER_HOSTS_SENTENCE = - 'parameters belong on a user task, a service task, a script task, a step, ' + - 'a send task, a receive task, a decision step, a subprocess, a call, and ' + - 'an attempt block.'; const noParameters = (description: string) => - `${capitalized(description)} cannot declare an 'input' or 'output' parameter; ${PARAMETER_HOSTS_SENTENCE}`; -const unknownDirection = (word: string, legal = `'input' or 'output'`) => - `Unknown parameter direction '${word}'; write ${legal}.`; + `${capitalized(description)} cannot declare an 'input' or 'output' parameter; ${PARAMETER_HOSTS_MESSAGE}`; const duplicateParameter = (direction: string, name: string) => `Duplicate '${direction}' parameter '${name}'.`; -const FIELD_HOSTS_SENTENCE = - 'an injected field belongs on a service task, a send task, a decision ' + - 'step, and on a listener.'; -const noFields = (description: string) => - `${capitalized(description)} cannot declare a 'field' parameter; ${FIELD_HOSTS_SENTENCE}`; -const fieldBinding = (subject: string, written?: string) => - `${subject} carries an injected field only under a 'class', 'delegate', or 'type' ` + - 'binding: the engine injects into the class, the delegate, or the built-in ' + - 'behaviour that binding names' + - (written === undefined - ? '.' - : `, and the binding written with '${written}' receives none.`); -/** A listener binds no `type`, so its refusal names the two it can write. */ -const listenerFieldBinding = (subject: string, written: string) => - `${subject} carries an injected field only under a 'class' or 'delegate' ` + - 'binding: the engine injects into the class or the delegate that binding ' + - `names, and the binding written with '${written}' receives none.`; -const scriptListenerField = (subject: string) => - `${subject} runs a fenced script, which the engine hands no field list; ` + - "remove the script and bind the listener with 'class' or 'delegate' " + - 'to inject one.'; -const fieldValue = (name: string) => - `Field '${name}' takes a quoted string or a "\${...}" expression; ` + - 'put the value in quotes.'; -const escapedFieldLiteral = (name: string) => - `Field '${name}' cannot carry quoted text opening with '\${' or '#{': the expression slot is picked by that opening, so the text would be evaluated rather than injected as written. Drop the backslash to write an expression.`; -const emptyField = (name: string) => - `Field '${name}' cannot be empty: the engine reads an empty value as absent, so the ` + - 'field declares neither a fixed value nor an expression and the deployment fails ' + - `(BpmnParse.parseFieldDeclaration). Write '\${""}' for an actual empty string.`; -const REPEATED_OUTPUT = - "A repeated step cannot map an 'output' parameter: the engine refuses to " + - 'deploy it (BpmnParse.checkActivityOutputParameterSupported). Move the ' + - 'mapping to a step after the repetition.'; const EMPTY_MAP_KEY = "A map entry's key cannot be empty; name the key its value is looked up by."; -const EMPTY_STRING_VALUE = - 'An empty or blank string writes no value at all: the engine trims it and ' + - 'reads it as absent rather than as an empty string (BpmnParseUtil.getElValueProvider). ' + - 'Write \'${""}\' for an actual empty string.'; const taskListenerOnly = (event: string, description: string) => `'on ${event}' is a task listener, which only a user task has; ` + `${description} takes 'start' or 'end'.`; @@ -395,24 +338,6 @@ const LISTENER_PARTICLE_ONLY = "Only 'on timeout' takes a particle."; // The extras of a step handed to an external worker. -const EXTERNAL_HOSTS_SENTENCE = - "a service task, a send task, or a decision step bound with 'topic'"; -const topicBinding = (subject: string, item: string, written?: string) => - `${subject} carries ${item} only under a 'topic' binding: the engine reads it for a step handed to an external worker` + - (written === undefined - ? '.' - : `, and the binding written with '${written}' hands the step to none.`); -const noPropertyHost = (description: string) => - `${capitalized(description)} cannot declare a 'property' line; a property line belongs on ${EXTERNAL_HOSTS_SENTENCE}, and in a form field's block.`; -const noMappingHost = (description: string) => - `${capitalized(description)} cannot map a reported failure; an 'error when ' line belongs on ${EXTERNAL_HOSTS_SENTENCE}, whose external worker is what reports one.`; -const MAPPING_HEAD = - 'An external task maps a reported failure onto an error and nothing else; ' + - "write 'error when '."; -const MAPPING_WHEN = - "Write 'when' between the code and the condition: 'error when '."; -const priorityShape = (key: string) => - `Setting '${key}' takes an integer or a "\${...}" expression; the engine refuses to deploy a constant that is not an integer.`; const codeNotDeclared = (trigger: string, name: string) => `'${name}' is not declared. Add '${trigger} ${name}' to the process.`; @@ -458,33 +383,11 @@ function scriptTagCases( const emptyBranch = (what: string) => `The ${what} has no steps.`; const emptyNumberedBranch = (index: number, keyword: string) => `Branch ${index} of the '${keyword}' statement has no steps.`; -const emptyLoopBody = (keyword: string) => - `The '${keyword}' body has no steps, so the loop and its condition would be dropped: with nothing to loop over, the gateway keeps only its exit and the condition is never written. Put a step in the body, or remove the loop.`; -const blockMember = (what: string, verb: string) => - `${what} written inside a body belongs to nothing and is dropped: put it in the attribute block of the element it ${verb}, the braces before that element's body.`; const blockParameter = (direction: string) => - blockMember(`An '${direction}' parameter`, 'configures'); -const BLOCK_LISTENER = blockMember('A listener', 'observes'); -const PARALLEL_SECOND_ELSE = - "A 'parallel' statement takes one 'else' branch at most; the first one " + - 'already runs when no condition held. Fold this branch into it or give it a ' + - 'condition.'; -const PARALLEL_ELSE_WITHOUT_CONDITION = - "An 'else' branch needs a sibling branch with a condition: with no condition " + - 'anywhere every branch runs, so there is nothing to fall back from. Give a ' + - "sibling a condition, or drop the 'else'."; -const PARALLEL_ELSE_BESIDE_UNCONDITIONED = - "An 'else' branch runs only when no sibling branch was taken, and a branch " + - 'with no condition is always taken, so this one could never run. Give every ' + - "sibling a condition, or drop the 'else'."; -const intoBranch = ( - subject: string, - jump: string, - keyword: 'parallel' | 'await', -) => - `'${subject}' jumps into a branch of ${keyword === 'await' ? 'an' : 'a'} '${keyword}' statement from outside that branch; a branch's steps run only when the whole '${keyword}' statement is reached, not via an external '${jump}'.`; + blockMemberMessage(`An '${direction}' parameter`, 'configures'); +const BLOCK_LISTENER = blockMemberMessage('A listener', 'observes'); const gotoIntoBranch = (target: string, keyword: 'parallel' | 'await') => - intoBranch(`goto ${target}`, 'goto', keyword); + intoBranchMessage(`goto ${target}`, 'goto', keyword); const unresolvedStatement = (name: string) => `Could not resolve reference to Statement named '${name}'.`; const missingStep = (name: string) => @@ -503,16 +406,6 @@ const BINDING_VALUE = `Setting 'binding' must be 'latest' or 'deployment'.`; const BINDING_IS_VERSION = `Write 'version: ' instead of 'binding: version'.`; const bindingVersionClash = (subject: string) => `${subject} cannot combine 'binding' and 'version'; use 'version: ' to pin a specific version, or 'binding: latest'/'binding: deployment' for the other modes.`; -const FORM_KEY_AND_REF = - "A user task names its form with 'formKey' or with 'formRef', never both; " + - 'the engine refuses to deploy a task carrying the two.'; -const FORM_REF_NEEDS_BINDING = - "A 'formRef' needs the binding resolving it: add 'binding: latest', " + - "'binding: deployment', or 'version: '. The engine refuses to " + - 'deploy a form reference with none.'; -const FORM_REF_MISSING = - "'binding' and 'version' pin which deployed version of a form the engine " + - "resolves, so neither stands without a 'formRef'."; const duplicateMapping = (direction: string, target: string) => `Duplicate '${direction}' mapping target '${target}'.`; const duplicateAllMapping = (direction: string) => @@ -528,42 +421,6 @@ const startAfterLiveChain = (name: string) => `'start ${name}' opens an entry of its own and takes no incoming flow, ` + 'but the statement before it still flows on to it. Close that flow first ' + "(with 'end', 'throw', or 'goto') or move the start ahead of that step."; -const noDefaultStart = (name: string) => - `Process '${name}' has no default start: with only message, signal, or ` + - 'condition starts, the engine can create an instance only by triggering ' + - 'one of them, and starting it by key fails at runtime.'; -const FORM_NEVER_OFFERED = - "The engine offers a start form only on the process's default start, its " + - 'plain or timer start; this form is on a different start and is never ' + - 'shown.'; -const INITIATOR_SHADOWED = - 'The engine keeps one initiator per process: whichever start is parsed ' + - 'last wins, so this setting is never written. Move it to the last ' + - 'start, or drop it.'; -const SECOND_DEFAULT_START = - 'A process takes one plain or timer start: Operaton refuses a second one ' + - '(BpmnParse.selectInitial), so the deployment fails. Keep one, or give ' + - 'this start a message, signal, or condition trigger.'; -const START_AFTER_IMPLICIT_START = - 'A body that does not open with a start gets a plain start of its own, ' + - "so this is the process's second plain or timer start, which Operaton " + - 'refuses (BpmnParse.selectInitial), so the deployment fails. Open the ' + - 'body with this start, or give it a message, signal, or condition trigger.'; -const duplicateNamedStart = (trigger: string, name: string) => - `Another start already subscribes to ${trigger} '${name}': Operaton keeps one ${trigger} start subscription per name and process (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails.`; -const duplicateConditionStart = (text: string) => - `Another start already carries the condition '${text}': Operaton keeps one conditional start per condition text and process (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails.`; -const NESTED_START_FORM = - 'A start inside a subprocess, attempt block, or handler body takes no ' + - "form: the engine reads a start form off the process's own start alone " + - '(BpmnParse.parseStartFormHandlers) and BpmnParse.parseScopeStartEvent ' + - 'reads none, so this form is never shown.'; -const NESTED_START_INITIATOR = - 'A start inside a subprocess, attempt block, or handler body takes no ' + - "'initiator': the engine reads it off the process's own start alone " + - '(BpmnParse.parseProcessDefinitionStartEvent) and ' + - 'BpmnParse.parseScopeStartEvent reads none, so nothing is written.'; -/** The three scopes a nested start opens, each wrapping one body. */ const NESTED_START_CONTAINERS: ReadonlyArray< [kind: string, wrap: (body: string) => string] > = [ @@ -574,114 +431,32 @@ const NESTED_START_CONTAINERS: ReadonlyArray< (body) => `process p { error X user T on error(X) { ${body} } }`, ], ]; -const timerJobKeyTwice = (key: string) => - `Setting '${key}' is already written on the 'on timer' head, and both land on this start event, the element the engine reads the timer job's settings from (BpmnParse.parseTimer, DefaultFailedJobParseListener.parseStartEvent); keep one.`; -const hostedHandlerStart = (name: string) => - `'start ${name}' cannot open a handler that names a host: the body runs ` + - "inside the host's own container and is entered from the boundary event, " + - 'so it is not a scope with a start of its own. Remove the start; the first ' + - 'step of the body is where the escape path begins.'; const startTriggerInBlock = (kind: string) => `Only the process's own start carries a trigger: ${kind} is entered from ` + 'the step before it, so its start has none. Put the trigger on an ' + "'on' handler inside the block if it should react to an event."; -const START_TRIGGER_IN_HANDLER = - "The start of an event-handler body carries no trigger; the handler's own " + - "'on ' is what it catches."; -const startRaisedKind = (word: string) => - `A process cannot start on an ${word}: the engine ignores the trigger and ` + - 'starts the process as if none were written. Catch it with ' + - `'on ${word}' inside the scope that raises it.`; -const START_COMPENSATION = - "A process cannot start on compensation: it undoes a subprocess's " + - "completed work, so it belongs in an 'on compensation' block inside that " + - 'subprocess.'; const START_CONDITION_REQUIRED = "A condition start needs its condition: 'start S condition(amount > 100)'."; const START_CONDITION_NO_CODE = "A condition start takes no code string; write the condition itself: 'start S condition(amount > 100)'."; const START_CONDITION_ONLY = 'Only a condition start takes a condition expression.'; -const unknownStartKind = (word: string) => - `Unknown event kind '${word}'; a start event supports 'message', 'signal', 'timer', or 'condition'.`; const startNameRequired = (trigger: string) => `A ${trigger} start needs the ${trigger}'s name: the engine matches ${trigger}s by name.`; -const startMessageExpression = (name: string) => - `A message start name cannot contain an expression ("${name}"): the engine ` + - 'rejects one there, because a process that has not started yet has no ' + - 'variables to evaluate it against. Give the start a fixed name; an ' + - "expression belongs on an 'on message' handler or an 'await message', " + - 'which run once the process has variables.'; const START_PARTICLE_ONLY = 'Only a timer start takes a particle.'; // Timers. -const TIMER_PAYLOAD = - `A timer needs to know how to read the time: write 'timer("PT1H")', ` + - `'timer(at: "2026-08-01T09:00:00")', or 'timer(every: "R/PT10M")'.`; -const LISTENER_TIMER_PAYLOAD = - `A timer needs to know how to read the time: write 'after "PT1H"', ` + - `'at "2026-08-01T09:00:00"', or 'every "R/PT10M"'.`; const unknownParticle = (word: string) => `Unknown timer particle '${word}'; write 'after', 'at', or 'every'.`; -const timerShape = (particle: string, expected: string, calendar: string) => - `'${particle}' takes ${expected}; the engine reads it as text and fails when it does not fit (${calendar}).`; -const AFTER_SHAPE = timerShape( - 'after', - 'an ISO 8601 duration such as "PT1H", on its own or beside a start or end date-time ("2026-01-01T00:00:00/PT1H", "PT1H/2026-12-31T00:00:00")', - 'DurationBusinessCalendar.resolveDuedate', -); -const AT_SHAPE = timerShape( - 'at', - 'an ISO date-time such as "2026-08-01T09:00:00", or a duration counted from now', - 'DueDateBusinessCalendar.resolveDuedate', -); -const EVERY_SHAPE = timerShape( - 'every', - 'an ISO 8601 repeat such as "R/PT10M", or a six-field cron expression or one of its nicknames such as "@daily"', - 'CycleBusinessCalendar.resolveDuedate', -); const REPEATING_INTERRUPTS = 'A repeating timer that interrupts its scope fires at most once: ' + "add 'alongside' to let it repeat, or give it a duration instead."; // End events. -const END_TRIGGERS_SENTENCE = - "An end event carries 'terminate', which stops every running path in this " + - `scope, or 'cancel', which gives up the 'attempt' block it sits in.`; -const endRaisedKind = (word: string, article: 'A' | 'An') => - `${article} ${word} is raised with 'throw', not on an end: write ` + - `'throw ${word}' in place of this end. ${END_TRIGGERS_SENTENCE}`; -const END_TIMER = - 'A timer cannot end a process; a timer is something a process waits on. ' + - `Write 'await timer("PT1H")' to pause the flow here, ` + - `'on timer("PT1H")' to react while the surrounding steps run, or ` + - `'on : timer("PT1H")' to watch only while that step runs. ` + - END_TRIGGERS_SENTENCE; -const END_CONDITION = - 'A condition cannot end a process; a condition is something a process ' + - `waits on. Write 'await condition(amount > 100)' to pause the flow here, ` + - `'on condition(amount > 100)' to react while the surrounding steps run, ` + - `or 'on : condition(amount > 100)' to watch only while that step ` + - `runs. ${END_TRIGGERS_SENTENCE}`; -const unknownEndKind = (word: string) => - `Unknown event kind '${word}'. ${END_TRIGGERS_SENTENCE} Every other kind ` + - "is raised with 'throw'."; -const TERMINATE_NAMES_NOTHING = - 'Terminate names nothing: it stops every running path in this scope; ' + - 'leave the payload out.'; -const CANCEL_NAMES_NOTHING = - 'Cancel names nothing: it gives up the block this end sits in; leave the ' + - 'payload out.'; - // Event handlers. -const unknownOnKind = (word: string) => - `Unknown event kind '${word}'; write 'error', 'escalation', 'message', ` + - `'signal', 'timer', 'condition', 'compensation', or 'cancel'.`; -const CONDITIONAL_TYPO = `Unknown event kind 'conditional'; did you mean 'condition'?`; -const COMPENSATE_TYPO = `Unknown event kind 'compensate'; write 'compensation'.`; const HANDLER_PLACEMENT = 'An event handler belongs directly in the body of a process, a subprocess, ' + 'an attempt block, or another event handler: it handles events for that ' + @@ -689,12 +464,6 @@ const HANDLER_PLACEMENT = const HANDLER_TRAILING = 'Event handlers read like catch blocks: move it after the last step of ' + 'this body.'; -const ERROR_ALWAYS_INTERRUPTS = - 'An error always interrupts: the handler takes over from the failed scope; ' + - "'alongside' is only available for escalations."; -const EMPTY_CODE_NOT_CATCH_ALL = - 'An empty code ("") is not a catch-all; to catch every error, leave the ' + - 'payload out entirely.'; const MESSAGELESS_NAME = "A message handler needs the message's name: the engine matches messages by name."; const CONDITION_REQUIRED = @@ -702,38 +471,16 @@ const CONDITION_REQUIRED = const CONDITION_NO_CODE = "A condition handler takes no code string; write the condition itself: 'on condition(amount > 100)'."; const CONDITION_ONLY = "Only 'on condition' takes a condition expression."; -const SECOND_PAREN_VALUE = - 'The parens carry one unkeyed value, the payload; a second one names ' + - "nothing and never reaches the engine. Write it as a 'key: value' setting, " + - 'or remove it.'; const PARTICLE_ONLY = "Only 'on timer' takes a particle."; const noBindings = (trigger: string) => `'(code: c)' bindings belong to error and escalation handlers; a ${trigger} carries no code.`; -const ESCALATION_HAS_NO_MESSAGE = - 'An escalation carries a code but no message.'; -/** Why a second catch of one thing on one scope is refused, per trigger. */ -const HANDLER_DUPLICATE_RULE: Readonly> = { - message: - 'Operaton keeps one message subscription per name and scope (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails', - signal: - 'Operaton keeps one signal subscription per name and scope (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails', - escalation: - 'Operaton keeps one escalation catch per code and scope (BpmnParse.addEscalationEventDefinition), so the deployment fails', - cancel: - 'Operaton keeps one cancel handler per attempt block (BpmnParse.parseBoundaryCancelEventDefinition), so the deployment fails', - error: - 'the engine refuses nothing here and takes the first match after sorting its handlers (BpmnParse.addErrorEventDefinition), so this surface keeps one handler per code', -}; const handlerDuplicate = (trigger: string, caught: string, scope: string) => `Another 'on ${trigger}' handler already catches ${caught} on scope '${scope}': ${HANDLER_DUPLICATE_RULE[trigger]}.`; -/** What a duplicate handler is said to catch: a code, a name, or every event. */ const everyEvent = 'every event of this kind'; const code = (value: string) => `code '${value}'`; const eventName = (value: string) => `name '${value}'`; const escalationCatchAllBesideCoded = (scope: string) => `An 'on escalation' handler with no code cannot sit beside one with a code on scope '${scope}': the code-less one would catch every escalation, and Operaton refuses the pair (BpmnParse.addEscalationEventDefinition). Give both a code, or keep one.`; -const raceDuplicate = (trigger: string, value: string) => - `Another branch of this 'await' already catches ${trigger} '${value}': every branch subscribes on the gateway's own scope (BpmnParse.parseIntermediateCatchEvent), which keeps one ${trigger} subscription per name (BpmnParse.addEventSubscriptionDeclaration), so the deployment fails.`; // Boundary hosts. @@ -744,77 +491,21 @@ const illegalHost = (name: string, kind: string) => const escalationHost = (name: string, kind: string) => 'An escalation boundary can only attach to a subprocess, an attempt block, ' + `a call, or a user task; '${name}' is ${kind}.`; -const selfAttachedHost = (name: string) => - 'A boundary event cannot attach to a step inside its own escape path: ' + - `'${name}' only runs after this handler has already fired, so it can never ` + - 'host the event that starts that path.'; // Compensation. -const COMPENSATION_NO_CODE = - "Compensation has no code or name: 'on compensation { }' is the undo block " + - 'of the subprocess or attempt block it sits in; leave the payload out.'; -const COMPENSATION_BINDINGS = - "'(code: c)' bindings belong to error and escalation handlers; compensation carries no values."; -const COMPENSATION_ALONGSIDE = - 'The work an undo block reverses has already finished, so there is no ' + - "running flow to run alongside; remove 'alongside'."; -const COMPENSATION_PLACEMENT = - "An undo block belongs directly inside the 'subprocess' or 'attempt' whose " + - 'work it undoes: a process cannot undo itself.'; -const COMPENSATION_DUPLICATE = - 'A subprocess or an attempt block has one undo block; merge the steps.'; -const COMPENSATION_HOST = - "Compensation cannot attach to a host: it undoes a subprocess's " + - 'already-completed work through its own undo block, not through a ' + - "boundary event; remove the host and write 'on compensation { ... }' " + - 'directly inside the subprocess or attempt block it reverses.'; const throwCompensationNames = (keyword: 'throw' | 'emit') => 'Compensation undoes completed work: there is nothing to name; ' + `write '${keyword} compensation'.`; // Cancel. -const CANCEL_END_PLACEMENT = - "A cancel end belongs directly inside an 'attempt' block: it gives that " + - 'block up, and the engine refuses one anywhere else. Wrap the steps to ' + - `give up in 'attempt { ... }', or end this path with a plain 'end'.`; -const CANCEL_HOSTLESS = - "A cancel is caught on the block it gives up; write 'on : cancel'. " + - 'A handler with no host opens on its own trigger, and nothing opens on a ' + - 'cancel.'; -const CANCEL_ALONGSIDE = - 'Giving a block up ends every step still running inside it, so there is ' + - "nothing left to run alongside; remove 'alongside'."; -const CANCEL_NO_CODE = - 'A cancel handler catches nothing by name: it runs when its block is ' + - 'given up; leave the payload out.'; -const CANCEL_NOT_RAISED = - 'A cancel is not raised: it is how a block gives itself up; write ' + - `'end cancel' inside the 'attempt' block.`; -const CANCEL_NOT_AWAITED = - 'A cancel is not awaited: it is how a block gives itself up; write ' + - `'end cancel' inside the 'attempt' block, and ` + - `'on : cancel' beside the block to say what happens then.`; const cancelHost = (name: string, kind: string) => `A cancel handler can only attach to an 'attempt' block: it catches that ` + `block being given up; '${name}' is ${kind}.`; -const cancelEndWithoutHandler = (name: string) => - `'${name}' gives itself up but nothing catches it: the engine stops with ` + - `an error the first time that end is reached. Write 'on ${name}: cancel ` + - `{ ... }' beside the block to say what happens then.`; -const cancelHandlerWithoutEnd = (name: string) => - `Nothing inside '${name}' gives it up, so this handler never runs: write ` + - `'end cancel' on the path that should give the block up, or remove ` + - 'the handler.'; // Throw, emit, and await. -const unknownThrowKind = (word: string) => - `Unknown event kind '${word}'; write 'error', 'escalation', 'message', 'signal', or 'compensation'.`; -const unknownEmitKind = (word: string) => - `Unknown event kind '${word}'; write 'escalation', 'message', 'signal', 'compensation', or 'link'.`; -const EMIT_ERROR = "An error always aborts its path; write 'throw error'."; const codeRequired = ( subject: 'A thrown' | 'An emitted', trigger: string, @@ -823,11 +514,6 @@ const codeRequired = ( const noImplementation = (key: string, subject: string) => `Setting '${key}' is not valid on ${subject}; an implementation is what ` + 'makes the engine really send a message, so only a message carries one.'; -const unknownAwaitKind = (word: string) => - `Unknown event kind '${word}'; intermediate catch supports 'message', ` + - `'timer', 'signal', 'condition', or 'link'. An error or an escalation is ` + - `raised with 'throw'/'emit', compensation is a subprocess's undo block, ` + - `and a cancel is written on the end that gives up an 'attempt' block.`; const awaitNameRequired = (kind: string) => `An awaited ${kind} needs the ${kind}'s name: the engine matches ${kind}s by name.`; const AWAIT_CONDITION_REQUIRED = @@ -840,18 +526,6 @@ const AWAIT_PARTICLE_ONLY = "Only 'await timer' takes a particle."; // Link events. -const LINK_CATCH_FLOW = - "Nothing may flow into an 'await link': end the path before it with 'end', " + - "'throw', 'goto', or 'emit link', because a link catch is entered only by " + - "'emit link' of the same name."; -const LINK_IN_RACE = - "'link' cannot head a branch of an 'await' block: the engine refuses a link " + - `catch after an event-based gateway; write 'await link("")' as its ` + - 'own statement.'; -const linkThrowNeverRuns = (item: string) => - `${item} has no effect on an emitted link: the engine creates no activity ` + - "for a link throw, so nothing written on it runs. Put it on the 'await " + - "link' of the same name instead."; const linkNoCatch = (name: string) => `No 'await link("${name}")' catches this link, and the engine refuses to ` + 'deploy an emitted link with no catch of its name. Write one where the ' + @@ -861,7 +535,7 @@ const linkOtherContainer = (name: string) => "handler body as its 'await link': a link cannot cross a subprocess or " + "handler boundary, the same way a 'goto' cannot."; const linkIntoBranch = (name: string, keyword: 'parallel' | 'await') => - intoBranch(`emit link("${name}")`, 'emit link', keyword); + intoBranchMessage(`emit link("${name}")`, 'emit link', keyword); const linkNameTaken = (name: string) => `Another 'await link("${name}")' already catches this link: the engine ` + 'keeps one catch per link name in the whole file, even across subprocesses.'; @@ -871,17 +545,9 @@ const linkUnused = (name: string) => const gotoToLink = (target: string) => `'goto ${target}' cannot target an awaited link: a link catch is entered ` + "by 'emit link' of the same name, not by a sequence flow."; -const THROW_LINK = - "A link continues at its catch rather than ending the path; write 'emit link'."; // Code declarations. -const unknownCodeDeclarationKind = (word: string) => - `Unknown declaration kind '${word}'; write 'error' or 'escalation', or a ` + - 'step keyword if a step was meant.'; -const DECLARATION_SETTINGS_ONLY = - `A declaration's parens take only 'code' or 'message' settings, ` + - "written 'key: value'."; const declarationNotAString = (kind: string, key: string) => `An ${kind} declaration's ${key} must be a quoted string.`; const declarationEmpty = (kind: string, key: string) => @@ -890,9 +556,6 @@ const alreadyDeclared = (kind: string, name: string) => `'${name}' is already declared in this process; '${kind}(${name})' would be ambiguous.`; const duplicateDeclaredCode = (kind: string, code: string, owner: string) => `${capitalized(kind)} code '${code}' is already declared by '${owner}'; two declarations cannot share a code.`; -const quotedCode = (kind: string, declaration: string, use: string) => - `An ${kind} code is a declared name, not quoted text. ` + - `Declare '${declaration}' in the process header and write '${kind}(${use})'.`; // ── Cases ─────────────────────────────────────────────────────────────────── @@ -907,16 +570,6 @@ checks('Validation - variables in expressions', [ `process p { var amount: number if (amount > 1000) { user A } }`, [], ], - [ - 'a header var is in scope for a reference further down the body', - `process p { var amount: number if (amount > 1000) { user A } end Done }`, - [], - ], - [ - 'the same reference without the declaration warns', - `process p { if (amount > 1000) { user A } end Done }`, - [warn(undeclared('amount'))], - ], [ 'a dotted formKey names a form, not a variable', `process p { user T(formKey: forms.review) }`, @@ -977,11 +630,6 @@ checks('Validation - variables in expressions', [ ], ]); -/** - * Revert check for the `assignee` rows: dropping the key from - * `NON_VARIABLE_ATTR_KEYS` brings the undeclared warning back on `demo` and - * the hyphen error beside it on `john-doe`. - */ checks('Validation - barewords in engine-side attribute values', [ [ 'a bareword candidateGroups reaches the engine as written', @@ -1040,40 +688,36 @@ checks('Validation - barewords in engine-side attribute values', [ ], ]); -/** - * Revert checks: dropping the word set from the spelling check turns the - * keyword rows red, dropping the hyphen test the `my-flag` row. - */ checks('Validation - a JUEL keyword or a hyphen in a rendered name', [ ...JUEL_RESERVED_WORDS.flatMap((word): Case[] => [ [ `'${word}' as a variable is an operator to the engine, at the declaration and at the use`, `process p { var ${word}: boolean if (${word}) { user A } }`, [ - juelKeyword(word, `execution.getVariable('${word}')`), - juelKeyword(word, `execution.getVariable('${word}')`), + juelKeywordMessage(word, `execution.getVariable('${word}')`), + juelKeywordMessage(word, `execution.getVariable('${word}')`), ], ], [ `'${word}' as a property is an operator to the engine`, `process p { var order: json if (order.${word}) { user A } }`, - [juelKeyword(word, `order['${word}']`)], + [juelKeywordMessage(word, `order['${word}']`)], ], [ `'${word}' as a property inside a raw template is the same operator`, `process p { if ("\${order.${word}}") { user A } }`, - [juelKeyword(word, `order['${word}']`)], + [juelKeywordMessage(word, `order['${word}']`)], ], ]), [ "'true' as a property inside a raw template is the same operator", `process p { if ("\${order.true}") { user A } }`, - [juelKeyword('true', "order['true']")], + [juelKeywordMessage('true', "order['true']")], ], [ 'a keyword read off a bracketed object in a raw template is keyed on that object', `process p { if ("\${items[0].and}") { user A } }`, - [juelKeyword('and', "items[0]['and']")], + [juelKeywordMessage('and', "items[0]['and']")], ], [ 'a keyword inside a JUEL string literal or between two templates is text, not a read', @@ -1083,23 +727,23 @@ checks('Validation - a JUEL keyword or a hyphen in a rendered name', [ [ 'a keyword read in a listener timeout template is the same operator', `process p { user A { on timeout after "\${order.and}"(class: "c.X") } }`, - [juelKeyword('and', "order['and']")], + [juelKeywordMessage('and', "order['and']")], ], [ 'a hyphenated variable scans as a subtraction', `process p { var my-flag: boolean if (my-flag) { user A } }`, - [hyphenName('my-flag', "execution.getVariable('my-flag')")], + [hyphenNameMessage('my-flag', "execution.getVariable('my-flag')")], ], [ 'a hyphenated property scans the same way, keyed on its object', `process p { var order: json if (order.line.is-paid) { user A } }`, - [hyphenName('is-paid', "order.line['is-paid']")], + [hyphenNameMessage('is-paid', "order.line['is-paid']")], ], [ 'an undeclared hyphenated variable keeps its warning beside the error', `process p { if (my-flag) { user A } }`, [ - hyphenName('my-flag', "execution.getVariable('my-flag')"), + hyphenNameMessage('my-flag', "execution.getVariable('my-flag')"), warn(undeclared('my-flag')), ], ], @@ -1116,7 +760,7 @@ checks('Validation - a JUEL keyword or a hyphen in a rendered name', [ [ 'a collection under an accessor is evaluated, so its spelling counts', `process p { var order: json user U for each x in order.line-items }`, - [hyphenName('line-items', "order['line-items']")], + [hyphenNameMessage('line-items', "order['line-items']")], ], [ 'a class name and a topic reach the engine as written', @@ -1125,7 +769,6 @@ checks('Validation - a JUEL keyword or a hyphen in a rendered name', [ ], ]); -/** Each position wrapping the same number variable, so the rule is pinned per position. */ const CONDITION_POSITIONS: Array<[title: string, at: (c: string) => string]> = [ ['if', (c) => `process p { var n: number if (${c}) { user A } }`], [ @@ -1162,10 +805,6 @@ const CONDITION_POSITIONS: Array<[title: string, at: (c: string) => string]> = [ ], ]; -/** - * Revert checks: dropping the `Additive` arm turns the `n + 1` row red, - * dropping the position list the per-position rows. - */ checks('Validation - a condition must be boolean', [ ...CONDITION_POSITIONS.map(([title, at]): Case => [ `${title} refuses a number variable`, @@ -1175,7 +814,7 @@ checks('Validation - a condition must be boolean', [ [ "an 'until' clause cites the multi-instance behaviour", `process p { user U for 3 until (nrOfCompletedInstances) }`, - [nonBooleanUntil("a variable of type 'number'")], + [nonBooleanConditionMessage("a variable of type 'number'", 'until')], ], [ 'a string literal', @@ -1245,27 +884,26 @@ checks('Validation - a condition must be boolean', [ ], ]); -/** Revert check: dropping `singleTemplateBody` from the nested check turns the negated row red. */ checks('Validation - a composite raw template cannot be an operand', [ [ 'negated', `process p { if (!"\${a} and \${b}") { user A } }`, - [COMPOSITE_OPERAND], + [COMPOSITE_OPERAND_MESSAGE], ], [ 'compared', `process p { if ("\${a} b" == "x") { user A } }`, - [COMPOSITE_OPERAND], + [COMPOSITE_OPERAND_MESSAGE], ], [ 'as an index', `process p { var m: json if (m["\${a} \${b}"]) { user A } }`, - [COMPOSITE_OPERAND], + [COMPOSITE_OPERAND_MESSAGE], ], [ 'in a ternary arm', `process p { var f: boolean if (f ? "\${a} \${b}" : true) { user A } }`, - [COMPOSITE_OPERAND], + [COMPOSITE_OPERAND_MESSAGE], ], [ 'one template under an operator splices in', @@ -1284,45 +922,41 @@ checks('Validation - a composite raw template cannot be an operand', [ ], ]); -/** - * Revert checks: dropping the `LiteralString` arm turns the quoted-delegate - * row red, dropping the blank test the empty rows. - */ checks('Validation - binding values', [ [ 'a quoted delegate on a service task never resolves', `process p { service S(delegate: "bean") }`, - [literalElBinding('delegate', NO_DELEGATE)], + [literalElBindingMessage('delegate')], ], [ 'a quoted expression on a listener evaluates to its own text', `process p { user U { on start(expression: "bean.run()") } }`, - [literalElBinding('expression', EVALUATES_ITSELF)], + [literalElBindingMessage('expression')], ], [ 'a quoted mapperDelegate on a call never resolves', `process p { call C(process: "q", mapperDelegate: "bean") }`, - [literalElBinding('mapperDelegate', NO_DELEGATE)], + [literalElBindingMessage('mapperDelegate')], ], [ 'a quoted expression on a thrown message evaluates to its own text', `process p { start S throw message("Ack", expression: "b") }`, - [literalElBinding('expression', EVALUATES_ITSELF)], + [literalElBindingMessage('expression')], ], [ 'a template as a class is loaded as a class name', `process p { service S(class: "\${cls}") }`, - [templateAsClass('class', 'delegate')], + [templateAsClassMessage('class', 'delegate')], ], [ 'a template as a listener class is loaded the same way', `process p { user U { on start(class: "\${cls}") } }`, - [templateAsClass('class', 'delegate')], + [templateAsClassMessage('class', 'delegate')], ], [ 'a template as a mapper is loaded the same way', `process p { call C(process: "q", mapper: "\${cls}") }`, - [templateAsClass('mapper', 'mapperDelegate')], + [templateAsClassMessage('mapper', 'mapperDelegate')], ], ...( [ @@ -1367,22 +1001,21 @@ checks('Validation - binding values', [ ], ]); -/** Revert check: dropping the `EXPRESSION_OPEN` test turns the escaped row red. */ checks('Validation - an escaped literal as a field value', [ [ 'quoted text opening with an escaped template has no field slot', `process p { service S(class: "com.acme.D") { field x = "\\\${y" } }`, - [escapedFieldLiteral('x')], + [escapedFieldLiteralMessage('x')], ], [ 'the hash opener and leading whitespace are the same slot', `process p { service S(class: "com.acme.D") { field x = "\\#{y}" field z = " \\\${y}" } }`, - [escapedFieldLiteral('x'), escapedFieldLiteral('z')], + [escapedFieldLiteralMessage('x'), escapedFieldLiteralMessage('z')], ], [ 'on a listener as well', `process p { user U { on start(class: "com.acme.L") { field x = "\\\${y" } } }`, - [escapedFieldLiteral('x')], + [escapedFieldLiteralMessage('x')], ], [ 'an opener further in is plain text', @@ -1392,12 +1025,12 @@ checks('Validation - an escaped literal as a field value', [ [ 'an empty literal has neither a fixed value nor an expression', `process p { service S(class: "com.acme.D") { field x = "" } }`, - [emptyField('x')], + [emptyFieldMessage('x')], ], [ 'an empty literal on a listener as well', `process p { user U { on start(class: "com.acme.L") { field x = "" } } }`, - [emptyField('x')], + [emptyFieldMessage('x')], ], ]); @@ -1410,12 +1043,16 @@ checks('Validation - attribute keys and value shapes', [ [ 'a second unkeyed value is dropped, so it is refused', `process p { var x: number start S await message("M", x > 1) }`, - [SECOND_PAREN_VALUE], + [SECOND_PAREN_VALUE_MESSAGE], ], [ 'the refusal counts one per extra value, whatever the element', `process p { start S user T("a", "b", "c") }`, - [settingsOnly('a user task'), SECOND_PAREN_VALUE, SECOND_PAREN_VALUE], + [ + settingsOnlyMessage('a user task'), + SECOND_PAREN_VALUE_MESSAGE, + SECOND_PAREN_VALUE_MESSAGE, + ], ], ...( [ @@ -1436,17 +1073,12 @@ checks('Validation - attribute keys and value shapes', [ ).map(([title, body, description]): Case => [ `an unkeyed value on ${title} is nothing the lowering reads, so it is refused`, `process p { ${body} }`, - [settingsOnly(description)], + [settingsOnlyMessage(description)], ]), [ 'an unkeyed value on the process header is refused the same way', `process p("a") { user T }`, - [settingsOnly('a process header')], - ], - [ - 'two different keys are clean', - `process p { user T(assignee: "a", formKey: "f") }`, - [], + [settingsOnlyMessage('a process header')], ], [ 'assignee on a service task is not valid there', @@ -1456,51 +1088,26 @@ checks('Validation - attribute keys and value shapes', [ bindingRequired(`Service task 'S'`, SERVICE_BINDINGS), ], ], - [ - 'class on a user task is not valid there', - `process p { user T(class: com.example.X) }`, - [notValidOn('class', 'a user task')], - ], - [ - 'formKey on a service task is not valid there', - `process p { service S(class: com.example.X, formKey: "f") }`, - [notValidOn('formKey', 'a service task')], - ], - [ - 'each kind writing only its own keys is clean', - `process p { user T(assignee: "a", formKey: "f") service S(class: com.example.X) }`, - [], - ], - [ - 'assignee on a call is not valid there', - `process p { call X(process: "p", assignee: "x") }`, - [notValidOn('assignee', 'a call')], - ], - [ - 'process on a user task is not valid there', - `process p { user T(process: "p") }`, - [notValidOn('process', 'a user task')], - ], - [ - 'resultVariable on a user task is not valid there', - `process p { user U(resultVariable: "r") }`, - [notValidOn('resultVariable', 'a user task')], - ], - [ - 'businessKey on a user task is not valid there', - `process p { user U(businessKey: "k") }`, - [notValidOn('businessKey', 'a user task')], - ], - [ - 'assignee on a subprocess is not valid there', - `process p { subprocess S(assignee: "a") { user U } }`, - [notValidOn('assignee', 'a subprocess')], - ], - [ - 'topic on an end event is not valid there', - `process p { start S end E(topic: "t") }`, - [notValidOn('topic', 'an end event')], - ], + ...( + [ + ['class', 'user T(class: com.example.X)', 'a user task'], + [ + 'formKey', + 'service S(class: com.example.X, formKey: "f")', + 'a service task', + ], + ['assignee', 'call X(process: "p", assignee: "x")', 'a call'], + ['process', 'user T(process: "p")', 'a user task'], + ['resultVariable', 'user U(resultVariable: "r")', 'a user task'], + ['businessKey', 'user U(businessKey: "k")', 'a user task'], + ['assignee', 'subprocess S(assignee: "a") { user U }', 'a subprocess'], + ['topic', 'start S end E(topic: "t")', 'an end event'], + ] as const + ).map(([key, body, description]): Case => [ + `${key} on ${description} is not valid there`, + `process p { ${body} }`, + [notValidOn(key, description)], + ]), [ 'a user task accepts every key it owns', `process p { user U(assignee: "demo", formKey: "f", candidateGroups: "approvers", candidateUsers: "ada", dueDate: "2026-09-01T09:00:00", followUpDate: "2026-08-30T09:00:00", priority: 10) }`, @@ -1511,21 +1118,6 @@ checks('Validation - attribute keys and value shapes', [ `process p { script T(resultVariable: "total") ${FENCE}js\n1 + 1\n${FENCE} }`, [], ], - [ - 'a version-pinned call accepts version and businessKey', - `process p { call C(process: "q", version: 1, businessKey: "k") }`, - [], - ], - [ - 'a binding-pinned call accepts binding', - `process p { call C(process: "q", binding: latest) }`, - [], - ], - [ - 'every boolean attribute takes an unquoted true or false', - `process p { user U(asyncBefore: true, asyncAfter: false, exclusive: true) }`, - [], - ], [ 'a quoted boolean is one error naming the unquoted form', `process p { user U(asyncBefore: true, exclusive: "true") }`, @@ -1596,18 +1188,8 @@ checks('Validation - service, send, and decision bindings', [ ], ], [ - 'an expression binding alone is enough', - `process p { service S(expression: "\${bean.method(execution)}") }`, - [], - ], - [ - 'a delegate binding alone is enough', - `process p { service S(delegate: "\${beanName}") }`, - [], - ], - [ - 'a topic binding alone is enough', - `process p { service S(topic: "shipping") }`, + 'an expression, a delegate, or a topic binding alone is enough', + `process p { service S(expression: "\${bean.method(execution)}") service T(delegate: "\${beanName}") service U(topic: "shipping") }`, [], ], [ @@ -1618,34 +1200,26 @@ checks('Validation - service, send, and decision bindings', [ [ 'a service task cannot carry resultVariable beside class: the engine refuses to deploy it', `process p { service V(class: com.example.X, resultVariable: "outcome") }`, - [resultVariableBinding('A service task', 'class', 'serviceTask', 'class')], + [resultVariableBindingMessage('A service task', 'class', 'serviceTask')], ], [ 'a service task cannot carry resultVariable beside delegate either', `process p { service V(delegate: "\${bean}", resultVariable: "outcome") }`, - [ - resultVariableBinding( - 'A service task', - 'delegate', - 'serviceTask', - 'delegateExpression', - ), - ], + [resultVariableBindingMessage('A service task', 'delegate', 'serviceTask')], ], [ 'a send task bound with class refuses resultVariable under its own element name', `process p { send N(class: com.example.X, resultVariable: "outcome") }`, - [resultVariableBinding('A send task', 'class', 'sendTask', 'class')], + [resultVariableBindingMessage('A send task', 'class', 'sendTask')], ], [ 'a decision step bound with delegate refuses resultVariable under its own element name', `process p { decide D(delegate: "\${bean}", resultVariable: "outcome") }`, [ - resultVariableBinding( + resultVariableBindingMessage( 'A decision step', 'delegate', 'businessRuleTask', - 'delegateExpression', ), ], ], @@ -1695,7 +1269,6 @@ checks('Validation - service, send, and decision bindings', [ `process p { decide D(decision: "riskRating", mapDecisionResult: nonsense) }`, [MAP_DECISION_RESULT], ], - ['a receive task needs no binding at all', `process p { receive R }`, []], [ 'a receive task takes a message name', `process p { receive R(message: "OrderPaid") }`, @@ -1703,33 +1276,29 @@ checks('Validation - service, send, and decision bindings', [ ], ]); -/** - * Settings the engine deploys and then reads at the step, or never. Revert - * checks: the `topic`/`type` arm of the result-variable check removed turns - * the two warned rows clean, the mapping warning removed the `singleEntry` - * row, the decision-modifier check removed the three refused rows. - */ checks('Validation - settings the engine reads under one binding alone', [ [ 'resultVariable beside a topic warns that nothing writes it', `process p { service V(topic: "t", resultVariable: "r") }`, - [warn(resultVariableUnread('topic'))], + [warn(resultVariableUnreadMessage('topic'))], ], [ 'resultVariable beside a topic on a decision step warns the same way', `process p { decide D(topic: "t", resultVariable: "r") }`, - [warn(resultVariableUnread('topic'))], + [warn(resultVariableUnreadMessage('topic'))], ], [ 'mapDecisionResult with no resultVariable is built and never applied', `process p { decide D(decision: "riskRating", mapDecisionResult: singleEntry) }`, - [warn(MAP_DECISION_RESULT_UNREAD)], - ], - [ - 'mapDecisionResult with resultVariable is applied when the result is stored', - `process p { decide D(decision: "riskRating", mapDecisionResult: singleEntry, resultVariable: "r") }`, - [], + [warn(MAP_DECISION_RESULT_UNREAD_MESSAGE)], ], + ...['singleEntry', 'singleResult', 'collectEntries', 'resultList'].map( + (mapping): Case => [ + `mapDecisionResult ${mapping} with resultVariable is applied when the result is stored, and names no variable`, + `process p { decide D(decision: "riskRating", mapDecisionResult: ${mapping}, resultVariable: "r") }`, + [], + ], + ), ...[ ['binding', 'binding: latest'], ['version', 'version: 2'], @@ -1737,22 +1306,20 @@ checks('Validation - settings the engine reads under one binding alone', [ ].map(([key, setting]): Case => [ `${key} on a decision step bound by code is read by nothing, so it is refused`, `process p { decide D(class: "com.example.Rate", ${setting}) }`, - [decisionModifierWithoutDecision(key!)], + [decisionModifierMessage(key!)], ]), [ 'two modifiers beside a topic are each refused once', `process p { decide D(topic: "t", binding: latest, mapDecisionResult: singleEntry) }`, [ - decisionModifierWithoutDecision('binding'), - decisionModifierWithoutDecision('mapDecisionResult'), + decisionModifierMessage('binding'), + decisionModifierMessage('mapDecisionResult'), ], ], ]); -/** One user task carrying the setting alone, so each row is one shape and its whole list. */ const userWith = (setting: string) => `process p { user U(${setting}) }`; -/** The three elements pinning a deployed version, each wrapping one `version` value. */ const VERSION_CARRIERS: Array<[carrier: string, at: (v: string) => string]> = [ ['a call', (v) => `process p { call C(process: "q", version: ${v}) }`], [ @@ -1765,12 +1332,6 @@ const VERSION_CARRIERS: Array<[carrier: string, at: (v: string) => string]> = [ ], ]; -/** - * Values the engine deploys as written and parses when the token reaches the - * step. Revert checks: `priority` dropped from `PRIORITY_ATTR_KEYS` turns its - * refused rows clean, the `version` rule removed its refused rows on all - * three carriers, the due-date text check removed the `"tomorrow"` row. - */ checks('Validation - values the engine parses when the step runs', [ ...['7', '"7"', '"${p}"', 'weight'].map((value): Case => [ `priority ${value} parses or evaluates to an integer`, @@ -1780,7 +1341,7 @@ checks('Validation - values the engine parses when the step runs', [ ...['1.5', '"high"'].map((value): Case => [ `priority ${value} fails the task creation, so it is refused`, userWith(`priority: ${value}`), - [USER_PRIORITY_SHAPE], + [priorityShapeMessage('priority')], ]), ...VERSION_CARRIERS.flatMap(([carrier, at]): Case[] => [ ...['2', '"2"', '"${v}"'].map((value): Case => [ @@ -1791,7 +1352,7 @@ checks('Validation - values the engine parses when the step runs', [ ...['1.5', '"abc"', '-1', '0', 'v'].map((value): Case => [ `version ${value} on ${carrier} fails at the step, so it is refused`, at(value), - [VERSION_SHAPE], + [VERSION_SHAPE_MESSAGE], ]), ]), ...['"P2D"', '"2026-01-01T00:00:00"', '"${d}"'].map((value): Case => [ @@ -1802,7 +1363,7 @@ checks('Validation - values the engine parses when the step runs', [ [ 'a due date the calendar cannot resolve is refused on both keys', userWith('dueDate: "tomorrow", followUpDate: "next week"'), - [dueDateShape('dueDate'), dueDateShape('followUpDate')], + [dueDateShapeMessage('dueDate'), dueDateShapeMessage('followUpDate')], ], ...['"PT10M"', '"R3/PT10M"', '"PT5M,PT10M"', '"${r}"'].map((value): Case => [ `retryCycle ${value} is a shape the retry parser resolves`, @@ -1812,17 +1373,10 @@ checks('Validation - values the engine parses when the step runs', [ [ 'a retryCycle the parser cannot resolve is dropped, so it only warns', userWith('asyncBefore: true, retryCycle: "bogus"'), - [warn(RETRY_CYCLE_SHAPE)], + [warn(RETRY_CYCLE_SHAPE_MESSAGE)], ], ]); -/** - * The three checks `BpmnParse.parseServiceTaskLike` runs before it builds a - * mail or shell behaviour, each mirrored so a clean script deploys. Revert - * checks: dropping `['text', 'html']` from the required table turns the - * body rows red, removing the flag check the `"True"` row, and removing the - * declared-name check the `recipient` row. - */ checks('Validation - the mail and shell bindings', [ [ 'a mail task with a recipient and a text body is clean', @@ -1847,23 +1401,21 @@ checks('Validation - the mail and shell bindings', [ [ 'an upper-case type is refused: one printed form keeps the round trip stable', `process p { service N(type: "MAIL") }`, - [TYPE_VALUE], + [TYPE_VALUE_MESSAGE], ], [ 'a type the engine has no behaviour for names the two it has', `process p { service N(type: "ftp") }`, - [TYPE_VALUE], + [TYPE_VALUE_MESSAGE], ], [ 'a cc does not stand in for the recipient', `process p { service N(type: "mail") { field cc = "c@d" field text = "t" } }`, [ - missingBuiltinField( + missingBuiltinFieldMessage( `Service task 'N'`, 'mail', - "'to'", - 'No recipient is defined on the mail activity', - 'validateFieldDeclarationsForEmail', + BUILTIN_REQUIRED_FIELDS.mail[0]!, ), ], ], @@ -1871,12 +1423,10 @@ checks('Validation - the mail and shell bindings', [ 'a subject does not stand in for the body', `process p { send N(type: "mail") { field to = "a@b" field subject = "s" } }`, [ - missingBuiltinField( + missingBuiltinFieldMessage( `Send task 'N'`, 'mail', - "'text' or 'html'", - 'Text or html field should be provided', - 'validateFieldDeclarationsForEmail', + BUILTIN_REQUIRED_FIELDS.mail[1]!, ), ], ], @@ -1889,19 +1439,15 @@ checks('Validation - the mail and shell bindings', [ 'a mail task with no field at all draws one refusal per requirement', `process p { service N(type: "mail") }`, [ - missingBuiltinField( + missingBuiltinFieldMessage( `Service task 'N'`, 'mail', - "'to'", - 'No recipient is defined on the mail activity', - 'validateFieldDeclarationsForEmail', + BUILTIN_REQUIRED_FIELDS.mail[0]!, ), - missingBuiltinField( + missingBuiltinFieldMessage( `Service task 'N'`, 'mail', - "'text' or 'html'", - 'Text or html field should be provided', - 'validateFieldDeclarationsForEmail', + BUILTIN_REQUIRED_FIELDS.mail[1]!, ), ], ], @@ -1909,53 +1455,37 @@ checks('Validation - the mail and shell bindings', [ 'a shell task without a command names the engine check', `process p { send S(type: "shell") { field wait = "true" } }`, [ - missingBuiltinField( + missingBuiltinFieldMessage( `Send task 'S'`, 'shell', - "'command'", - 'No shell command is defined on the shell activity', - 'validateFieldDeclarationsForShell', + BUILTIN_REQUIRED_FIELDS.shell[0]!, ), ], ], [ 'a shell field written as an expression fails the deployment, so it is refused', `process p { service R(type: "shell") { field command = "\${cmd}" } }`, - [shellFieldExpression('command')], + [shellFieldExpressionMessage('command')], ], [ 'a shell flag the engine would read as false is refused', `process p { service R(type: "shell") { field command = "ls" field wait = "True" } }`, - [shellFlagValue('wait')], + [shellFlagValueMessage('wait')], ], [ 'a field the mail behaviour does not declare names the ones it does', `process p { service N(type: "mail") { field to = "a@b" field text = "t" field recipient = "x" } }`, - [ - unknownBuiltinField( - 'recipient', - 'mail', - 'MailActivityBehavior', - MAIL_FIELDS, - ), - ], + [unknownBuiltinFieldMessage('recipient', 'mail')], ], [ 'a field the shell behaviour does not declare names the ones it does', `process p { service R(type: "shell") { field command = "ls" field args = "x" } }`, - [ - unknownBuiltinField( - 'args', - 'shell', - 'ShellActivityBehavior', - SHELL_FIELDS, - ), - ], + [unknownBuiltinFieldMessage('args', 'shell')], ], [ "a field's own shape is refused first, and the shell rules stand down", `process p { service R(type: "shell") { field command = ["ls"] } }`, - [fieldValue('command')], + [fieldValueMessage('command')], ], [ 'a type beside a code binding is the one-binding conflict and nothing more', @@ -1980,23 +1510,10 @@ checks('Validation - the mail and shell bindings', [ [ 'a result variable beside a type warns that nothing writes it, as beside a topic', `process p { service R(type: "shell", resultVariable: "r") { field command = "ls" } }`, - [warn(resultVariableUnread('type'))], + [warn(resultVariableUnreadMessage('type'))], ], ]); -describe('Validation - decision result mappings', () => { - test.each(['singleEntry', 'singleResult', 'collectEntries', 'resultList'])( - '`mapDecisionResult = %s` is clean, and names no variable', - async (mapping) => { - expect( - await diagnosticsOf( - `process p { decide D(decision: "riskRating", mapDecisionResult: ${mapping}, resultVariable: "rating") }`, - ), - ).toEqual([]); - }, - ); -}); - checks('Validation - script tasks and fenced scripts', [ [ 'an unsupported language tag names the tag and the supported ones', @@ -2026,23 +1543,8 @@ checks('Validation - goto', [ [missingStep('Missing')], ], [ - 'a goto resolving to a user task is clean', - `process p { user Foo goto Foo }`, - [], - ], - [ - 'a goto resolving to a topic-bound service task is clean', - `process p { service Ship(topic: "shipping") goto Ship }`, - [], - ], - [ - 'a goto resolving to a script task is clean', - `process p { script Compute ${FENCE}js\nx = 1\n${FENCE} goto Compute }`, - [], - ], - [ - 'a goto resolving to a call activity is clean', - `process p { call F(process: "p") goto F }`, + 'a goto resolves to a user task, a service task, a script task, and a call', + `process p { var c: boolean user Foo service Ship(topic: "shipping") script Compute ${FENCE}js\nx = 1\n${FENCE} call F(process: "p") if (c) { goto Foo } if (c) { goto Ship } if (c) { goto Compute } goto F }`, [], ], [ @@ -2060,7 +1562,6 @@ checks('Validation - goto', [ `process p { parallel { { user A goto A } { user B } } }`, [], ], - ['a goto outside every branch is clean', `process p { user A goto A }`, []], [ 'a goto from outside into a race branch names the await statement', `process p { await { message("M") { user A } signal("S") { user B } } goto A }`, @@ -2085,7 +1586,6 @@ checks('Validation - a body with no flow steps', [ [noFlowSteps('p')], ], ['a start alone is a flow step', `process p { start S }`, []], - ['a start and an end are flow steps', `process p { start S end E }`, []], [ 'an empty subprocess body names the subprocess head', `process p { subprocess S { } }`, @@ -2101,11 +1601,6 @@ checks('Validation - a body with no flow steps', [ `process p { attempt A { } }`, [blockNoFlowSteps('an attempt block', 'A')], ], - [ - 'a subprocess with one step is clean', - `process p { subprocess S { user A } }`, - [], - ], ]); checks('Validation - empty branches and bodies', [ @@ -2127,12 +1622,12 @@ checks('Validation - empty branches and bodies', [ [ "an empty 'while' body would lose the loop's condition", `process p { var flag: boolean while (flag == true) { } }`, - [emptyLoopBody('while')], + [emptyLoopBodyMessage('while')], ], [ "an empty 'do ... while' body would lose the loop's condition", `process p { var flag: boolean do { } while (flag == true) }`, - [emptyLoopBody('do')], + [emptyLoopBodyMessage('do')], ], [ 'a parameter written in a handler body configures nothing', @@ -2164,17 +1659,6 @@ checks('Validation - empty branches and bodies', [ `process p { error X on error(X) { } }`, [noFlowSteps('p'), warn(emptyBranch('event handler'))], ], - [ - 'populated branches are clean', - `process p { - var flag: boolean - if (flag == true) { user A } else if (flag == false) { user B } else { user C } - while (flag == true) { user D } - do { user E } while (flag == true) - parallel { { user F } { user G } } -}`, - [], - ], ]); checks('Validation - reserved synthesized-id names', [ @@ -2245,9 +1729,6 @@ checks('Validation - reserved synthesized-id names', [ reservedName('Catch_x'), ], ], - // The implicit start and end are reserved as the exact ids the compiler - // mints for the body the statement sits in, not as prefixes: a modelling - // tool's default `StartEvent_1` is an ordinary name. [ 'the exact implicit start and end of the process body are reserved', `process p { user StartEvent_p user EndEvent_p }`, @@ -2315,19 +1796,13 @@ checks('Validation - one process per file', [ `process Invoice { start S end E }\nprocess Shipping { start S end E }`, [ONE_PROCESS_ONLY], ], - [ - 'a second process repeating the name is flagged the same way', - `process Invoice { start S end E }\nprocess Invoice { start S end E }`, - [ONE_PROCESS_ONLY], - ], - ['a single process is clean', `process Invoice { start S end E }`, []], ]); checks('Validation - where a start may sit', [ [ 'a start after a chain that still flows on interrupts it, and is a second plain start beside the minted one', `process p { user A start S end E }`, - [startAfterLiveChain('S'), START_AFTER_IMPLICIT_START], + [startAfterLiveChain('S'), START_AFTER_IMPLICIT_START_MESSAGE], ], [ 'starts written back to back open one chain', @@ -2349,16 +1824,6 @@ checks('Validation - where a start may sit', [ `process p { start S if (true) { start Nested } end E }`, [startNotFirst('Nested')], ], - [ - 'a start as the first statement is clean', - `process p { start S user A end E }`, - [], - ], - [ - 'a process with no explicit start is clean', - `process p { user A end E }`, - [], - ], [ 'a start opening a host-less handler body is legal', `process p { error PF service A(class: "x.A") on error(PF) { start S service R(class: "x.R") } }`, @@ -2367,7 +1832,7 @@ checks('Validation - where a start may sit', [ [ 'a start opening a hosted handler body has no scope of its own', `process p { error PF service A(class: "x.A") on A: error(PF) { start S service R(class: "x.R") } }`, - [hostedHandlerStart('S')], + [hostedHandlerStartMessage('S')], ], [ 'a start first in a subprocess body is clean', @@ -2379,18 +1844,13 @@ checks('Validation - where a start may sit', [ `process p { subprocess S { user A start In } }`, [startNotFirst('In')], ], - [ - 'a start inside an if nested in a subprocess is one error', - `process p { subprocess S { if (true) { start In } } }`, - [startNotFirst('In')], - ], ]); checks('Validation - the default start among several', [ [ 'message and signal starts alone leave no default start', `process p { start A message("M") start B signal("S") user T end E }`, - [warn(noDefaultStart('p'))], + [warn(noDefaultStartMessage('p'))], ], [ 'a timer start beside a message start is the default', @@ -2405,7 +1865,7 @@ checks('Validation - the default start among several', [ [ 'a form on a start that is not the default is never offered', `process p { start A start B message("M") { form { x: number "X" } } user T end E }`, - [warn(FORM_NEVER_OFFERED)], + [warn(FORM_NEVER_OFFERED_MESSAGE)], ], [ 'a form on the default start beside a message start is offered', @@ -2415,12 +1875,12 @@ checks('Validation - the default start among several', [ [ 'with no default start every form is dead', `process p { start A message("M") { form { x: number "X" } } start B signal("S") user T end E }`, - [warn(noDefaultStart('p')), warn(FORM_NEVER_OFFERED)], + [warn(noDefaultStartMessage('p')), warn(FORM_NEVER_OFFERED_MESSAGE)], ], [ 'initiator on more than one start warns on every start but the last', `process p { start A(initiator: "a") start B message("M", initiator: "b") start C signal("S", initiator: "c") user T end E }`, - [warn(INITIATOR_SHADOWED), warn(INITIATOR_SHADOWED)], + [warn(INITIATOR_SHADOWED_MESSAGE), warn(INITIATOR_SHADOWED_MESSAGE)], ], [ 'initiator on one start beside a start with none shadows nothing', @@ -2439,7 +1899,7 @@ checks('Validation - the default start among several', [ ).map(([title, starts]): Case => [ `${title} is a second default start the engine refuses`, `process p { ${starts} user T end E }`, - [SECOND_DEFAULT_START], + [SECOND_DEFAULT_START_MESSAGE], ]), ...( [ @@ -2449,32 +1909,41 @@ checks('Validation - the default start among several', [ ).map(([title, start]): Case => [ `${title} after a body that does not open with a start is the second default start`, `process invoice_batch { end Done ${start} }`, - [START_AFTER_IMPLICIT_START], + [START_AFTER_IMPLICIT_START_MESSAGE], ]), [ 'a message start after such a body leaves the compiler-minted start the default, so its form is never offered', `process p { user T end Done start S message("M") { form { x: number "X" } } }`, - [warn(FORM_NEVER_OFFERED)], + [warn(FORM_NEVER_OFFERED_MESSAGE)], ], [ 'two message starts of one name are one subscription too many', `process p { start A message("M") start B message("M") user T end E }`, - [warn(noDefaultStart('p')), duplicateNamedStart('message', 'M')], + [ + warn(noDefaultStartMessage('p')), + duplicateNamedStartMessage('message', 'M'), + ], ], [ 'two signal starts of one name are one subscription too many', `process p { start A signal("S") start B signal("S") user T end E }`, - [warn(noDefaultStart('p')), duplicateNamedStart('signal', 'S')], + [ + warn(noDefaultStartMessage('p')), + duplicateNamedStartMessage('signal', 'S'), + ], ], [ 'two condition starts on one condition are one subscription too many', `process p { var x: number start A condition(x > 1) start B condition(x > 1) user T end E }`, - [warn(noDefaultStart('p')), duplicateConditionStart('${x > 1}')], + [ + warn(noDefaultStartMessage('p')), + duplicateConditionStartMessage('${x > 1}'), + ], ], [ 'starts of one kind with different payloads coexist', `process p { var x: number start A message("M") start B message("N") start C signal("M") start D condition(x > 1) start E condition(x > 2) user T end F }`, - [warn(noDefaultStart('p'))], + [warn(noDefaultStartMessage('p'))], ], ]); @@ -2484,17 +1953,11 @@ checks('Validation - duplicate declarations', [ `process p { var total: number var total: string start S end E }`, [duplicateVariable('total', 'p')], ], - [ - 'two vars with different names are clean', - `process p { var total: number var quantity: number start S end E }`, - [], - ], [ 'two labels on one process is one error', `process p(label: "First", label: "Second") { start S end E }`, [duplicateSetting('label')], ], - ['a single label is clean', `process p(label: "Only") { start S end E }`, []], [ 'two steps with one name make a goto ambiguous', `process p { user Review user Review }`, @@ -2505,11 +1968,6 @@ checks('Validation - duplicate declarations', [ `process p { user p }`, [stepNameEqualsProcess('p')], ], - [ - 'two steps with different names are clean', - `process p { user Review user Approve }`, - [], - ], [ 'the ambiguity crosses element kinds', `process p { service A(topic: "shipping") script A ${FENCE}js\nx = 1\n${FENCE} }`, @@ -2565,7 +2023,7 @@ checks('Validation - parallel branch heads', [ else { user ManualTriage } } }`, - [PARALLEL_ELSE_BESIDE_UNCONDITIONED], + [PARALLEL_ELSE_BESIDE_UNCONDITIONED_MESSAGE], ], [ "a second 'else' branch is one error", @@ -2577,12 +2035,12 @@ checks('Validation - parallel branch heads', [ else { user B } } }`, - [PARALLEL_SECOND_ELSE], + [PARALLEL_SECOND_ELSE_MESSAGE], ], [ "an 'else' branch with no conditioned sibling has nothing to fall back from", `process p { parallel { { user A } else { user B } } }`, - [PARALLEL_ELSE_WITHOUT_CONDITION], + [PARALLEL_ELSE_WITHOUT_CONDITION_MESSAGE], ], ]); @@ -2604,10 +2062,8 @@ process p { }); /** - * The five statements whose head parens carry a gateway's settings, each an - * otherwise-clean program with the parens left open. The `if` carries an - * `else if`, so a row on it also pins that the chain's one head takes the - * settings for the whole chain. + * The `if` carries an `else if`, so a row on it also pins that the chain's one + * head takes the settings for the whole chain. */ const GATEWAY_HOSTS: ReadonlyArray< [kind: string, description: string, head: (items: string) => string] @@ -2667,7 +2123,7 @@ checks('Validation - gateway settings', [ ...LOOP_HOSTS.map(([kind, description, head]): Case => [ `a join key on ${kind} names the unprefixed key, a loop having one gateway`, head('joinAsyncBefore: true'), - [loopJoinKey('joinAsyncBefore', description, 'asyncBefore')], + [loopJoinKeyMessage('joinAsyncBefore', description)], ]), [ 'a key no gateway takes is not valid on an if statement', @@ -2682,7 +2138,7 @@ checks('Validation - gateway settings', [ [ 'a bare word is refused, the parens taking settings alone, and names no variable', ifHead('wibble'), - [settingsOnly('an if statement')], + [settingsOnlyMessage('an if statement')], ], [ 'a repeated key is one duplicate', @@ -2692,7 +2148,7 @@ checks('Validation - gateway settings', [ [ 'asyncAfter on an await block is what the engine refuses on an event-based gateway', awaitHead('asyncAfter: true'), - [AWAIT_ASYNC_AFTER], + [refusedHeadKeyMessage('asyncAfter', 'an await block')], ], [ 'a quoted boolean in a join key names the unquoted form', @@ -2702,7 +2158,7 @@ checks('Validation - gateway settings', [ [ 'a decimal in joinJobPriority is refused as on an element', ifHead('joinAsyncBefore: true, joinJobPriority: 1.5'), - [priorityShape('joinJobPriority')], + [priorityShapeMessage('joinJobPriority')], ], [ 'a number in joinRetryCycle asks for quotes', @@ -2722,12 +2178,12 @@ checks('Validation - gateway settings', [ [ 'a join key on an if whose every branch ends has no join to set', `process p { var a: boolean if (a) (joinAsyncBefore: true) { end A } else { end B } }`, - [warn(prunedJoin('if statement', 'joinAsyncBefore'))], + [warn(prunedJoinMessage('if statement', 'joinAsyncBefore'))], ], [ 'a join key on a parallel whose every branch ends has no join to set, and draws no pairing warning on top', `process p { parallel (asyncBefore: true, joinJobPriority: 5) { { end A } { end B } } }`, - [warn(prunedJoin('parallel statement', 'joinJobPriority'))], + [warn(prunedJoinMessage('parallel statement', 'joinJobPriority'))], ], [ 'the same if without an else keeps its join, so the join key is clean', @@ -2736,14 +2192,6 @@ checks('Validation - gateway settings', [ ], ]); -/** - * `DefaultFailedJobParseListener.parseActivity` stores a retry cycle only - * once the carrier is async, and `BpmnParse.parseAsynchronousContinuation` - * folds `exclusive` into the async flag rather than declaring a job of its - * own, so `retryCycle`/`exclusive`/`jobPriority` (and their `join`/`run` - * spellings) configure nothing without the matching async flag beside them. - * Revert: dropping the rule clears every bare row below to `[]`. - */ checks('Validation - job-setting pairing', [ [ "a task's and a handler's retryCycle, exclusive and jobPriority need their own async flag", @@ -2753,18 +2201,30 @@ checks('Validation - job-setting pairing', [ }`, [ warn( - noJob('retryCycle', "'asyncBefore' or 'asyncAfter'", 'a service task'), + noJobMessage( + 'retryCycle', + ['asyncBefore', 'asyncAfter'], + 'a service task', + ), ), warn( - noJob('exclusive', "'asyncBefore' or 'asyncAfter'", 'a service task'), + noJobMessage( + 'exclusive', + ['asyncBefore', 'asyncAfter'], + 'a service task', + ), ), warn( - noJob('jobPriority', "'asyncBefore' or 'asyncAfter'", 'a service task'), + noJobMessage( + 'jobPriority', + ['asyncBefore', 'asyncAfter'], + 'a service task', + ), ), warn( - noJob( + noJobMessage( 'retryCycle', - "'asyncBefore' or 'asyncAfter'", + ['asyncBefore', 'asyncAfter'], 'an event handler', ), ), @@ -2790,44 +2250,58 @@ checks('Validation - job-setting pairing', [ }`, [ warn( - noJob('retryCycle', "'asyncBefore' or 'asyncAfter'", 'an if statement'), + noJobMessage( + 'retryCycle', + ['asyncBefore', 'asyncAfter'], + 'an if statement', + ), ), warn( - noJob( + noJobMessage( 'joinJobPriority', - "'joinAsyncBefore' or 'joinAsyncAfter'", + ['joinAsyncBefore', 'joinAsyncAfter'], 'an if statement', ), ), - warn(noJob('exclusive', "'asyncBefore' or 'asyncAfter'", 'a while loop')), warn( - noJob( + noJobMessage( + 'exclusive', + ['asyncBefore', 'asyncAfter'], + 'a while loop', + ), + ), + warn( + noJobMessage( 'jobPriority', - "'asyncBefore' or 'asyncAfter'", + ['asyncBefore', 'asyncAfter'], 'a do-while loop', ), ), warn( - noJob( + noJobMessage( 'retryCycle', - "'asyncBefore' or 'asyncAfter'", + ['asyncBefore', 'asyncAfter'], 'a parallel statement', ), ), warn( - noJob( + noJobMessage( 'joinExclusive', - "'joinAsyncBefore' or 'joinAsyncAfter'", + ['joinAsyncBefore', 'joinAsyncAfter'], 'a parallel statement', ), ), warn( - noJob('jobPriority', "'asyncBefore' or 'asyncAfter'", 'an await block'), + noJobMessage( + 'jobPriority', + ['asyncBefore', 'asyncAfter'], + 'an await block', + ), ), warn( - noJob( + noJobMessage( 'joinRetryCycle', - "'joinAsyncBefore' or 'joinAsyncAfter'", + ['joinAsyncBefore', 'joinAsyncAfter'], 'an await block', ), ), @@ -2849,26 +2323,26 @@ checks('Validation - job-setting pairing', [ "a repeated step's plain retryCycle/exclusive price the whole loop and jobPriority/run keys price each run, both needing their own async flag", `process p { step T for 3(retryCycle: "PT1M", exclusive: false, jobPriority: 5, runRetryCycle: "PT2M", runExclusive: false) }`, [ - warn(noJob('retryCycle', "'asyncBefore' or 'asyncAfter'", 'a step')), - warn(noJob('exclusive', "'asyncBefore' or 'asyncAfter'", 'a step')), + warn(noJobMessage('retryCycle', ['asyncBefore', 'asyncAfter'], 'a step')), + warn(noJobMessage('exclusive', ['asyncBefore', 'asyncAfter'], 'a step')), warn( - noPerRunJob( + noPerRunJobMessage( 'jobPriority', - "'runAsyncBefore' or 'runAsyncAfter'", + ['runAsyncBefore', 'runAsyncAfter'], 'a repeated step', ), ), warn( - noPerRunJob( + noPerRunJobMessage( 'runRetryCycle', - "'runAsyncBefore' or 'runAsyncAfter'", + ['runAsyncBefore', 'runAsyncAfter'], 'a repeated step', ), ), warn( - noPerRunJob( + noPerRunJobMessage( 'runExclusive', - "'runAsyncBefore' or 'runAsyncAfter'", + ['runAsyncBefore', 'runAsyncAfter'], 'a repeated step', ), ), @@ -2899,15 +2373,23 @@ checks('Validation - job-setting pairing', [ }`, [ warn( - noJob('exclusive', "'asyncBefore' or 'asyncAfter'", 'a start event'), + noJobMessage( + 'exclusive', + ['asyncBefore', 'asyncAfter'], + 'a start event', + ), ), warn( - noJob('retryCycle', "'asyncBefore' or 'asyncAfter'", 'a start event'), + noJobMessage( + 'retryCycle', + ['asyncBefore', 'asyncAfter'], + 'a start event', + ), ), warn( - noJob( + noJobMessage( 'jobPriority', - "'asyncBefore' or 'asyncAfter'", + ['asyncBefore', 'asyncAfter'], 'an event handler', ), ), @@ -2978,18 +2460,18 @@ checks('Validation - form fields', [ [ `a form on the start of ${kind} is never shown`, wrap('start In { form { a: string } } user A'), - [NESTED_START_FORM], + [NESTED_START_FORM_MESSAGE], ], [ `an initiator on the start of ${kind} is never written`, wrap('start In(initiator: "who") user A'), - [NESTED_START_INITIATOR], + [NESTED_START_INITIATOR_MESSAGE], ], ]), [ 'a constraint name outside the six and validator names the eight settings', `process p { start S { form { amount: number (minimum: 0) } } }`, - [unknownFormFieldSetting('amount', 'minimum')], + [unknownFormFieldSettingMessage('amount', 'minimum')], ], ...formFieldRows('a constraint fits the type its validator checks', [ ['min: 0', 'string', [constraintMisfit('min', 'f', 'string', 'number')]], @@ -3011,39 +2493,42 @@ checks('Validation - form fields', [ ['pattern: "dd/MM/yyyy"', 'date', []], ]), ...formFieldRows('a constraint takes one value shape', [ - ['required: false', 'string', [flagFalse('required')]], - ['required: "true"', 'string', [flagNotTrue('required')]], - ['min: "abc"', 'number', [integerBound('min')]], - ['max: 1.5', 'number', [integerBound('max')]], - ['minlength: 2.5', 'string', [integerBound('minlength')]], - ['pattern: ""', 'date', [PATTERN_VALUE]], + ['required: false', 'string', [flagFalseMessage('required')]], + ['required: "true"', 'string', [flagNotTrueMessage('required')]], + ['min: "abc"', 'number', [integerBoundMessage('min')]], + ['max: 1.5', 'number', [integerBoundMessage('max')]], + ['minlength: 2.5', 'string', [integerBoundMessage('minlength')]], + ['pattern: ""', 'date', [PATTERN_VALUE_MESSAGE]], ['min: -5', 'number', []], ['min: "-5"', 'number', []], ['maxlength: "80"', 'string', []], ['validator: com.example.Check', 'string', []], - ['validator: ""', 'string', [VALIDATOR_EMPTY]], - ['pattern: "dd-xx-yyyy"', 'date', [PATTERN_LETTERS]], + ['validator: ""', 'string', [VALIDATOR_EMPTY_MESSAGE]], + ['pattern: "dd-xx-yyyy"', 'date', [PATTERN_LETTERS_MESSAGE]], [`pattern: "'T'HH:mm"`, 'date', []], ]), [ "a bare word in a field's parens is not a setting", `process p { start S { form { amount: number (required) } } }`, - [warn(undeclared('required')), formFieldSettingsOnly('amount', 'required')], + [ + warn(undeclared('required')), + formFieldSettingsOnlyMessage('amount', 'required'), + ], ], [ 'an enum with no values is a warning', `process p { start S { form { plan: enum } } }`, - [warn(emptyEnum('plan'))], + [warn(emptyEnumMessage('plan'))], ], [ 'a repeated value id is one error on the repeat', `process p { start S { form { plan: enum { a "A" a "B" } } } }`, - [duplicateValue('a')], + [duplicateValueMessage('a')], ], [ 'a literal default outside the values names the ids it can take', `process p { start S { form { plan: enum = "zzz" { a "A" b } } } }`, - [enumDefault('plan', 'zzz', `'a' or 'b'`)], + [enumDefaultMessage('plan', 'zzz', ['a', 'b'])], ], [ 'an expression default is left to the engine', @@ -3058,7 +2543,7 @@ checks('Validation - form fields', [ [ 'a property is text', `process p { start S { form { p: string { property hint = ["a"] } } } }`, - [propertyValue('hint')], + [propertyValueMessage('hint')], ], [ 'a repeated property key is a duplicate', @@ -3068,12 +2553,12 @@ checks('Validation - form fields', [ [ "an io direction in a field's block is not a member", `process p { start S { form { p: enum { a input x = "1" } } } }`, - [formFieldDirection('p', 'input', true)], + [formFieldDirectionMessage('p', 'input', true)], ], [ "an io direction in a non-enum field's block names no value lines", `process p { start S { form { p: string { input x = "1" } } } }`, - [formFieldDirection('p', 'input')], + [formFieldDirectionMessage('p', 'input', false)], ], ]); @@ -3081,66 +2566,36 @@ checks('Validation - form field defaults', [ [ 'a decimal default on a number field is not an integer', `process p { start S { form { n: number = 1.5 } } }`, - [numberDefault('n', '1.5')], + [numberDefaultMessage('n', '1.5')], ], [ 'a quoted non-digit default on a number field is not an integer', `process p { start S { form { n: number = "high" } } }`, - [numberDefault('n', '"high"')], - ], - [ - 'an integer default on a number field is clean', - `process p { start S { form { n: number = 5 } } }`, - [], + [numberDefaultMessage('n', '"high"')], ], [ 'a quoted word default on a boolean field is neither true nor false', `process p { start S { form { b: boolean = "yes" } } }`, - [booleanDefault('b', '"yes"')], - ], - [ - 'the literal true default on a boolean field is clean', - `process p { start S { form { b: boolean = true } } }`, - [], - ], - [ - 'a quoted "false" default on a boolean field is clean', - `process p { start S { form { b: boolean = "false" } } }`, - [], + [booleanDefaultMessage('b', '"yes"')], ], [ 'an ISO default on a date field with no pattern reads against the engine default instead', `process p { start S { form { d: date = "2026-01-01" } } }`, - [isoDateDefault('d', '2026-01-01')], - ], - [ - 'an ISO default fit to an explicit matching pattern is clean', - `process p { start S { form { d: date = "2026-01-01" (pattern: "yyyy-MM-dd") } } }`, - [], + [isoDateDefaultMessage('d', '2026-01-01')], ], [ - 'a default already fit to the engine default pattern is clean', - `process p { start S { form { d: date = "01/01/2026" } } }`, + 'an integer, a literal or quoted boolean, and a date fit to its pattern are clean', + `process p { start S { form { n: number = 5 b: boolean = true c: boolean = "false" d: date = "2026-01-01" (pattern: "yyyy-MM-dd") e: date = "01/01/2026" } } }`, [], ], [ - 'a raw expression default passes on every type, since it is evaluated at render', - `process p { var x: any start S { form { n: number = "\${x}" } } }`, - [], - ], - [ - 'a variable default passes on every type, for the same reason', - `process p { var x: number start S { form { n: number = x } } }`, + 'a raw expression or a variable default passes on every type, since it is evaluated at render', + `process p { var x: any var y: number start S { form { n: number = "\${x}" m: number = y } } }`, [], ], ]); checks('Validation - unreachable statements', [ - [ - 'a step after an end in the same block can never run', - `process p { start S end Done user Dead }`, - [UNREACHABLE], - ], [ 'every dead step after an end is reported', `process p { start S end Done user A user B }`, @@ -3161,11 +2616,6 @@ checks('Validation - unreachable statements', [ `process p { start S end Done if (cond) { user A user B } }`, [warn(undeclared('cond')), UNREACHABLE], ], - [ - 'ordinary sequential flow is clean', - `process p { start S user A end Done }`, - [], - ], [ 'a step after an all-terminating if/else can never run', `process p { if (cond) { end A } else { end B } user Dead }`, @@ -3209,12 +2659,12 @@ checks('Validation - unreachable statements', [ [ 'a step after a do-while whose body always ends can never run, and neither can the loop', `process p { var c: boolean do { end X } while (c) user Dead }`, - [UNREACHABLE, DEAD_LOOP], + [UNREACHABLE, DEAD_LOOP_MESSAGE], ], [ 'a last do-while whose body always ends leaves its gateway with no incoming flow', `process p { var c: boolean start S do { end X } while (c) }`, - [DEAD_LOOP], + [DEAD_LOOP_MESSAGE], ], [ 'a do-while whose body ends on one branch only keeps its loop', @@ -3231,16 +2681,6 @@ checks('Validation - unreachable statements', [ `process p { start S end Done subprocess Sub { user A user B } }`, [UNREACHABLE], ], - [ - 'a call after an end can never run', - `process p { start S end Done call Dead(process: "p") }`, - [UNREACHABLE], - ], - [ - 'a goto targeting the call makes it reachable', - `process p { start S if (cond) { goto Retry } end Done call Retry(process: "p") }`, - [warn(undeclared('cond'))], - ], [ 'a handler after an end is not part of the sequence', `process p { error X start S end Done on error(X) { user A } }`, @@ -3261,16 +2701,6 @@ checks('Validation - unreachable statements', [ `process p { escalation X emit escalation(X) user Alive }`, [], ], - [ - 'a step after a thrown compensation can never run', - `process p { throw compensation user Dead }`, - [UNREACHABLE], - ], - [ - 'a step after an emitted compensation runs', - `process p { emit compensation user Alive }`, - [], - ], [ 'a named await after an end is reachable again as a goto target', `process p { start S if (c) { goto Wait } end Done await message Wait("M") }`, @@ -3279,11 +2709,6 @@ checks('Validation - unreachable statements', [ ]); checks('Validation - call activities', [ - [ - 'a call naming only the process is clean', - `process p { call X(process: "p") }`, - [], - ], [ 'every attribute and mapping shape together is clean', `process p { @@ -3383,23 +2808,8 @@ checks('Validation - call activities', [ [], ], [ - 'a mapper class is clean', - `process p { call X(process: "p", mapper: "com.acme.Mapper") }`, - [], - ], - [ - 'a mapper delegate is clean', - `process p { call X(process: "p", mapperDelegate: "\${mapperBean}") }`, - [], - ], - [ - 'a bareword mapper class is not read as a variable', - `process p { call X(process: "p", mapper: com.acme.Mapper) }`, - [], - ], - [ - 'a mapper runs beside the declared mappings, not instead of them', - `process p { call X(process: "p", mapper: "com.acme.Mapper") { in * out result } }`, + 'a mapper class or a mapper delegate is clean, and runs beside the declared mappings', + `process p { call X(process: "p", mapper: "com.acme.Mapper") { in * out result } call Y(process: "p", mapperDelegate: "\${mapperBean}") }`, [], ], [ @@ -3458,35 +2868,20 @@ checks('Validation - event handlers', [ `process p { error X if (true) { on error(X) { user A } } }`, [HANDLER_PLACEMENT], ], - [ - 'a handler directly in a process body is placed right', - `process p { error X on error(X) { user A } }`, - [noFlowSteps('p')], - ], [ 'a step after a handler reads out of order', `process p { error X on error(X) { user A } service S(class: "x.Y") }`, [HANDLER_TRAILING], ], - [ - 'a handler after a handler is in order', - `process p { error X escalation Y on error(X) { user A } on escalation(Y) { user B } }`, - [noFlowSteps('p')], - ], [ 'an error handler cannot run alongside the scope it takes over', `process p { error X on error(X, alongside) { user A } }`, - [noFlowSteps('p'), ERROR_ALWAYS_INTERRUPTS], - ], - [ - 'an escalation handler may run alongside', - `process p { escalation X on escalation(X, alongside) { user A } }`, - [noFlowSteps('p')], + [noFlowSteps('p'), alongsideMessage('error')], ], [ 'an empty code string is not a catch-all', `process p { on error("") { user A } }`, - [noFlowSteps('p'), EMPTY_CODE_NOT_CATCH_ALL], + [noFlowSteps('p'), EMPTY_CODE_MESSAGE], ], [ 'two handlers with one trigger and code are ambiguous', @@ -3511,7 +2906,7 @@ checks('Validation - event handlers', [ [ 'an escalation carries a code but no message', `process p { escalation X on escalation(X, message: m) { user A } }`, - [noFlowSteps('p'), ESCALATION_HAS_NO_MESSAGE], + [noFlowSteps('p'), ESCALATION_NO_MESSAGE_MESSAGE], ], [ 'a binding key filled with a value binds nothing, so both are refused', @@ -3541,17 +2936,17 @@ checks('Validation - event handlers', [ [ 'an unknown trigger word names every kind an on handler takes', `process p { start S on erorr("X") { } }`, - [unknownOnKind('erorr')], + [onTriggerMessage('erorr')], ], [ "'on conditional' is a did-you-mean", `process p { start S on conditional { user A } }`, - [CONDITIONAL_TYPO], + [CONDITIONAL_TYPO_MESSAGE], ], [ "'on compensate' is a did-you-mean", `process p { start S on compensate { user A } }`, - [COMPENSATE_TYPO], + [COMPENSATE_TYPO_MESSAGE], ], [ 'a variable named message coexists with the binding field of that word', @@ -3581,7 +2976,7 @@ checks('Validation - event handlers', [ [ "a timer job key on a timer handler's start repeats the head's", `process p { user T on timer("PT1H", jobPriority: 5) { start ES(jobPriority: 7, exclusive: false) user A } }`, - [timerJobKeyTwice('jobPriority')], + [timerJobKeyTwiceMessage('jobPriority')], ], [ 'a timer job key on the head alone, or on the start alone, is written once', @@ -3599,33 +2994,8 @@ checks('Validation - event handlers', [ [noFlowSteps('p')], ], [ - 'a name-only message handler is clean', - `process p { start S on message("PaymentReceived") { user A } }`, - [], - ], - [ - 'a signal handler may run alongside', - `process p { start S on signal("Cancelled", alongside) { user A } }`, - [], - ], - [ - "an 'after' timer handler is clean", - `process p { start S on timer("PT1H") { user A } }`, - [], - ], - [ - "an 'at' timer handler is clean", - `process p { start S on timer(at: "2026-08-01T09:00:00") { user A } }`, - [], - ], - [ - "an 'every' timer handler running alongside is clean", - `process p { start S on timer(every: "R/PT10M", alongside) { user A } }`, - [], - ], - [ - 'a condition handler over a declared variable is clean', - `process p { var amount: number start S on condition(amount > 100) { user A } }`, + "a message, an alongside signal, an 'after', 'at', and alongside 'every' timer, and a condition handler are clean", + `process p { var amount: number start S on message("PaymentReceived") { user A } on signal("Cancelled", alongside) { user B } on timer("PT1H") { user C } on timer(at: "2026-08-01T09:00:00") { user D } on timer(every: "R/PT10M", alongside) { user E } on condition(amount > 100) { user F } }`, [], ], [ @@ -3641,7 +3011,7 @@ checks('Validation - event handlers', [ [ 'a payload-less timer handler asks how to read the time', `process p { on timer { user A } }`, - [noFlowSteps('p'), TIMER_PAYLOAD], + [noFlowSteps('p'), TIMER_PAYLOAD_MESSAGE], ], [ 'a condition-less condition handler asks for the condition', @@ -3686,12 +3056,10 @@ checks('Validation - event handlers', [ ]); /** - * Every carrier that reads a timer clause, held to the shape its particle's - * calendar parses. A timer start deploys the expression up front + * A timer start deploys the expression up front * (`BpmnDeployer.adjustStartEventSubscriptions`), so its bad shape is an * error; every other carrier reads it only when its scope is entered, so its - * bad shape is a warning. Revert check: the parse reverted to `startsWith('P')` - * leaves the 'after' bad value ("Pbogus") clean on every carrier. + * bad shape is a warning. */ const TIMER_CARRIERS: ReadonlyArray<{ name: string; @@ -3717,9 +3085,8 @@ const TIMER_CARRIERS: ReadonlyArray<{ `process p { await { message("M") { user A } timer(${timerClause(particle, value)}) { user B } } }`, }, { - // Only an `on` handler is told to run alongside, so its 'every' rows carry - // the flag: without it every 'every' row would also draw the unrelated - // "fires at most once" warning that "Validation - event handlers" already pins. + // An interrupting 'every' handler also draws the fires-at-most-once + // warning, pinned under "Validation - event handlers". name: 'an on-handler timer', severity: 'warning', source: (particle, value) => @@ -3733,21 +3100,10 @@ const TIMER_CARRIERS: ReadonlyArray<{ }, ]; -/** `after` is always bare; `at` and `every` are always written under their key. */ function timerClause(particle: string, value: string): string { return particle === 'after' ? `"${value}"` : `${particle}: "${value}"`; } -/** - * Revert checks: `ISO_DURATION_BODY` with `W` beside the other fields turns - * 'after' `P1W2D` clean, and `JODA_PERIOD_BODY` without it turns 'at' `P1W2D` - * red; `ISO_DATE_TIME_BODY` without its zone, month-only, hour-only, ordinal, - * week or bare-`T` arm turns the matching 'at' values red; `INTERVAL_BODY` - * without its end-date arms turns the dated 'after' and 'every' values red; - * `AFTER_TIME_TEXT` without `REPEAT_BODY` turns `R3/PT1H` red; `CRON_BODY` - * back to `{5,6}` fields turns the seven-field cron clean and without its - * nickname alternative turns `@daily` red. - */ const TIMER_PARTICLE_SHAPES: ReadonlyArray<{ particle: string; bad: readonly string[]; @@ -3764,7 +3120,7 @@ const TIMER_PARTICLE_SHAPES: ReadonlyArray<{ '2026-01-01T00:00:00/PT1H', 'R3/PT1H', ], - message: AFTER_SHAPE, + message: AFTER_SHAPE_MESSAGE, }, { particle: 'at', @@ -3780,7 +3136,7 @@ const TIMER_PARTICLE_SHAPES: ReadonlyArray<{ '2026-12-01T', 'P1W2D', ], - message: AT_SHAPE, + message: AT_SHAPE_MESSAGE, }, { particle: 'every', @@ -3793,7 +3149,7 @@ const TIMER_PARTICLE_SHAPES: ReadonlyArray<{ 'R/PT1H/2026-12-31T00:00:00', 'R2/2026-01-01T00:00:00/2026-01-02T00:00:00', ], - message: EVERY_SHAPE, + message: EVERY_SHAPE_MESSAGE, }, ]; @@ -3821,7 +3177,7 @@ checks('Validation - boundary hosts', [ [ 'compensation has no attached form, so it refuses a host', `process p { subprocess S { user A on A: compensation { user Undo } } }`, - [COMPENSATION_HOST], + [COMPENSATION_HOST_MESSAGE], ], [ 'a host-less undo block is unaffected by that rule', @@ -3905,7 +3261,7 @@ ${FENCE} [ 'a host inside the handler own body could never run first', `process p { error X user A on Self: error(X) { user Self } }`, - [selfAttachedHost('Self')], + [selfAttachedHostMessage('Self')], ], [ "a host on a different handler's escape path is legal", @@ -3980,12 +3336,12 @@ ${FENCE} [ 'two host-less handlers of an unknown trigger word draw the unknown-kind error alone', `process p { user T on foo { user A } on foo { user B } }`, - [unknownOnKind('foo'), unknownOnKind('foo')], + [onTriggerMessage('foo'), onTriggerMessage('foo')], ], [ 'a hosted error handler still cannot run alongside', `process p { error X user Pack on Pack: error(X, alongside) { user A } }`, - [ERROR_ALWAYS_INTERRUPTS], + [alongsideMessage('error')], ], [ 'a fully host-less program fires no boundary machinery', @@ -4021,17 +3377,17 @@ checks('Validation - compensation', [ [ 'an undo block names nothing, so a code string is dropped', `process p { subprocess S { on compensation("X") { user A } } }`, - [blockNoFlowSteps('a subprocess', 'S'), COMPENSATION_NO_CODE], + [blockNoFlowSteps('a subprocess', 'S'), COMPENSATION_NO_CODE_MESSAGE], ], [ 'an undo block carries no values, so bindings are dropped', `process p { subprocess S { on compensation(code: c) { user A } } }`, - [blockNoFlowSteps('a subprocess', 'S'), COMPENSATION_BINDINGS], + [blockNoFlowSteps('a subprocess', 'S'), COMPENSATION_BINDINGS_MESSAGE], ], [ 'the work an undo block reverses has finished, so alongside is dropped', `process p { subprocess S { on compensation(alongside) { user A } } }`, - [blockNoFlowSteps('a subprocess', 'S'), COMPENSATION_ALONGSIDE], + [blockNoFlowSteps('a subprocess', 'S'), COMPENSATION_ALONGSIDE_MESSAGE], ], [ 'a timer key on an undo block is the inherited timer-only rule', @@ -4046,12 +3402,12 @@ checks('Validation - compensation', [ [ 'a process cannot undo itself', `process p { on compensation { user A } }`, - [noFlowSteps('p'), COMPENSATION_PLACEMENT], + [noFlowSteps('p'), COMPENSATION_PLACEMENT_MESSAGE], ], [ 'a handler body is no subprocess to undo either', `process p { error X on error(X) { on compensation { user A } } }`, - [noFlowSteps('p'), COMPENSATION_PLACEMENT], + [noFlowSteps('p'), COMPENSATION_PLACEMENT_MESSAGE], ], [ 'an undo block in a branch is the generic placement error, once', @@ -4061,12 +3417,12 @@ checks('Validation - compensation', [ [ 'two undo blocks in one subprocess merge into one', `process p { subprocess S { on compensation { user A } on compensation { user B } } }`, - [COMPENSATION_DUPLICATE, blockNoFlowSteps('a subprocess', 'S')], + [COMPENSATION_DUPLICATE_MESSAGE, blockNoFlowSteps('a subprocess', 'S')], ], [ 'two undo blocks in one attempt block merge the same way', `process p { attempt A { on compensation { user U1 } on compensation { user U2 } } }`, - [COMPENSATION_DUPLICATE, blockNoFlowSteps('an attempt block', 'A')], + [COMPENSATION_DUPLICATE_MESSAGE, blockNoFlowSteps('an attempt block', 'A')], ], [ 'one undo block in each of two subprocesses is clean', @@ -4076,21 +3432,11 @@ checks('Validation - compensation', [ }`, [], ], - [ - "'throw compensate' is a did-you-mean", - `process p { throw compensate }`, - [COMPENSATE_TYPO], - ], - [ - "'emit compensate' is a did-you-mean", - `process p { emit compensate }`, - [COMPENSATE_TYPO], - ], - [ - "'await compensate' is a did-you-mean", - `process p { await compensate }`, - [COMPENSATE_TYPO], - ], + ...(['throw', 'emit', 'await'] as const).map((keyword): Case => [ + `'${keyword} compensate' is a did-you-mean`, + `process p { ${keyword} compensate }`, + [COMPENSATE_TYPO_MESSAGE], + ]), ]); checks('Validation - the cancel end and its handler', [ @@ -4123,27 +3469,27 @@ checks('Validation - the cancel end and its handler', [ [ 'a cancel end in a process body has no block to give up', `process p { start S end E cancel }`, - [CANCEL_END_PLACEMENT], + [CANCEL_END_PLACEMENT_MESSAGE], ], [ 'a cancel end in a plain subprocess body has none either', `process p { start S subprocess Sub { user A end E cancel } end Done }`, - [CANCEL_END_PLACEMENT], + [CANCEL_END_PLACEMENT_MESSAGE], ], [ 'a cancel end in a subprocess nested in an attempt gives up the subprocess', `process p { start S attempt A { subprocess Sub { user T end E cancel } } end Done }`, - [CANCEL_END_PLACEMENT], + [CANCEL_END_PLACEMENT_MESSAGE], ], [ 'a cancel end in a handler body inside an attempt is not directly in the block', `process p { error X start S attempt A { user B on error(X) { user C end E cancel } } end Done }`, - [CANCEL_END_PLACEMENT], + [CANCEL_END_PLACEMENT_MESSAGE], ], [ 'a code string after cancel names cancel, not terminate', `process p { start S attempt A { user B end E cancel("X") } end Done on A: cancel { user C end F } }`, - [CANCEL_NAMES_NOTHING], + [END_TRIGGER_NO_CODE_MESSAGES.cancel], ], [ 'a cancel handler on a plain subprocess names what the host is', @@ -4153,17 +3499,17 @@ checks('Validation - the cancel end and its handler', [ [ 'a host-less cancel handler points at the hosted spelling', `process p { start S user A on cancel { user B end E } }`, - [CANCEL_HOSTLESS], + [CANCEL_HOSTLESS_MESSAGE], ], [ 'a cancel handler cannot run alongside the block it drains', `process p { start S attempt A { user B end G cancel } end Done on A: cancel(alongside) { user C end E } }`, - [CANCEL_ALONGSIDE], + [CANCEL_ALONGSIDE_MESSAGE], ], [ 'a cancel handler catches nothing by name', `process p { start S attempt A { user B end G cancel } end Done on A: cancel("X") { user C end E } }`, - [CANCEL_NO_CODE], + [CANCEL_NO_CODE_MESSAGE], ], [ 'two cancel handlers on one block are the generic duplicate', @@ -4181,20 +3527,15 @@ checks('Validation - the cancel end and its handler', [ `process p { start S attempt A { user B on compensation { user Undo } } end Done }`, [], ], - [ - 'an attempt block carries the settings a subprocess carries', - `process p { start S attempt A(asyncBefore: true) { user B } end Done }`, - [], - ], [ 'a cancel end with no handler stops the run', `process p { start S attempt A { user B end G cancel } end Done }`, - [warn(cancelEndWithoutHandler('A'))], + [warn(cancelEndWithoutHandlerMessage('A'))], ], [ 'a cancel handler on a block that never gives itself up never runs', `process p { start S attempt A { user B } end Done on A: cancel { user C end E } }`, - [warn(cancelHandlerWithoutEnd('A'))], + [warn(cancelHandlerWithoutEndMessage('A'))], ], [ "a nested block's cancel end does not pair with the outer handler", @@ -4207,22 +3548,22 @@ checks('Validation - the cancel end and its handler', [ end Done on A: cancel { user F end H } }`, - [warn(cancelHandlerWithoutEnd('A'))], + [warn(cancelHandlerWithoutEndMessage('A'))], ], [ "'throw cancel' points at the end that gives the block up", `process p { start S throw cancel }`, - [CANCEL_NOT_RAISED], + [CANCEL_NOT_RAISED_MESSAGE], ], [ "'emit cancel' points there too", `process p { start S emit cancel }`, - [CANCEL_NOT_RAISED], + [CANCEL_NOT_RAISED_MESSAGE], ], [ "'await cancel' points at the end and at the handler that catches it", `process p { start S await cancel }`, - [CANCEL_NOT_AWAITED], + [CANCEL_NOT_AWAITED_MESSAGE], ], ]); @@ -4230,17 +3571,17 @@ checks('Validation - throw and emit', [ [ "'emit error' points at 'throw error'", `process p { error X emit error(X) }`, - [EMIT_ERROR], + [emitTriggerMessage('error')], ], [ 'an unknown throw kind names the kinds with a terminal form', `process p { throw banana("X") }`, - [unknownThrowKind('banana')], + [throwTriggerMessage('banana')], ], [ 'an unknown emit kind leaves error off the list', `process p { emit banana("X") }`, - [unknownEmitKind('banana')], + [emitTriggerMessage('banana')], ], [ 'a thrown error with no code names the shape', @@ -4262,21 +3603,6 @@ checks('Validation - throw and emit', [ `process p { throw compensation("X") }`, [throwCompensationNames('throw')], ], - [ - 'a bare thrown compensation is clean', - `process p { throw compensation }`, - [], - ], - [ - 'a thrown message is clean', - `process p { start S throw message("Ack") }`, - [], - ], - [ - 'an emitted message is clean', - `process p { start S emit message("Ack") }`, - [], - ], [ 'a thrown message with no code names the shape', `process p { start S throw message }`, @@ -4287,41 +3613,20 @@ checks('Validation - throw and emit', [ `process p { start S throw signal("Alert") }`, [], ], - [ - 'an emitted signal is clean', - `process p { start S emit signal("Ping") user A }`, - [], - ], - [ - 'a thrown message carries a class implementation', - `process p { start S throw message("Ack", class: "com.example.Send") }`, - [], - ], - [ - 'a thrown message carries an expression implementation', - `process p { start S throw message("Ack", expression: "\${sender.send(order)}") }`, - [], - ], - [ - 'a thrown message carries a delegate implementation', - `process p { start S throw message("Ack", delegate: "\${senderBean}") }`, - [], - ], - [ - 'a thrown message carries a topic implementation', - `process p { start S throw message("Ack", topic: "send-ack") }`, - [], - ], - [ - 'an emitted message carries a class implementation', - `process p { start S emit message("Ack", class: "com.example.Send") }`, - [], - ], - [ - 'an emitted message carries a topic implementation', - `process p { start S emit message("Ack", topic: "send-ack") }`, + ...( + [ + ['throw', 'class: "com.example.Send"'], + ['throw', 'expression: "${sender.send(order)}"'], + ['throw', 'delegate: "${senderBean}"'], + ['throw', 'topic: "send-ack"'], + ['emit', 'class: "com.example.Send"'], + ['emit', 'topic: "send-ack"'], + ] as const + ).map(([keyword, binding]): Case => [ + `a message ${keyword} carries a ${binding.split(':')[0]} implementation`, + `process p { start S ${keyword} message("Ack", ${binding}) }`, [], - ], + ]), [ 'only a message really sends, so a thrown error carries no implementation', `process p { error X start S throw error(X, class: "c") }`, @@ -4350,33 +3655,30 @@ checks('Validation - throw and emit', [ ]); checks('Validation - awaited events', [ - ['an awaited message is clean', `process p { await message("M") }`, []], - ['an awaited timer is clean', `process p { await timer("PT1H") }`, []], - ['an awaited signal is clean', `process p { await signal("S") }`, []], [ - 'an awaited condition is clean', - `process p { var x: number await condition(x > 1) }`, + 'an awaited message, timer, signal, and condition are clean', + `process p { var x: number await message("M") await timer("PT1H") await signal("S") await condition(x > 1) }`, [], ], [ 'an error is raised outward, so it cannot be awaited', `process p { error E await error(E) }`, - [unknownAwaitKind('error')], + [catchTriggerMessage('error')], ], [ 'an escalation cannot be awaited either', `process p { escalation E await escalation(E) }`, - [unknownAwaitKind('escalation')], + [catchTriggerMessage('escalation')], ], [ 'compensation runs through an undo block, not an await', `process p { await compensation }`, - [unknownAwaitKind('compensation')], + [catchTriggerMessage('compensation')], ], [ 'an unknown word names the kinds an await takes and where the rest are written', `process p { start S await nonsense }`, - [unknownAwaitKind('nonsense')], + [catchTriggerMessage('nonsense')], ], [ 'an awaited message with no name asks for it', @@ -4391,7 +3693,7 @@ checks('Validation - awaited events', [ [ 'an awaited timer with no payload asks how to read the time', `process p { await timer }`, - [TIMER_PAYLOAD], + [TIMER_PAYLOAD_MESSAGE], ], [ 'an awaited condition with no parens asks for the condition', @@ -4426,7 +3728,7 @@ checks('Validation - awaited events', [ ...(['message', 'signal'] as const).map((trigger): Case => [ `two ${trigger} branches of one race on one name subscribe twice on the gateway`, `process p { await { ${trigger}("Dup") { user A } timer("PT1H") { user T } ${trigger}("Dup") { user B } } }`, - [raceDuplicate(trigger, 'Dup')], + [raceDuplicateMessage(trigger, 'Dup')], ]), [ 'a message branch and a signal branch of one name are two subscriptions', @@ -4508,12 +3810,12 @@ checks('Validation - link events', [ [ 'a link catch after a live step refuses the flow that would enter it', `process p { step A await link C("L") step B emit link T("L") }`, - [LINK_CATCH_FLOW], + [LINK_CATCH_FLOW_MESSAGE], ], [ 'a link cannot head a race branch', `process p { await { link("L") { user A } message("M") { user B } } }`, - [LINK_IN_RACE], + [LINK_IN_RACE_MESSAGE], ], [ 'an awaited link with no name asks for it', @@ -4534,9 +3836,9 @@ checks('Validation - link events', [ 'engine settings and listeners on an emitted link are refused, one each', `process p { step A emit link T("L", asyncBefore: true, jobPriority: 5) { on end(class: "x.L") } await link C("L") }`, [ - linkThrowNeverRuns("Setting 'asyncBefore'"), - linkThrowNeverRuns("Setting 'jobPriority'"), - linkThrowNeverRuns("The 'on end' listener"), + linkThrowNeverRunsMessage("Setting 'asyncBefore'"), + linkThrowNeverRunsMessage("Setting 'jobPriority'"), + linkThrowNeverRunsMessage("The 'on end' listener"), ], ], [ @@ -4577,39 +3879,14 @@ checks('Validation - link events', [ [ 'throw link points at emit link', `process p { step A throw link("L") }`, - [THROW_LINK], + [throwTriggerMessage('link')], ], ]); checks('Validation - start triggers', [ [ - 'a message start naming the message is clean', - `process p { start S message("OrderReceived") user A }`, - [], - ], - [ - 'a signal start naming the signal is clean', - `process p { start S signal("Ready") user A }`, - [], - ], - [ - "a timer start with 'after' is clean", - `process p { start S timer("PT1H") user A }`, - [], - ], - [ - "a timer start with 'at' is clean", - `process p { start S timer(at: "2026-08-01T09:00:00") user A }`, - [], - ], - [ - 'a repeating timer start is a schedule, not a mistake', - `process p { start S timer(every: "R/PT10M") user A }`, - [], - ], - [ - 'a label between the name and the trigger is clean', - `process p { start S timer("PT1H", label: "Scheduled") user A }`, + 'a message start, a signal start, and a labelled repeating timer start are clean', + `process p { start A message("OrderReceived") start B signal("Ready") start C timer(every: "R/PT10M", label: "Scheduled") user T end E }`, [], ], [ @@ -4625,7 +3902,7 @@ checks('Validation - start triggers', [ [ 'a timer start with no payload asks how to read the time', `process p { start S timer }`, - [TIMER_PAYLOAD], + [TIMER_PAYLOAD_MESSAGE], ], [ 'a timer key on a non-timer start belongs to a timer', @@ -4635,17 +3912,17 @@ checks('Validation - start triggers', [ [ 'the engine ignores an error start', `process p { error X start S error(X) }`, - [startRaisedKind('error')], + [startTriggerMessage('error')], ], [ 'the engine ignores an escalation start', `process p { escalation X start S escalation(X) }`, - [startRaisedKind('escalation')], + [startTriggerMessage('escalation')], ], [ 'a compensation start points at the undo block', `process p { start S compensation }`, - [START_COMPENSATION], + [startTriggerMessage('compensation')], ], [ 'a condition start carrying its condition is clean', @@ -4675,51 +3952,31 @@ checks('Validation - start triggers', [ [ 'the near-miss spelling is answered as a typo', `process p { start S conditional }`, - [CONDITIONAL_TYPO], + [CONDITIONAL_TYPO_MESSAGE], ], [ 'an unknown start kind names the legal ones', `process p { start S nonsense("X") }`, - [unknownStartKind('nonsense')], + [startTriggerMessage('nonsense')], ], [ 'a message start name cannot hold a trailing expression', `process p { start S message("Order\${x}") }`, - [startMessageExpression('Order${x}')], + [startMessageExpressionMessage('Order${x}')], ], [ 'a message start name cannot hold a whole expression', `process p { start S message("#{orderType}") }`, - [startMessageExpression('#{orderType}')], + [startMessageExpressionMessage('#{orderType}')], ], [ 'the other expression spelling is rejected too', `process p { start S message("Order#{x}") }`, - [startMessageExpression('Order#{x}')], - ], - [ - 'a signal start name may hold an expression', - `process p { start S signal("Order\${x}") user A }`, - [], - ], - [ - 'a signal start name may hold the other spelling', - `process p { start S signal("#{orderType}") user A }`, - [], - ], - [ - 'an awaited message name may hold an expression: the process is running', - `process p { start S await message("Order\${x}") user A }`, - [], - ], - [ - 'an awaited message name may hold the other spelling', - `process p { start S await message("#{orderType}") user A }`, - [], + [startMessageExpressionMessage('Order#{x}')], ], [ - 'a handler message name may hold an expression', - `process p { start S user A on message("Order\${x}") { user B } }`, + 'a signal start, an awaited message, and a handler message name may hold either expression spelling: the process is running', + `process p { start P start S signal("Order\${x}") start T signal("#{orderType}") await message("Order\${x}") await message("#{orderType}") user A on message("Order\${x}") { user B } }`, [], ], [ @@ -4735,7 +3992,7 @@ checks('Validation - start triggers', [ [ "a handler body's start catches what the handler catches", `process p { error X start S on error(X) { start In message("M") user A end Out } }`, - [START_TRIGGER_IN_HANDLER], + [START_TRIGGER_IN_HANDLER_MESSAGE], ], [ 'a triggered start nested in a plain branch draws no block-specific message, just the ordinary positional one', @@ -4746,76 +4003,60 @@ checks('Validation - start triggers', [ checks('Validation - end triggers', [ [ - 'a terminate end in a process body is clean', - `process p { start S user A end E terminate }`, - [], - ], - [ - 'a terminate end in a subprocess body is clean', - `process p { start S subprocess Sub { user A end E terminate } end Done }`, - [], - ], - [ - 'a terminate end in a handler body is clean', - `process p { error X start S user A on error(X) { user B end E terminate } }`, - [], - ], - [ - 'a label before terminate is clean', - `process p { start S user A end E terminate(label: "All stop") }`, + 'a terminate end is clean in a process body, a subprocess body, and a handler body, with or without a label', + `process p { error X start S subprocess Sub { user A end E terminate } user B end G terminate(label: "All stop") on error(X) { user C end F terminate } }`, [], ], [ 'terminate names nothing', `process p { start S end E terminate("X") }`, - [TERMINATE_NAMES_NOTHING], + [END_TRIGGER_NO_CODE_MESSAGES.terminate], ], [ 'an error is raised with throw, not on an end', `process p { error Ack start S end E error(Ack) }`, - [endRaisedKind('error', 'An')], + [endTriggerMessage('error')], ], [ 'an escalation is raised with throw', `process p { escalation Ack start S end E escalation(Ack) }`, - [endRaisedKind('escalation', 'An')], + [endTriggerMessage('escalation')], ], [ 'a message is raised with throw', `process p { start S end E message("Ack") }`, - [endRaisedKind('message', 'A')], + [endTriggerMessage('message')], ], [ 'a signal is raised with throw', `process p { start S end E signal("Ack") }`, - [endRaisedKind('signal', 'A')], + [endTriggerMessage('signal')], ], [ 'a compensation is raised with throw', `process p { start S end E compensation("Ack") }`, - [endRaisedKind('compensation', 'A')], + [endTriggerMessage('compensation')], ], [ 'a timer end names the places a timer belongs', `process p { start S end E timer("PT1H") }`, - [END_TIMER], + [END_TIMER_MESSAGE], ], [ 'a condition end names the places a condition belongs', `process p { start S end E condition }`, - [END_CONDITION], + [END_CONDITION_MESSAGE], ], [ 'the near-miss spelling gets the same answer', `process p { start S end E conditional }`, - [END_CONDITION], + [END_CONDITION_MESSAGE], ], [ 'an unknown end kind names both end words and the throw alternative', `process p { start S end E nonsense }`, - [unknownEndKind('nonsense')], + [endTriggerMessage('nonsense')], ], - // The advice is only useful if the placements it quotes validate. [ 'the three timer placements the advice names validate', `process p { @@ -4965,7 +4206,7 @@ checks('Validation - code declarations', [ [ 'an unknown declaration kind names the kinds a declaration takes', `process p { usr Review user A }`, - [unknownCodeDeclarationKind('usr')], + [unknownDeclarationKindMessage('usr')], ], [ 'a mistyped step keyword is named rather than blamed on the brace after it', @@ -4974,7 +4215,7 @@ checks('Validation - code declarations', [ "Expecting token of type '}' but found `{`.", 'Expecting end of file but found `}`.', noFlowSteps('p'), - unknownCodeDeclarationKind('usr'), + unknownDeclarationKindMessage('usr'), ], ], [ @@ -5012,7 +4253,7 @@ checks('Validation - code declarations', [ `process p { escalation E(message: "m", wibble: "x") user A }`, [ notValidOn('wibble', 'an escalation declaration'), - ESCALATION_HAS_NO_MESSAGE, + ESCALATION_NO_MESSAGE_MESSAGE, ], ], [ @@ -5033,12 +4274,12 @@ checks('Validation - code declarations', [ [ 'a declaration writes its message by key rather than as a bare value', `process p { error E("Out of stock") user A }`, - [DECLARATION_SETTINGS_ONLY], + [DECLARATION_SETTINGS_ONLY_MESSAGE], ], [ 'a bare word in a declaration is refused, and is no undeclared variable', `process p { error E(SOMETHING) user A }`, - [DECLARATION_SETTINGS_ONLY], + [DECLARATION_SETTINGS_ONLY_MESSAGE], ], [ 'a declaration may carry the name of a step', @@ -5050,23 +4291,17 @@ checks('Validation - code declarations', [ [ 'a thrown code is a declared name, not quoted text', `process p { start S throw error("PAYMENT_DECLINED") }`, - [quotedCode('error', 'error PAYMENT_DECLINED', 'PAYMENT_DECLINED')], + [quotedCodeMessage('error', 'PAYMENT_DECLINED')], ], [ 'text no name could spell is declared under a name the author picks', `process p { start S emit escalation("review.manual") }`, - [ - quotedCode( - 'escalation', - 'escalation (code: "review.manual")', - '', - ), - ], + [quotedCodeMessage('escalation', 'review.manual')], ], [ 'a caught code is a declared name too', `process p { start S user T on error("X") { user U } }`, - [quotedCode('error', 'error X', 'X')], + [quotedCodeMessage('error', 'X')], ], [ 'a message name stays quoted text, since it declares nothing', @@ -5281,7 +4516,7 @@ describe('Validation - every element with settings and a member block', () => { 'a property line on %s names the element kind and the hosts that take one', async (_kind, description, program) => { expect(await diagnosticsOf(program('property k = "v"'))).toEqual([ - noPropertyHost(description), + noPropertyHostMessage(description), ]); }, ); @@ -5305,7 +4540,7 @@ describe('Validation - every element with settings and a member block', () => { 'an error mapping on %s names the element kind and the hosts that take one', async (_kind, description, program) => { expect(await diagnosticsOf(program('error E when "${x}"'))).toEqual([ - noMappingHost(description), + noMappingHostMessage(description), ]); }, ); @@ -5330,7 +4565,7 @@ checks('Validation - input and output parameters', [ [ 'an unrecognized direction names the two legal ones', `process p { user U { inp a = 1 } }`, - [unknownDirection('inp')], + [unknownDirectionMessage('inp', ['input', 'output'])], ], [ 'a repeated name within one direction is one error', @@ -5367,24 +4602,14 @@ checks('Validation - input and output parameters', [ [EMPTY_MAP_KEY], ], [ - 'an empty string value writes no value at all', - `process p { user U { input a = "" } }`, - [warn(EMPTY_STRING_VALUE)], - ], - [ - 'an empty string reached through a list item warns the same way', - `process p { user U { input a = ["x", ""] } }`, - [warn(EMPTY_STRING_VALUE)], + 'an empty or a whitespace-only string value writes no value at all', + `process p { user U { input a = "" input b = " " } }`, + [warn(EMPTY_STRING_VALUE_MESSAGE), warn(EMPTY_STRING_VALUE_MESSAGE)], ], [ - "an empty string reached through a map entry's value warns the same way", - `process p { user U { input a = { k: "" } } }`, - [warn(EMPTY_STRING_VALUE)], - ], - [ - 'a string of only whitespace is trimmed to the same nothing', - `process p { user U { input a = " " } }`, - [warn(EMPTY_STRING_VALUE)], + "an empty string reached through a list item or a map entry's value warns the same way", + `process p { user U { input a = ["x", ""] input b = { k: "" } } }`, + [warn(EMPTY_STRING_VALUE_MESSAGE), warn(EMPTY_STRING_VALUE_MESSAGE)], ], [ 'a parameter name is in scope for the collection it feeds', @@ -5409,23 +4634,10 @@ lines": 3, "Grüße 日本": 4 } } }`, ], ]); -// The placement rule under test is Operaton's: a field list is built for the -// behaviours a `class` and a `delegate` expression select, so every other -// binding, and the fenced script a listener can bind with, takes none. checks('Validation - injected fields', [ [ - 'a class-bound service task carries a field', - `process p { service V(class: "com.acme.D") { field greeting = "hello" } }`, - [], - ], - [ - 'a delegate-bound send task carries a field written as an expression', - `process p { send N(delegate: "\${sender}") { field subject = "\${topic}" } }`, - [], - ], - [ - 'a class-bound decision step carries a field beside its io parameters', - `process p { decide D(class: "com.acme.R") { input amount = 1 field greeting = "hello" output risk = 2 } }`, + 'a class-bound service task, a delegate-bound send task, and a class-bound decision step carry a field, quoted or as an expression, beside io parameters', + `process p { service V(class: "com.acme.D") { field greeting = "hello" } send N(delegate: "\${sender}") { field subject = "\${topic}" } decide D(class: "com.acme.R") { input amount = 1 field greeting = "hello" output risk = 2 } }`, [], ], [ @@ -5455,69 +4667,56 @@ checks('Validation - injected fields', [ 'a field and a member of an unknown direction report in the order they are written', `process p { service V(class: "com.acme.D") { field greeting = 3 fld x = 1 } }`, [ - fieldValue('greeting'), - unknownDirection('fld', `'input', 'output', 'field', or 'property'`), + fieldValueMessage('greeting'), + unknownDirectionMessage('fld', ['input', 'output', 'field', 'property']), ], ], [ 'a user task names the kinds that take a field', `process p { user U { field greeting = "hello" } }`, - [noFields('a user task')], + [noFieldHostMessage('a user task')], ], [ 'a start event names the kinds that take a field', `process p { start S { field greeting = "hello" } }`, - [noFields('a start event')], - ], - [ - 'a class-bound listener carries a field', - `process p { user U { on start(class: "com.acme.L") { field greeting = "hello" } } }`, - [], + [noFieldHostMessage('a start event')], ], [ - 'a delegate-bound task listener carries a field', - `process p { user U { on complete(delegate: "\${listenerBean}") { field greeting = "hello" } } }`, + 'a class-bound listener and a delegate-bound task listener carry a field', + `process p { user U { on start(class: "com.acme.L") { field greeting = "hello" } on complete(delegate: "\${listenerBean}") { field greeting = "hello" } } }`, [], ], [ 'an expression-bound listener takes no field', `process p { user U { on start(expression: "\${bean.run(task)}") { field greeting = "hello" } } }`, - [listenerFieldBinding(`The 'on start' listener`, 'expression')], + [ + fieldBindingMessage( + `The 'on start' listener`, + ['expression'], + LISTENER_FIELD_BINDINGS, + ), + ], ], [ 'a fenced-script listener takes no field', `process p { user U { on start ${FENCE}groovy\nlog(task)\n${FENCE} { field greeting = "hello" } } }`, - [scriptListenerField(`The 'on start' listener`)], + [scriptListenerFieldMessage(`The 'on start' listener`)], ], [ 'a fenced script beside a class binding is still what refuses the field', `process p { user U { on start(class: "com.acme.L") ${FENCE}groovy\nlog(task)\n${FENCE} { field greeting = "hello" } } }`, - [scriptListenerField(`The 'on start' listener`)], + [scriptListenerFieldMessage(`The 'on start' listener`)], ], [ - 'a number is not a field value', - `process p { service V(class: "com.acme.D") { field retries = 3 } }`, - [fieldValue('retries')], - ], - [ - 'a bareword is not a field value', - `process p { var salutation: string service V(class: "com.acme.D") { field greeting = salutation } }`, - [fieldValue('greeting')], - ], - [ - 'a list is not a field value', - `process p { service V(class: "com.acme.D") { field greetings = ["a", "b"] } }`, - [fieldValue('greetings')], - ], - [ - 'a map is not a field value', - `process p { service V(class: "com.acme.D") { field greeting = { text: "hi" } } }`, - [fieldValue('greeting')], - ], - [ - 'an inline script is not a field value', - `process p { service V(class: "com.acme.D") { field greeting = ${FENCE}groovy\n"hi"\n${FENCE} } }`, - [fieldValue('greeting')], + 'a number, a bareword, a list, a map, and an inline script are not field values, one refusal each', + `process p { var salutation: string service V(class: "com.acme.D") { field retries = 3 field greeting = salutation field greetings = ["a", "b"] field text = { text: "hi" } field body = ${FENCE}groovy\n"hi"\n${FENCE} } }`, + [ + fieldValueMessage('retries'), + fieldValueMessage('greeting'), + fieldValueMessage('greetings'), + fieldValueMessage('text'), + fieldValueMessage('body'), + ], ], [ 'a repeated field name is one error', @@ -5532,48 +4731,41 @@ checks('Validation - injected fields', [ [ 'an unrecognized direction on a service task names field as legal too', `process p { service V(class: "com.acme.D") { fld greeting = "hi" } }`, - [unknownDirection('fld', `'input', 'output', 'field', or 'property'`)], + [unknownDirectionMessage('fld', ['input', 'output', 'field', 'property'])], ], [ "a listener's block takes a field alone, so an input there is unrecognized", `process p { user U { on start(class: "com.acme.L") { input x = 1 } } }`, - [unknownDirection('input', `'field'`)], + [unknownDirectionMessage('input', ['field'])], ], ]); -/** - * The extras `BpmnParse.parseExternalServiceTask` reads and nothing else does, - * so each is legal beside `topic` on the three kinds that bind one and nowhere - * else. Raw conditions where the condition is not the point, so no variable - * warning rides along. - */ +/** `BpmnParse.parseExternalServiceTask` reads these extras and nothing else does. */ checks('Validation - external task extras', [ - // `taskPriority` needs `topic`. [ 'a priority under a class binding names the binding written', `process p { service V(class: "c.D", taskPriority: 5) }`, - [topicBinding('A service task', "'taskPriority'", 'class')], + [topicBindingMessage('A service task', "'taskPriority'", ['class'])], ], [ 'a priority under a decision binding names the binding written', `process p { decide D(decision: "k", taskPriority: 5) }`, - [topicBinding('A decision step', "'taskPriority'", 'decision')], + [topicBindingMessage('A decision step', "'taskPriority'", ['decision'])], ], [ 'a priority on a thrown message is an unknown key, topic or not', `process p { start S emit message M("X", topic: "t", taskPriority: 5) }`, [notValidOn('taskPriority', 'an emit statement')], ], - // Property lines. [ 'a property line under a class binding names the binding written', `process p { service V(class: "c.D") { property k = "v" } }`, - [topicBinding('A service task', 'a property line', 'class')], + [topicBindingMessage('A service task', 'a property line', ['class'])], ], [ 'a property value is text', `process p { service V(topic: "t") { property k = ["a"] } }`, - [propertyValue('k')], + [propertyValueMessage('k')], ], [ 'a repeated property key is one duplicate', @@ -5585,21 +4777,20 @@ checks('Validation - external task extras', [ `process p { service V(topic: "t") { property k = "v" } if (k) { step A } }`, [warn(undeclared('k'))], ], - // Error mappings. [ 'a mapping under a class binding names the binding written', `process p { error E service V(class: "c.D") { error E when "\${x}" } }`, - [topicBinding('A service task', 'an error mapping', 'class')], + [topicBindingMessage('A service task', 'an error mapping', ['class'])], ], [ 'a mapping raises an error and nothing else', `process p { escalation S service V(topic: "t") { escalation S when "\${x}" } }`, - [MAPPING_HEAD], + [MAPPING_HEAD_MESSAGE], ], [ 'a mapping is written with when', `process p { error E service V(topic: "t") { error E wenn "\${x}" } }`, - [MAPPING_WHEN], + [MAPPING_WHEN_MESSAGE], ], [ 'a mapping naming an undeclared code draws the linker message alone', @@ -5619,19 +4810,14 @@ checks('Validation - external task extras', [ ], ]); -/** - * One row per key and value shape. `parsePriority` deploys an integer or an - * expression and refuses every other constant, for `jobPriority` as for - * `taskPriority`. - */ checks( 'Validation - a priority is an integer or an expression', ( [ - ['taskPriority', '1.5', [priorityShape('taskPriority')]], - ['taskPriority', '"high"', [priorityShape('taskPriority')]], - ['jobPriority', '2.5', [priorityShape('jobPriority')]], - ['jobPriority', 'true', [priorityShape('jobPriority')]], + ['taskPriority', '1.5', [priorityShapeMessage('taskPriority')]], + ['taskPriority', '"high"', [priorityShapeMessage('taskPriority')]], + ['jobPriority', '2.5', [priorityShapeMessage('jobPriority')]], + ['jobPriority', 'true', [priorityShapeMessage('jobPriority')]], ['taskPriority', '42', []], ['taskPriority', '-5', []], ['taskPriority', '"42"', []], @@ -5650,39 +4836,29 @@ checks( checks('Validation - form references on a user task', [ [ - 'a form reference resolved by binding is accepted', - `process p { user T(formRef: "review-form", binding: latest) }`, - [], - ], - [ - 'a form reference pinned to a version is accepted', - `process p { user T(formRef: "review-form", version: 2) }`, - [], - ], - [ - 'a form key alone is still accepted', - `process p { user T(formKey: "review-form") }`, + 'a form reference resolved by binding or pinned to a version, a bareword form id, and a form key alone are accepted', + `process p { user T(formRef: "review-form", binding: latest) user U(formRef: "review-form", version: 2) user V(formRef: reviewForm, binding: latest) user W(formKey: "review-form") }`, [], ], [ 'a form key beside a form reference is one error', `process p { user T(formKey: "k", formRef: "review-form", binding: latest) }`, - [FORM_KEY_AND_REF], + [FORM_KEY_AND_REF_MESSAGE], ], [ 'a form reference with no binding is one error', `process p { user T(formRef: "review-form") }`, - [FORM_REF_NEEDS_BINDING], + [FORM_REF_BINDING_MESSAGE], ], [ 'a binding with no form reference has nothing to pin', `process p { user T(binding: latest) }`, - [FORM_REF_MISSING], + [FORM_REF_MISSING_MESSAGE], ], [ 'a version with no form reference has nothing to pin', `process p { user T(version: 2) }`, - [FORM_REF_MISSING], + [FORM_REF_MISSING_MESSAGE], ], [ 'a binding and a version together is reported once', @@ -5699,11 +4875,6 @@ checks('Validation - form references on a user task', [ `process p { user T(formRef: "review-form", binding: newest) }`, [BINDING_VALUE], ], - [ - 'a form id names a form, not a variable', - `process p { user T(formRef: reviewForm, binding: latest) }`, - [], - ], ]); checks('Validation - listeners', [ @@ -5780,7 +4951,7 @@ checks('Validation - listeners', [ [ 'a timeout listener without a timer asks for the particle clause', `process p { user U { on timeout(class: "com.example.T") } }`, - [LISTENER_TIMER_PAYLOAD], + [LISTENER_TIMER_PAYLOAD_MESSAGE], ], [ 'a timer on any other listener event is one error', @@ -5869,14 +5040,8 @@ checks('Validation - process header attributes', [ ], ]); -/** A header setting's value, alone, so each row is one shape and its whole list. */ const header = (setting: string) => `process p(${setting}) { start S }`; -/** - * The engine reads no header value as an expression. Revert checks: each - * rule's arm removed from `PROCESS_HEADER_VALUE_RULES` turns its refused rows - * clean; the length check dropped turns the 65-character row clean alone. - */ checks('Validation - process header value shapes', [ ...['"30"', '"P0D"', '"P30D"'].map((value): Case => [ `historyTimeToLive ${value} is a day count the engine parses`, @@ -5887,28 +5052,28 @@ checks('Validation - process header value shapes', [ (value): Case => [ `historyTimeToLive ${value} fails the deployment, so it is refused`, header(`historyTimeToLive: ${value}`), - [HISTORY_TIME_TO_LIVE_SHAPE], + [HISTORY_TIME_TO_LIVE_MESSAGE], ], ), [ 'a bareword historyTimeToLive is refused the same way and names no variable', header('historyTimeToLive: P90D'), - [HISTORY_TIME_TO_LIVE_SHAPE], + [HISTORY_TIME_TO_LIVE_MESSAGE], ], [ 'a versionTag template is stored as its own text, so it is refused', header('versionTag: "${v}"'), - [VERSION_TAG_LITERAL], + [VERSION_TAG_LITERAL_MESSAGE], ], [ 'a number in versionTag asks for quotes', header('versionTag: 3'), - [VERSION_TAG_LITERAL], + [VERSION_TAG_LITERAL_MESSAGE], ], [ 'a bareword in versionTag asks for quotes and names no variable', header('versionTag: deadline'), - [VERSION_TAG_LITERAL], + [VERSION_TAG_LITERAL_MESSAGE], ], [ 'a 64-character versionTag fits its column', @@ -5918,7 +5083,7 @@ checks('Validation - process header value shapes', [ [ 'a 65-character versionTag overflows its column', header(`versionTag: "${'v'.repeat(65)}"`), - [VERSION_TAG_LENGTH], + [VERSION_TAG_LENGTH_MESSAGE], ], [ 'a template anywhere in a starter list is stored as a literal id, so it is refused', @@ -5926,19 +5091,19 @@ checks('Validation - process header value shapes', [ 'candidateStarterUsers: "demo, ${starter}", candidateStarterGroups: "#{g}"', ), [ - candidateStarterTemplate('candidateStarterUsers'), - candidateStarterTemplate('candidateStarterGroups'), + candidateStarterMessage('candidateStarterUsers'), + candidateStarterMessage('candidateStarterGroups'), ], ], [ 'a header label or documentation is a quoted string, never a number or a template', header('label: 3, documentation: "${d}"'), - [headerLiteral('label'), headerLiteral('documentation')], + [headerLiteralMessage('label'), headerLiteralMessage('documentation')], ], [ 'a bareword header label names no variable', header('label: foo'), - [headerLiteral('label')], + [headerLiteralMessage('label')], ], [ 'a label on a step still takes the expression a task name evaluates', @@ -5947,15 +5112,12 @@ checks('Validation - process header value shapes', [ ], ]); -/** The form carrying every optional part, the one a nested placement must still take. */ const DECORATED_CLAUSE = 'for each line in lines sequentially until (nrOfCompletedInstances >= 2)'; /** - * One statement per kind that takes the clause, with the clause, the parens - * items, and the block members left open. Each writes whatever else its own - * validation demands, so the only diagnostics a case can produce are the - * slots'. + * Each host writes whatever else its own validation demands, so the only + * diagnostics a case can produce are the slots'. */ const REPEAT_HOSTS: Array< [ @@ -5983,7 +5145,6 @@ const REPEAT_HOSTS: Array< ['call', 'a call', repeatHost('call C', 'process: "q"')], ]; -/** An empty parens or block is omitted, so the unrepeated form is what an author writes. */ function repeatHost(head: string, own = '', tail = '') { return (clause: string, items: string, members: string) => { const all = [own, items].filter(Boolean).join(', '); @@ -6013,7 +5174,7 @@ describe('Validation - the repeat clause', () => { '`%s` is not a repeat count', async (clause) => { expect(await diagnosticsOf(`process p { user U ${clause} }`)).toEqual([ - REPEAT_COUNT, + REPEAT_COUNT_MESSAGE, ]); }, ); @@ -6056,7 +5217,7 @@ describe('Validation - the repeat clause', () => { await diagnosticsOf( `process p { ${statement('for 3', '', 'output b = 1')} }`, ), - ).toEqual([REPEATED_OUTPUT]); + ).toEqual([REPEATED_OUTPUT_MESSAGE]); }, ); @@ -6110,11 +5271,6 @@ checks('Validation - the repeat clause in scope', [ const RUN_SETTINGS = 'runAsyncBefore: true, runAsyncAfter: true, runExclusive: false, runRetryCycle: "R3/PT10M"'; -/** - * Revert checks: dropping `repeats` from the `GenericTask` row turns the step - * rows red, dropping the `run` prefix from the boolean set the `runExclusive` - * row, and removing the no-clause guard the no-clause rows. - */ checks('Validation - per-run job settings', [ ...REPEAT_HOSTS.map(([kind, , statement]): Case => [ `${kind} with a clause takes every run key`, @@ -6125,10 +5281,10 @@ checks('Validation - per-run job settings', [ `${kind} without a clause refuses every run key, one refusal per key`, `process p { ${statement('', RUN_SETTINGS, '')} }`, [ - runWithoutClause('runAsyncBefore', description, 'asyncBefore'), - runWithoutClause('runAsyncAfter', description, 'asyncAfter'), - runWithoutClause('runExclusive', description, 'exclusive'), - runWithoutClause('runRetryCycle', description, 'retryCycle'), + runWithoutClauseMessage('runAsyncBefore', description), + runWithoutClauseMessage('runAsyncAfter', description), + runWithoutClauseMessage('runExclusive', description), + runWithoutClauseMessage('runRetryCycle', description), ], ]), [ @@ -6144,7 +5300,7 @@ checks('Validation - per-run job settings', [ [ 'runJobPriority on a repeated step names the key the engine reads', `process p { step X for 2(runJobPriority: 5) }`, - [RUN_JOB_PRIORITY], + [RUN_JOB_PRIORITY_MESSAGE], ], [ 'runJobPriority on a kind that never repeats is an unknown key there', @@ -6174,12 +5330,11 @@ checks('Validation - per-run job settings', [ ]); /** - * One program per slot a check reads and names back, with the reserved word the - * parser reports for it. A hard keyword in the slot makes the parser recover - * with it left empty, and where that keyword also opens a statement of its own - * the statement's body is empty too. Three slots carry no word: a form field - * type is a keyword alternation with no `ID` alternative, and an empty trailing - * `STRING` is a shape no keyword produces. + * A hard keyword in a slot makes the parser recover with the slot left empty, + * so each program reaches one check that reads an empty name or trigger back. + * A row without a word empties its slot another way: a form field type is a + * keyword alternation with no `ID` alternative, and a trailing `STRING` or + * value can be left out. */ const UNPARSED_SLOT: Array<[where: string, program: string, word?: string]> = [ ['a `var` name', `process p { var while: number start S end E }`, 'while'], diff --git a/packages/language/test/variables.test.ts b/packages/language/test/variables.test.ts index 4220a29..1bde365 100644 --- a/packages/language/test/variables.test.ts +++ b/packages/language/test/variables.test.ts @@ -1,44 +1,41 @@ /** - * Tests for the {@link VariableSymbolProvider} service. - * - * The provider turns a `Process` AST into a flat, position-independent variable - * table (declared variable names -> their Operaton-aligned types) that the - * validators consult. - * - * The tests parse a process with `parseHelper` (no validation needed) and - * exercise the provider directly. + * `VariableSymbolProvider.collect`, the flat table of every name a process + * declares, driven through the injected service on a parsed process. */ import { beforeAll, describe, expect, test } from 'vitest'; import { EmptyFileSystem } from 'langium'; import { parseHelper } from 'langium/test'; -import type { Model, Process, VarType } from '@bpmn-script/language'; -import { - createBpmnScriptServices, - DefaultVariableSymbolProvider, - type VariableSymbolProvider, +import type { + Model, + VariableSymbol, + VariableSymbolProvider, + VariableTable, } from '@bpmn-script/language'; +import { createBpmnScriptServices } from '@bpmn-script/language'; import { formatParseFailure } from './helpers/parse-failure.js'; -let services: ReturnType; +let provider: VariableSymbolProvider; let parse: ReturnType>; beforeAll(() => { - services = createBpmnScriptServices(EmptyFileSystem); + const services = createBpmnScriptServices(EmptyFileSystem); + provider = services.BpmnScript.references.VariableSymbolProvider; parse = parseHelper(services.BpmnScript); }); -describe('VariableSymbolProvider', () => { - test('is registered as an injectable language service', () => { - const provider: VariableSymbolProvider = - services.BpmnScript.references.VariableSymbolProvider; - expect(provider).toBeDefined(); - expect(typeof provider.collect).toBe('function'); - }); +const LOOP_COUNTERS: [string, VariableSymbol][] = [ + 'nrOfInstances', + 'nrOfActiveInstances', + 'nrOfCompletedInstances', + 'loopCounter', +].map((name) => [name, { name, type: 'number' }]); - test('collects declared variables with their types for a multi-var process', async () => { - const process = await parseProcess(` -process p { +describe('the variable table of a process', () => { + test.each([ + [ + 'a header var of every type enters with that type', + `process p { var amount: number var name: string var flag: boolean @@ -47,96 +44,63 @@ process p { var misc: any start S end E -} -`); - const table = newProvider().collect(process); - expect(table.size).toBe(6); - expect(table.get('amount')?.type).toBe('number'); - expect(table.get('name')?.type).toBe('string'); - expect(table.get('flag')?.type).toBe('boolean'); - expect(table.get('due')?.type).toBe('date'); - expect(table.get('payload')?.type).toBe('json'); - expect(table.get('misc')?.type).toBe('any'); - }); - - test('a process with no var declarations yields an empty table', async () => { - const process = await parseProcess(`process p { start S end E }`); - expect(newProvider().collect(process).size).toBe(0); - }); - - test('a field name stays out of the table while input and output names enter it', async () => { - const process = await parseProcess(` -process p { +}`, + new Map([ + ['amount', { name: 'amount', type: 'number' }], + ['name', { name: 'name', type: 'string' }], + ['flag', { name: 'flag', type: 'boolean' }], + ['due', { name: 'due', type: 'date' }], + ['payload', { name: 'payload', type: 'json' }], + ['misc', { name: 'misc', type: 'any' }], + ]), + ], + [ + 'a process declaring nothing has an empty table', + `process p { start S end E }`, + new Map(), + ], + [ + 'a field name stays out of the table while input and output names enter it', + `process p { service S(class: "com.acme.D") { input amount = 1 output result = "x" field greeting = "hello" on start(class: "com.acme.L") { field salutation = "hi" } } -} -`); - expect(newProvider().collect(process)).toEqual( +}`, new Map([ ['amount', { name: 'amount', type: 'any' }], ['result', { name: 'result', type: 'any' }], ]), - ); - }); - - test('a repeat element enters the table as any beside the loop counters', async () => { - const process = await parseProcess(` -process p { + ], + [ + 'a repeat element enters the table as any beside the loop counters', + `process p { var items: json user U for each item in items -} -`); - expect(newProvider().collect(process)).toEqual( - new Map([ +}`, + new Map([ ['items', { name: 'items', type: 'json' }], ['item', { name: 'item', type: 'any' }], - ['nrOfInstances', { name: 'nrOfInstances', type: 'number' }], - [ - 'nrOfActiveInstances', - { name: 'nrOfActiveInstances', type: 'number' }, - ], - [ - 'nrOfCompletedInstances', - { name: 'nrOfCompletedInstances', type: 'number' }, - ], - ['loopCounter', { name: 'loopCounter', type: 'number' }], + ...LOOP_COUNTERS, ]), - ); - }); - - test('collect() answers membership and type queries via the returned table', async () => { - const process = await parseProcess(` -process p { - var amount: number - start S - end E -} -`); - const table = newProvider().collect(process); - expect(table.has('amount')).toBe(true); - expect(table.has('missing')).toBe(false); - expect(table.get('amount')?.type).toBe('number'); - expect(table.get('missing')?.type).toBeUndefined(); + ], + [ + 'a count alone seeds the loop counters, and a declared one keeps its own type', + `process p { + var loopCounter: string + user U for 3 +}`, + new Map([ + ['loopCounter', { name: 'loopCounter', type: 'string' }], + ...LOOP_COUNTERS.filter(([name]) => name !== 'loopCounter'), + ]), + ], + ])('%s', async (_title, source, expected) => { + const document = await parse(source); + expect(formatParseFailure(document)).toBeUndefined(); + const process = document.parseResult.value.processes[0]!; + expect(provider.collect(process)).toEqual(expected); }); }); - -// ── Helpers ────────────────────────────────────────────────────────────────── - -/** A fresh provider with no contributors. */ -function newProvider(): VariableSymbolProvider { - return new DefaultVariableSymbolProvider(); -} - -/** Parse a source string and return its single process, failing on parse error. */ -async function parseProcess(source: string): Promise { - const document = await parse(source.trim()); - const failure = formatParseFailure(document); - if (failure) { - throw new Error(`source failed to parse:\n${failure}`); - } - return document.parseResult.value.processes[0]!; -} diff --git a/packages/language/test/vocabulary-sync.test.ts b/packages/language/test/vocabulary-sync.test.ts index b311334..dd53abc 100644 --- a/packages/language/test/vocabulary-sync.test.ts +++ b/packages/language/test/vocabulary-sync.test.ts @@ -1,12 +1,10 @@ /** - * Two lists that have to agree with a list they cannot import. - * - * A TextMate grammar cannot read TypeScript, so its keyword alternation is a - * second derivation of the keywords the parser reserves. It is generated into - * `syntaxes/`, which is gitignored, so a stale copy never shows up in a diff. - * - * A trigger word and a listener event both follow `on`, so one word appearing - * in both vocabularies would give `on ` two meanings. + * Lists that have to agree with a list they cannot import: the TextMate + * grammar cannot read TypeScript, so its keyword alternation is a second + * derivation of what the parser reserves, generated into the gitignored + * `syntaxes/` where a stale copy never shows in a diff. A trigger word and a + * listener event both follow `on`, so one word in both would give `on ` + * two meanings. */ import { readFileSync } from 'node:fs'; @@ -18,7 +16,9 @@ import { EMIT_TRIGGERS, END_TRIGGERS, EXECUTION_LISTENER_EVENTS, + FLAG_WORD_RULE, ON_TRIGGERS, + SETTING_KEY_RULE, splitFencedScript, START_TRIGGERS, TASK_LISTENER_EVENTS, @@ -31,11 +31,7 @@ const TEXTMATE_GRAMMAR = fileURLToPath( new URL('../syntaxes/bpmn-script.tmLanguage.json', import.meta.url), ); -/** - * `(? { ]; expect(listenerEvents.filter((event) => triggers.has(event))).toEqual([]); }); + + test('the completion provider names datatype rules the grammar has', () => { + const services = createBpmnScriptServices(EmptyFileSystem); + const rules = new Set( + services.BpmnScript.Grammar.rules.map((rule) => rule.name), + ); + expect( + [SETTING_KEY_RULE, FLAG_WORD_RULE].filter((name) => !rules.has(name)), + ).toEqual([]); + }); }); function grammarPatterns(): { name?: string; match?: string }[] { @@ -92,9 +98,7 @@ function grammarPatterns(): { name?: string; match?: string }[] { .patterns; } -/** Every match of the named top-level pattern, tried as a JS `RegExp` (both - * engines agree on lookaround and the classes used here, the same assumption - * `injection-grammar.test.ts` makes for the extension's Oniguruma grammar). */ +/** Every match of the named top-level pattern as a JS `RegExp`; Oniguruma agrees on the lookaround and classes used here, as `injection-grammar.test.ts` assumes too. */ function tokensOf(line: string, namePrefix: string): string[] { const pattern = grammarPatterns().find((p) => p.name?.startsWith(namePrefix)); if (!pattern?.match) { diff --git a/packages/transform/README.md b/packages/transform/README.md index 22f6fd4..6c905de 100644 --- a/packages/transform/README.md +++ b/packages/transform/README.md @@ -40,8 +40,8 @@ interface BpmnProcess { isExecutable: true; // always true (executable process) versionTag?: string; // operaton:versionTag, an author-supplied version label historyTimeToLive?: string; // operaton:historyTimeToLive, absent means the exporter's default (P30D) - candidateStarterUsers?: string; // operaton:candidateStarterUsers, comma-separated user ids listed as candidate starters (Tasklist's process list; BpmnDeployer.addAuthorizations stores each entry as written) - candidateStarterGroups?: string; // operaton:candidateStarterGroups, comma-separated group ids, listed and stored the same way + candidateStarterUsers?: string; // operaton:candidateStarterUsers, comma-separated user ids stored as written + candidateStarterGroups?: string; // operaton:candidateStarterGroups, comma-separated group ids flowElements: FlowElement[]; sequenceFlows: SequenceFlow[]; errorDecls?: { name: string; code: string; message?: string }[]; // every error code the process raises, catches, or declares @@ -49,21 +49,21 @@ interface BpmnProcess { } type FlowElement = - | StartEvent // kind: 'startEvent' (+eventDefinition? on a process's own start with a trigger, or on an event-handler start, +initiator?, +formFields?) - | EndEvent // kind: 'endEvent' (+eventDefinition? for a typed throw end, a terminate, or a cancel) - | UserTask // kind: 'userTask' (+assignee?, +formKey?, +formRef?: a deployed form's key and the binding resolving it, +formFields?, +candidateGroups?/candidateUsers?/dueDate?/followUpDate?/priority?, +loop?) - | ServiceTask // kind: 'serviceTask' (+binding: class | expression | delegateExpression | external | decision | builtin, class/delegateExpression/builtin also carrying +fields?: the name and value pairs the engine sets on the bean, the external one +taskPriority?/properties?/errorMappings?, the builtin one +type: 'mail' | 'shell', +resultVariable?, +element?: send | businessRule, the tag it serializes to, +loop?) - | ScriptTask // kind: 'scriptTask' (+format, +code, +resultVariable?, +loop?) - | Task // kind: 'task' (no binding of its own; the engine records it and passes straight through; +loop?) - | ReceiveTask // kind: 'receiveTask' (+messageName?: absent waits for the engine's own signal API instead of a correlation, +loop?) - | ExclusiveGateway // kind: 'exclusiveGateway' (+defaultFlowId?) + | StartEvent // kind: 'startEvent' (+eventDefinition?, +initiator?, +formFields?) + | EndEvent // kind: 'endEvent' (+eventDefinition?: a typed throw, a terminate, or a cancel) + | UserTask // kind: 'userTask' (+assignee?, +formKey?, +formRef?, +formFields?, +candidateGroups?/candidateUsers?/dueDate?/followUpDate?/priority?, +loop?) + | ServiceTask // kind: 'serviceTask' (+binding: class | expression | delegateExpression | external | decision | builtin, +resultVariable?, +element?: send | businessRule, +loop?) + | ScriptTask // kind: 'scriptTask' (+format, +code, +resultVariable?, +loop?) + | Task // kind: 'task' (no binding; the engine passes straight through; +loop?) + | ReceiveTask // kind: 'receiveTask' (+messageName?, absent waits for the engine's own signal API; +loop?) + | ExclusiveGateway // kind: 'exclusiveGateway' (+defaultFlowId?) | ParallelGateway // kind: 'parallelGateway' - | InclusiveGateway // kind: 'inclusiveGateway' (+defaultFlowId?) + | InclusiveGateway // kind: 'inclusiveGateway' (+defaultFlowId?) | EventBasedGateway // kind: 'eventBasedGateway' - | SubProcess // kind: 'subProcess' (a nested FlowContainer; may host an on-compensation undo block; +element?: 'transaction', the tag it serializes to; +loop?) - | CallActivity // kind: 'callActivity' (starts another process; in/out mappings, binding, businessKey, +mapper?: the class or bean that computes the mapping in code, +loop?) - | IntermediateThrowEvent // kind: 'intermediateThrowEvent' (an emit: escalation | message | signal | compensation | link; +binding? on a message, the implementation Operaton sends it with) - | IntermediateCatchEvent // kind: 'intermediateCatchEvent' (an await: message | timer | signal | conditional | link) + | SubProcess // kind: 'subProcess' (a nested FlowContainer; +element?: 'transaction'; +loop?) + | CallActivity // kind: 'callActivity' (+calledElement, +binding?, +businessKey?, +mapper?, +inMappings?/outMappings?, +loop?) + | IntermediateThrowEvent // kind: 'intermediateThrowEvent' (an emit; +binding? on a message) + | IntermediateCatchEvent // kind: 'intermediateCatchEvent' (an await) | BoundaryEvent; // kind: 'boundaryEvent' (attached to an activity in the same container; +cancelActivity?: false) interface SequenceFlow { @@ -126,7 +126,7 @@ const { ir: imported, warnings } = await xmlToIr(xmlString); // async; discards const { source, warnings: printWarnings } = irToDsl(ir); // sync; warns for what it cannot carry into the script ``` -`src/index.ts` is the public surface, summarized here: the IR types, `isGateway` and `gatewayDefaultFlowId` for reading them at runtime, the deterministic id helpers, the JUEL parser (`parseJuel`, `renderRawFallback`), the `Unsupported*Error` classes, and the `ImportWarning` and `PrintWarning` types. +`src/index.ts` is the public surface, summarized here: the IR types, `isGateway` and `gatewayDefaultFlowId` for reading them at runtime, the deterministic id helpers, the JUEL parser (`parseJuel`, `renderRawFallback`), the `Unsupported*Error` classes, `LayoutError`, and the `ImportWarning` and `PrintWarning` types. ## The import contract @@ -135,6 +135,7 @@ What it cannot represent falls into two buckets; the reasoning is in [ADR-0014]( Content the IR cannot express at all is refused: `xmlToIr` throws a subclass of `UnsupportedConstructError` before producing any IR, so there is no partial output. Content Operaton refuses to deploy is refused the same way, since there is nothing that runs to write back, and the message names the `BpmnParse` method that fails the deployment. +A document whose root is not `bpmn:definitions`, a process or flow element without an `id`, or a sequence flow whose `sourceRef` or `targetRef` resolves to nothing is malformed rather than unsupported and throws a plain `Error`, which the CLI reports as a parse failure. Content the IR does not carry (an extra Operaton extension attribute, a lane, a text annotation) comes back through the `warnings` array instead. Every drop of a setting the engine reads names the method that reads it and says the document written back runs without it, so a reader can tell a cosmetic drop from one that changes what runs. @@ -153,7 +154,7 @@ One is an attribute in a foreign namespace written directly on a mapped BPMN ele The same attribute on an Operaton extension element the IR reads is reported, a whole foreign-namespace extension element is reported too, and an attribute written in no namespace at all that BPMN does not declare is reported as well, since no editor writes there. The other is a BPMN attribute this surface neither reads nor reports: a process's `processType` or `isClosed`. Each of those is content left out. -A lone `operaton:resource` on a sequence flow's condition expression or a conditional event definition's condition is the one exception that is reported rather than silently dropped: Operaton reads it only alongside a `language`, so on its own it reaches nothing, and the condition still imports as the expression its body writes. +A lone `operaton:resource` on a sequence flow's condition expression or a conditional event definition's condition is the one exception that is reported rather than dropped: Operaton reads it only alongside a `language`, so on its own it reaches nothing, and the condition still imports as the expression its body writes. `isExecutable="false"`, whose import changes what the document says, is reported instead of left for the reader to notice, and so is an absent `isExecutable`, which `BpmnParse.parseProcessDefinitions` skips in a new deployment. ### Refusals @@ -161,24 +162,77 @@ A lone `operaton:resource` on a sequence flow's condition expression or a condit Every class below extends `UnsupportedConstructError`, so catching that one classifies any refusal. `src/errors.ts` carries the per-class detail and `src/xml-to-ir.ts` the exact conditions. -| Class | Refused because | -| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `UnsupportedDocumentError` | A document Operaton refuses to deploy whole, or one this tool cannot read as one process: a `` declaration, which `Parser.setXxeProcessing` disallows; an entity reference XML does not predefine, which the engine's parse fails on; an `isSequential` or `triggeredByEvent` value outside `true` and `false`, which the validating parse against `BPMN20.xsd` refuses; an id written on two elements, which the same validation refuses as a duplicate `xs:ID`; an id outside the ASCII letters, digits, `_`, `-`, and `.` this tool reads, where the schema admits any letter; a `bpmn:import`, which `BpmnParse.parseImports` fails on for every type but WSDL; no `bpmn:process` at all; or several processes of which none is marked `isExecutable="true"`, which `parseProcessDefinitions` deploys none of | -| `UnsupportedCollaborationError` | Two or more processes marked `isExecutable="true"`; `parseProcessDefinitions` deploys each of them and the IR holds one | -| `UnsupportedElementError` | An element kind outside the supported subset (`bpmn:adHocSubProcess`, `bpmn:complexGateway`, ...) | -| `UnsupportedServiceTaskFormError` | A service, send, or business rule task that carries no execution binding the engine would reach, that carries an `operaton:type` outside `external`, `mail`, and `shell`, or that carries an `` element beside its implementation attributes, which the Connect plugin runs in their place; a mail or shell task missing a field its behaviour's parse requires, naming a field its behaviour class does not declare, or carrying a shell field as an expression or a flag outside `true`/`false`; a thrown message whose binding shape it cannot read, a `mail`/`shell` type included, since a thrown message has no member block to carry the fields either requires ([ADR-0042](../../docs/decisions/0042-mail-and-shell-as-a-type-binding.md)); a service, send, or business rule task without a decision reference carrying `operaton:resultVariable`, or the older `operaton:resultVariableName`, beside `operaton:class` or `operaton:delegateExpression`, which `BpmnParse.parseServiceTaskLike` refuses to deploy ([ADR-0043](../../docs/decisions/0043-result-variable-beside-an-expression-binding.md)); or a business rule task carrying `operaton:decisionRefTenantId`, which pins the tenant `BpmnParse.parseTenantId` resolves the decision against, an `operaton:mapDecisionResult` outside the four words the engine maps, or a `decisionRefBinding` of `version` without a version or of `versionTag` | -| `UnsupportedFormFieldTypeError` | A form field typed outside `string`/`long`/`boolean`/`date`/`enum` | -| `UnsupportedFormFieldConstraintError` | A form field constraint the engine refuses to deploy or the script cannot spell: a constraint with no name, or a name outside the seven the surface takes, since `FormValidators.createValidator` fails the deployment on any name it has not registered; a `validator` or a bound with no `config`; or a name repeated on one field, which `DefaultFormHandler.parseValidation` deploys and this script holds once per field | -| `UnsupportedFormReferenceError` | A user task naming both a form key and a form reference, or a form reference with no binding, one outside `latest`, `deployment`, and `version`, or a `version` binding naming no version | -| `UnsupportedCallActivityError` | A call activity naming neither a `calledElement` nor an `operaton:caseRef`, or both, which `BpmnParse.parseCallActivity` refuses to deploy; one naming a `caseRef` alone, which the engine runs through `CaseCallActivityBehavior` and this surface has no form for; a `calledElementTenantId`, which pins the tenant `parseTenantId` resolves the called process against; `calledElementBinding="version"` without a version, or `="versionTag"`, which this surface has no setting for; or an `operaton:in`/`out` carrying a `variables` other than `all`, a `source=""`, which `parseCallableElementProvider` refuses, a `source` or `sourceExpression` with no `target`, or none of the recognized shapes | -| `UnsupportedEventDefinitionError` | An event definition of the wrong kind for its position | -| `UnsupportedEventFeatureError` | An event of the right kind in a shape the surface cannot express or the engine refuses to deploy. On the surface's side: a process start carrying an error, escalation, or compensation trigger, which Operaton ignores; a message start whose name is an expression; a sub-process start carrying a trigger; a second start event in a sub-process or transaction; a thrown message carrying a connector; a thrown message's `resultVariable` beside `operaton:expression`, under which `parseServiceTaskLike` stores the expression's value and the script's throw has no slot for it; a link definition naming no link, and a sequence flow leaving a link throw or entering a link catch; a branch of a wait with several branches that leads to anything other than another wait, and a branch-opening wait that another path also flows into; a compensation boundary event or an `isForCompensation` activity, with the `subprocess`/`on compensation` rewrite named when the two pair up through a `bpmn:association`; a cancel boundary carrying `cancelActivity="false"`; and a `messageRef` or `signalRef` naming no root, as one written with a prefix does, since `BpmnParse.resolveName` maps a prefix through the xmlns table and this tool matches the id as written. On the engine's side, each naming the method: two or more plain or timer starts on the process (`selectInitial`); a process or a sub-process with no start (`parseStartEvents`); a sequence flow into or out of an event handler (`parseSequenceFlow`); a thrown message's `resultVariable` beside `operaton:class` or `operaton:delegateExpression` (`parseServiceTaskLike`); an error end with no `errorRef`, and an error or escalation throw whose root carries no code (`parseEndEvents`, `parseIntermediateThrowEvent`); an escalation throw with no `escalationRef` or one naming no root, and an escalation catch naming no root (`findEscalationForEscalationEventDefinition`, `createEscalationEventDefinitionForEscalationHandler`); two `bpmn:signal` roots sharing a name (`parseSignals`); a cancel definition on an end outside a `bpmn:transaction`, on a boundary whose host is not one, or on a second cancel boundary of one block; and a wait with several branches carrying `operaton:asyncAfter` (`parseEventBasedGateway`, [ADR-0040](../../docs/decisions/0040-engine-settings-on-synthesized-gateways.md)) | -| `UnsupportedGatewayShapeError` | An exclusive gateway whose outgoing flows `BpmnParse.validateExclusiveGateway` fails the deployment on, quoting the engine's own sentence: no outgoing flow; one outgoing flow carrying a condition; a default flow carrying a condition; or a flow without a condition that is not the default, beside a default or beside another unconditioned flow. One conditioned flow beside one plain flow with no default imports, since the engine deploys it and takes the plain flow as the default | -| `UnsupportedLoopCharacteristicsError` | A multi-instance repetition on an event handler, which its trigger enters rather than repeats, or a multi-instance repetition anywhere in a shape this surface cannot write back: an `operaton:outputParameter` beside it, which `BpmnParse.checkActivityOutputParameterSupported` fails the deployment on, a `bpmn:loopCardinality` body or an element name it cannot spell, or a combination Operaton refuses to deploy | -| `UnsupportedExtensionFormError` | Extension content in a shape this surface cannot write or the engine refuses. On the surface's side: an input/output value form it cannot write, a listener naming no binding or an event outside its position's, a `timeout` listener with no timer or two, an injected field naming no value slot or both of its literal ones, a form field `id` or an input/output parameter `name` the script cannot spell (letters, digits, `_`, an inner `-`, no keyword), since the engine sets the variable under that name; two input or two output parameters sharing a name, which `IoMapping.executeInputParameters` runs both of with the last write winning; a script naming a deployment resource, which `ScriptUtil.getScript` runs in place of the body; and a script body containing three consecutive backticks, which no fence this language has can enclose. On the engine's side, each naming the method: an `operaton:inputOutput` on a start event or a boundary event (`ensureNoIoMappingDefined`), on any gateway or on an event handler (`checkActivityInputOutputSupported`); an `operaton:outputParameter` on an end event (`checkActivityOutputParameterSupported`); a second `operaton:inputOutput`, `operaton:formData`, `operaton:properties`, or `operaton:failedJobRetryTimeCycle` on one element, which `Element.elementNS` throws on (the retry cycle's reader runs on an async step, a timer-driven event or a typed throw alone, and the refusal says so rather than gating on it); an execution listener with `class=""` or `delegateExpression=""` (`parseExecutionListener`); a `jobPriority` or `taskPriority` that is neither an integer nor an expression (`parsePriority`); and a script task with an empty `scriptFormat` or with neither a body nor a resource (`ScriptUtil.getScript`) | -| `UnsupportedErrorMappingError` | An `operaton:errorEventDefinition` on an external task carrying an `errorRef` and no `expression`, which `parseOperatonErrorEventDefinitions` fails the deployment on, or an `errorRef` naming an error root with no code | -| `UnsupportedAssignmentError` | A user task carrying a `bpmn:humanPerformer` beside `operaton:assignee`, which `parseUserTaskCustomExtensions` refuses as a duplicate assignee, or more than one `bpmn:humanPerformer`, which `parseHumanPerformer` refuses | -| `UnsupportedConditionExpressionError` | A sequence flow's condition or a conditional event definition's condition carrying a `language`, which Operaton evaluates in a script engine rather than as the UEL expression this tool writes, or typed through `xsi:type` as anything but `tFormalExpression`, which `parseConditionExpression` fails the deployment on | +`UnsupportedDocumentError` covers a document Operaton refuses to deploy whole, or one this tool cannot read as one process. +That is a `` declaration, which `Parser.setXxeProcessing` disallows, and an entity reference XML does not predefine, which the engine's parse fails on. +It is an `isSequential` or `triggeredByEvent` value outside `true` and `false`, which the validating parse against `BPMN20.xsd` refuses. +It is an id written on two elements, which the same validation refuses as a duplicate `xs:ID`, and an id outside the ASCII letters, digits, `_`, `-`, and `.` this tool reads, where the schema admits any letter. +It is a `bpmn:import`, which `BpmnParse.parseImports` fails on for every type but WSDL. +And it is no `bpmn:process` at all, or several processes of which none is marked `isExecutable="true"`, which `parseProcessDefinitions` deploys none of. + +`UnsupportedCollaborationError` covers two or more processes marked `isExecutable="true"`: `parseProcessDefinitions` deploys each of them and the IR holds one. + +`UnsupportedElementError` covers an element kind outside the supported subset, such as `bpmn:adHocSubProcess` or `bpmn:complexGateway`. + +`UnsupportedServiceTaskFormError` covers a service, send, or business rule task that carries no execution binding the engine would reach, that carries an `operaton:type` outside `external`, `mail`, and `shell`, or that carries an `` element beside its implementation attributes, which the Connect plugin runs in their place. +A mail or shell task refuses when it misses a field its behaviour's parse requires, names a field its behaviour class does not declare, or carries a shell field as an expression or a flag outside `true`/`false`. +A thrown message refuses on a binding shape this tool cannot read, a `mail`/`shell` type included, since a thrown message has no member block to carry the fields either requires ([ADR-0042](../../docs/decisions/0042-mail-and-shell-as-a-type-binding.md)). +A service, send, or business rule task without a decision reference refuses when it carries `operaton:resultVariable`, or the older `operaton:resultVariableName`, beside `operaton:class` or `operaton:delegateExpression`, which `BpmnParse.parseServiceTaskLike` refuses to deploy ([ADR-0043](../../docs/decisions/0043-result-variable-beside-an-expression-binding.md)). +A business rule task refuses on an `operaton:decisionRefTenantId`, which pins the tenant `BpmnParse.parseTenantId` resolves the decision against, on an `operaton:mapDecisionResult` outside the four words the engine maps, and on a `decisionRefBinding` of `version` without a version or of `versionTag`. + +`UnsupportedFormFieldTypeError` covers a form field typed outside `string`/`long`/`boolean`/`date`/`enum`. + +`UnsupportedFormFieldConstraintError` covers a form field constraint the engine refuses to deploy or the script cannot spell. +That is a constraint with no name, or a name outside the seven the surface takes, since `FormValidators.createValidator` fails the deployment on any name it has not registered. +It is also a `validator` or a bound with no `config`, and a name repeated on one field, which `DefaultFormHandler.parseValidation` deploys and this script holds once per field. + +`UnsupportedFormReferenceError` covers a user task naming both a form key and a form reference, and a form reference with no binding, one outside `latest`, `deployment`, and `version`, or a `version` binding naming no version. + +`UnsupportedCallActivityError` covers a call activity naming neither a `calledElement` nor an `operaton:caseRef`, or both, which `BpmnParse.parseCallActivity` refuses to deploy. +It covers one naming a `caseRef` alone, which the engine runs through `CaseCallActivityBehavior` and this surface has no form for. +It covers a `calledElementTenantId`, which pins the tenant `parseTenantId` resolves the called process against, and `calledElementBinding="version"` without a version or `="versionTag"`, which this surface has no setting for. +And it covers an `operaton:in`/`out` carrying a `variables` other than `all`, a `source=""`, which `parseCallableElementProvider` refuses, a `source` or `sourceExpression` with no `target`, or none of the recognized shapes. + +`UnsupportedEventDefinitionError` covers an event definition of the wrong kind for its position. + +`UnsupportedEventFeatureError` covers an event of the right kind in a shape the surface cannot express or the engine refuses to deploy. +The conditions below are the ones a reader meets most; `src/xml-to-ir.ts` holds the full list. +Among them, on the surface's side, is a process start carrying an error, escalation, or compensation trigger, which Operaton ignores, a message start whose name is an expression, a sub-process start carrying a trigger, and a second start event in a sub-process or transaction. +A thrown message refuses when it carries a connector, and when its `resultVariable` sits beside `operaton:expression`, under which `parseServiceTaskLike` stores the expression's value and the script's throw has no slot for it. +A link definition naming no link refuses, and so does a sequence flow leaving a link throw or entering a link catch. +A branch of a wait with several branches that leads to anything other than another wait refuses, and so does a branch-opening wait that another path also flows into. +A compensation boundary event or an `isForCompensation` activity refuses, with the `subprocess`/`on compensation` rewrite named when the two pair up through a `bpmn:association`. +A cancel boundary carrying `cancelActivity="false"` refuses. +An error root carrying an `operaton:errorMessage` and no code refuses, since nothing can key the message, and so do two error roots declaring one code with two different messages, since usage cannot recover which text was meant. +A `messageRef` or `signalRef` naming no root refuses, as one written with a prefix does, since `BpmnParse.resolveName` maps a prefix through the xmlns table and this tool matches the id as written. +On the engine's side, each naming the method: two or more plain or timer starts on the process (`selectInitial`), and a process or a sub-process with no start (`parseStartEvents`). +A sequence flow into or out of an event handler refuses (`parseSequenceFlow`), and so does a thrown message's `resultVariable` beside `operaton:class` or `operaton:delegateExpression` (`parseServiceTaskLike`). +An error end with no `errorRef` refuses, and so does an error or escalation throw whose root carries no code (`parseEndEvents`, `parseIntermediateThrowEvent`). +An escalation throw with no `escalationRef` or one naming no root refuses, and so does an escalation catch naming no root (`findEscalationForEscalationEventDefinition`, `createEscalationEventDefinitionForEscalationHandler`). +Two `bpmn:signal` roots sharing a name refuse (`parseSignals`). +A cancel definition refuses on an end outside a `bpmn:transaction`, on a boundary whose host is not one, and on a second cancel boundary of one block. +A wait with several branches carrying `operaton:asyncAfter` refuses (`parseEventBasedGateway`, [ADR-0040](../../docs/decisions/0040-engine-settings-on-synthesized-gateways.md)). + +`UnsupportedGatewayShapeError` covers an exclusive gateway whose outgoing flows `BpmnParse.validateExclusiveGateway` fails the deployment on, quoting the engine's own sentence: no outgoing flow, one outgoing flow carrying a condition, a default flow carrying a condition, or a flow without a condition that is not the default, beside a default or beside another unconditioned flow. +One conditioned flow beside one plain flow with no default imports, since the engine deploys it and takes the plain flow as the default. + +`UnsupportedLoopCharacteristicsError` covers a multi-instance repetition on an event handler, which its trigger enters rather than repeats, and a multi-instance repetition anywhere in a shape this surface cannot write back. +That is an `operaton:outputParameter` beside it, which `BpmnParse.checkActivityOutputParameterSupported` fails the deployment on, a `bpmn:loopCardinality` body or an element name it cannot spell, or a combination Operaton refuses to deploy. + +`UnsupportedExtensionFormError` covers extension content in a shape this surface cannot write or the engine refuses. +On the surface's side, that is an input/output value form it cannot write, a listener naming no binding or an event outside its position's, a `timeout` listener with no timer or two, and an injected field naming no value slot or both of its literal ones. +It is a form field `id` or an input/output parameter `name` the script cannot spell (letters, digits, `_`, an inner `-`, no keyword), since the engine sets the variable under that name. +It is two input or two output parameters sharing a name, which `IoMapping.executeInputParameters` runs both of with the last write winning. +It is a script naming a deployment resource, which `ScriptUtil.getScript` runs in place of the body, and a script body containing three consecutive backticks, which no fence this language has can enclose. +On the engine's side, each naming the method: an `operaton:inputOutput` on a start event or a boundary event (`ensureNoIoMappingDefined`), on any gateway or on an event handler (`checkActivityInputOutputSupported`), and an `operaton:outputParameter` on an end event (`checkActivityOutputParameterSupported`). +A second `operaton:inputOutput`, `operaton:formData`, `operaton:properties`, or `operaton:failedJobRetryTimeCycle` on one element refuses, since `Element.elementNS` throws on it; the retry cycle's reader runs on an async step, a timer-driven event or a typed throw alone, and the refusal says so rather than gating on it. +An execution listener with `class=""` or `delegateExpression=""` refuses (`parseExecutionListener`), and so does a `jobPriority` or `taskPriority` that is neither an integer nor an expression (`parsePriority`). +A script task with an empty `scriptFormat` or with neither a body nor a resource refuses (`ScriptUtil.getScript`). + +`UnsupportedErrorMappingError` covers an `operaton:errorEventDefinition` on an external task carrying an `errorRef` and no `expression`, which `parseOperatonErrorEventDefinitions` fails the deployment on, and an `errorRef` naming an error root with no code. + +`UnsupportedAssignmentError` covers a user task carrying a `bpmn:humanPerformer` beside `operaton:assignee`, which `parseUserTaskCustomExtensions` refuses as a duplicate assignee, and more than one `bpmn:humanPerformer`, which `parseHumanPerformer` refuses. + +`UnsupportedConditionExpressionError` covers a sequence flow's condition or a conditional event definition's condition carrying a `language`, which Operaton evaluates in a script engine rather than as the UEL expression this tool writes, and one typed through `xsi:type` as anything but `tFormalExpression`, which `parseConditionExpression` fails the deployment on. ### Import warnings @@ -251,7 +305,7 @@ It also covers a label on a start or an end whose id is the one the compiler min Any other id prints, respelled if the script cannot spell it, with the print reporting the rename ([ADR-0050](../../docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md)). `unreferencedRoot` covers a `bpmn:Message` or `bpmn:Signal` root that nothing in the process references, a receive task's `messageRef` included. -It also covers an error or escalation root carrying no code, which nothing can key it by; one carrying a code imports as a declaration whether or not anything raises it. +It also covers an error or escalation root carrying no code, which nothing can key it by, unless it is an error root carrying an `operaton:errorMessage`, which refuses; one carrying a code imports as a declaration whether or not anything raises it. `documentation` covers `bpmn:documentation` at every position where `label` above is warned, for the same reason: an event handler, a typed end event, an intermediate throw, an intermediate catch, a boundary event, and a start or an end whose id is the one the compiler mints for its container. It also covers a `bpmn:documentation` on a position that reads no `name` at all: the definitions root, an event definition, an external task's `operaton:errorEventDefinition`, an Error/Escalation/Message/Signal root, a multi-instance loop characteristics element, and a sequence flow. @@ -297,6 +351,55 @@ A block holding a cancel end with no cancel boundary attached imports whole, and A cancel boundary on a block nothing inside gives up imports whole too, and the warning names the path that can never run. Refusing either would reject a file the engine deploys, which the import contract does not license. +### Print warnings + +`irToDsl` returns `{ source, warnings }`, and each `PrintWarning` names one thing the script could not carry from the IR, or one it wrote that draws an error when the source is read back. +`warnings` is `[]` for an IR that prints cleanly. +Every message keeps the id out of its text and in `elementId`, since a synthesized id routinely spells BPMN vocabulary the script keeps away from its readers. + +```ts +interface PrintWarning { + elementId: string; // id of the element the notice is about + category: + | 'label' + | 'documentation' + | 'droppedEdge' + | 'defaultFlow' + | 'degradedSplit' + | 'droppedCondition' + | 'refusedStatement' + | 'renamedId' + | 'droppedSetting'; + message: string; // names what was left out or changed and whether the process runs the same +} +``` + +`label` covers a `name` on a gateway, and `documentation` a `bpmn:documentation` on one: the script derives every split and every merge from its block structure, so there is no statement to carry either, and the process runs the same without it. +Every other elided label and documentation is reported by `xmlToIr`, so a caller printing both channels sees each drop once. + +`droppedEdge` covers a route the emitter can neither name nor place: a jump into a gateway that still has a choice of its own, into a gateway the walk has already crossed, or into a step the print leaves out. +The edge is left out, and the `// unstructured region: hand-repair required` marker written where it belonged names the step it led to. + +`defaultFlow` covers a split whose fallback the script cannot write as the model has it. +That is a split naming no fallback whose every route carries a condition, where the engine fails the run when none holds and the script carries on past the routes instead. +It is a fallback carrying a condition, which a fork or a step skips while weighing, so the run is the same without it, and which the engine refuses to deploy on a choice, so the script without it runs where the model as drawn does not. +And it is a fallback on an inclusive split beside an unconditioned branch, which nothing is ever left over for; the script writes it as an `else` all the same, which draws an error when the source is read back. + +`degradedSplit` covers a split whose branches reach no merge the block form can close, so each branch leaves as a jump under a marker line and the split's own kind is written nowhere, and a wait with several branches of which one opens on something other than a wait the block can hold: a step of another kind, a wait already printed, or one that splits. +It also covers a gateway with no route out, which the script leaves out where the engine ends the run, stops it with an error, waits forever, or refuses to deploy, depending on the gateway's kind. + +`droppedCondition` covers a condition on a route the script has no place for: a route out of a wait or out of a split that takes every route, which the engine weighs nowhere, and a route from one step straight to the next, which the engine takes only when the condition holds. +On a route beside an unconditioned fallback the model diverts the run rather than failing it, and the warning says which. + +`refusedStatement` covers a step written under a name the script keeps for the ids it mints itself, a plain synthesized end printed anywhere but at the tail of the block the compiler mints it for, and a jump into a branch of an `await` or `parallel` block from outside that branch. +Each prints as the model has it and draws an error when the source is read back. + +`renamedId` covers an id the script writes as a process header, a statement name, or a `goto` target and cannot spell, which prints under a minted name that no id in the document and no earlier minted name collides with. +The document built from the script carries the minted id, and the engine keys history, migration plans, and a start-before-activity on the activity id, so what runs is not the same ([ADR-0050](../../docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md)). + +`droppedSetting` covers job settings on a gateway that no statement head took over by the end of the walk, an elided pass-through or a gateway a jump was forwarded through among them. +The process runs without them. + ## Build and test ```bash @@ -329,6 +432,9 @@ npm test `irToXml` uses `bpmn-moddle@^10` and `bpmn-auto-layout@^1.2.0`. The layout library injects the `` data, so the IR needs no coordinate fields. +When `bpmn-auto-layout` throws on a validator-clean shape (a `goto` restructuring among them), `irToXml` raises `LayoutError` rather than an `Unsupported*Error`, and its `xml` field carries the document with no `bpmndi:` element, for any process. +Operaton deploys it the same, since `BpmnParse.parseDiagramInterchangeElements` reads a diagram only when one is present. +The CLI and the extension write that document either way and report the layout failure as a warning. Version 1.x exposes `layoutProcess(xml)` as a flat named export; the `new BpmnAutoLayout()` constructor belongs to the 0.x series and is not used here. The Operaton namespace comes from `src/operaton-moddle.json`, a trimmed fork of the camunda-bpmn-moddle descriptor ([ADR-0007](../../docs/decisions/0007-operaton-moddle-extension-fork.md)). @@ -346,6 +452,4 @@ A JUEL string literal always prints double-quoted, whichever quote character the ## Dependencies on other packages -- `@bpmn-script/language` (workspace) for the Langium-generated AST types `astToIr` consumes, and the `renderExpression` helper. - It also owns the shared DSL vocabulary in `packages/language/src/vocabulary.ts`: trigger words, timer particles, listener events, form-field types, form constraint names and the types each fits, member directions, the `taskPriority` key and the words of an error mapping, decision result mappings, and script-format aliases, so the two packages cannot drift on a word. - `astToIr` reads every one of those tables along with the `formatPlainWordList` and `splitFencedScript` helpers; `xmlToIr` the trigger words, listener events, decision result mappings, and constraint names and fits; `irToDsl` the end triggers, timer particles, the date pattern key, and `isReservedName`; `ir/types.ts` type-imports the trigger, event, mapping, form-type, and constraint-name tables to derive the IR union types. +- `@bpmn-script/language` (workspace) for the Langium-generated AST types `astToIr` consumes, the `renderExpression` helper, and the shared DSL vocabulary in `packages/language/src/vocabulary.ts`, so the two packages cannot drift on a word. diff --git a/packages/transform/package.json b/packages/transform/package.json index 3ee4e21..636ee1b 100644 --- a/packages/transform/package.json +++ b/packages/transform/package.json @@ -2,6 +2,12 @@ "name": "@bpmn-script/transform", "description": "IR type definitions and bidirectional BPMN transforms for BPMNscript", "version": "0.0.1", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/BPMNscript/bpmnscript.git", + "directory": "packages/transform" + }, "type": "module", "engines": { "node": ">=22.0.0", diff --git a/packages/transform/src/ast-to-ir.ts b/packages/transform/src/ast-to-ir.ts index 87ee4a6..161ca8e 100644 --- a/packages/transform/src/ast-to-ir.ts +++ b/packages/transform/src/ast-to-ir.ts @@ -54,6 +54,7 @@ import { formatPlainWordList, scriptFormatOf, splitFencedScript, + CALL_BINDING_VALUES, CALL_MAPPER_KEY_BY_KIND, CATCH_TRIGGERS, DATE_PATTERN_KEY, @@ -64,8 +65,10 @@ import { EXECUTION_LISTENER_EVENTS, FIELD_DIRECTION, FORM_FIELD_TYPES, + INPUT_DIRECTION, isFormConstraintName, ON_TRIGGERS, + OUTPUT_DIRECTION, PROPERTY_DIRECTION, START_TRIGGERS, TASK_PRIORITY_KEY, @@ -128,6 +131,7 @@ import type { CallVariableMapping, CatchEventDefinition, CodeBinding, + EmitEventDefinition, EndEventDefinition, EngineAttributes, ErrorMapping as IrErrorMapping, @@ -191,25 +195,19 @@ interface Frontier { entry: string | null; exit: string | null; /** - * When set, the fall-through flow out of `exit` uses this exact id and - * becomes its source gateway's default flow. `while` reserves - * `Flow__default` so the gateway's `defaultFlowId` matches. Only - * ever set together with a non-null `exit`, which is what lets a `start` - * that takes the empty `exit` slot leave this field alone. + * The exact id the fall-through flow out of `exit` takes, a gateway's + * reserved default flow. Only set beside a non-null `exit`, so a `start` + * taking the empty `exit` slot leaves it alone. */ exitFlowId?: string; - /** - * Starts beyond `exit` still waiting for a step when the block ends. Only a - * container body reads them, routing each to its synthesized end. - */ + /** Starts beyond `exit` still waiting for a step; a container body routes them to its end. */ waitingStarts?: string[]; } /** - * Mutable accumulator threaded through the walk. Each flow container gets its - * own `flowElements`/`sequenceFlows`, but one `taken` set is shared document- - * wide, pre-seeded with every named element id, so a synthesized id never - * clashes with an author-chosen name. BPMN requires that (`id` is an XML ID). + * One container's elements. `taken` is shared document-wide and seeded with + * every authored name, so a synthesized id never clashes with one (BPMN `id` + * is an XML ID). */ interface Builder { readonly flowElements: FlowElement[]; @@ -217,7 +215,12 @@ interface Builder { readonly taken: Set; } -/** Convert an AST `Model` into a {@link BpmnProcess}. Only the first `process` block is read. */ +/** A nested container's own element lists over the parent's `taken` set. */ +function nestedBuilder(parent: Builder): Builder { + return { flowElements: [], sequenceFlows: [], taken: parent.taken }; +} + +/** Only the first `process` block is read. */ export function astToIr(model: Model): BpmnProcess { const process = model.processes[0]; if (!process) { @@ -272,7 +275,6 @@ export function astToIr(model: Model): BpmnProcess { }; } -/** What a header declaration says about one code, before it is ordered. */ interface DeclaredCode { name?: string; code: string; @@ -280,17 +282,11 @@ interface DeclaredCode { } /** - * The process header's error and escalation declarations plus one for every - * code only a throw, an emit, or a catch names, canonically ordered: every code - * something uses, in first-use order, then every declared code nothing uses, in - * source order. `irToXml` emits roots in that order and `xmlToIr` reads them - * back in document order, so matching it here is what makes both lists survive - * a round trip whatever order the author declared in. - * - * A code no declaration names still gets one, because a use site refers to a - * declaration by name and the name has to come from somewhere. The message text - * is the one root-element datum usage alone cannot recover: two throws of a - * code share one root. A duplicate code keeps the first declaration. + * The header's error and escalation declarations plus one for every code only + * a throw, an emit, or a catch names, in the order `irToXml` writes roots and + * `xmlToIr` reads them back: every used code in first-use order, then every + * unused declaration in source order. A code no declaration names still gets + * one, since a use site refers to a declaration by name. */ function collectCodeDecls( process: Process, @@ -327,8 +323,7 @@ function collectCodeDecls( ...[...from.values()].filter((d) => !used.has(d.code)), ]; - // One namespace across both lists: an error and an escalation declaration - // share the scope a use site resolves in, so a name taken by one is taken. + // One namespace: a use site resolves error and escalation names in one scope. const taken = new Set(); const errorDecls = canonical(usedErrorCodes, errors).map((d) => ({ name: claimDeclarationName(d.code, taken, d.name), @@ -345,9 +340,8 @@ function collectCodeDecls( } /** - * Lower one flow container's body, plus the implicit start/end events. `coord` - * is the enclosing block's structural coordinate; `containerId` seeds the - * implicit start/end ids through `resolveCollision` against `taken`. + * Lower one flow container's body plus its implicit start and end. `coord` is + * the body's structural coordinate; `containerId` seeds the implicit event ids. */ function lowerContainerBody( builder: Builder, @@ -368,20 +362,15 @@ function lowerContainerBody( } if (body.exit !== null) { - const last = statements[statements.length - 1]; - const lastIsExplicitEnd = last !== undefined && isEndEvent(last); - if (!lastIsExplicitEnd) { - const endId = makeEndEventId(containerId, builder.taken); - builder.flowElements.push({ kind: 'endEvent', id: endId }); - // Honor a reserved exit-flow id, e.g. a `while` loop's default exit. - addFlow(builder, body.exit, endId, undefined, body.exitFlowId); - for (const start of body.waitingStarts ?? []) { - addFlow(builder, start, endId); - } + // Never an authored `end`: that statement reports `exit: null`. + const endId = makeEndEventId(containerId, builder.taken); + builder.flowElements.push({ kind: 'endEvent', id: endId }); + addFlow(builder, body.exit, endId, undefined, body.exitFlowId); + for (const start of body.waitingStarts ?? []) { + addFlow(builder, start, endId); } } else if (body.entry === null) { - // No flow step at all (empty, or every statement is an `on` handler), so - // neither branch above ran and the container would have no start event. + // No flow step at all (empty, or only `on` handlers): neither branch above ran. const startId = makeStartEventId(containerId, builder.taken); const endId = makeEndEventId(containerId, builder.taken); builder.flowElements.unshift({ kind: 'startEvent', id: startId }); @@ -408,12 +397,10 @@ function lowerBlockStatements( let waitingStarts: string[] = []; statements.forEach((stmt, index) => { - // An `on` handler catches an event rather than being a flow step, so it - // lowers out-of-chain and leaves the waiting exits and `entry` untouched. + // An `on` handler is not a flow step: it lowers out of the chain. if (isOnHandler(stmt)) { if (stmt.host !== undefined) { - // `$refText` is there even when the linker could not resolve the host. - // Dispatching on the slot keeps this in step with the scope provider. + // As in `lowerGoto`, `$refText` survives an unresolved host. lowerBoundaryHandler(builder, stmt, stmt.host.$refText, coord, index); } else { lowerOnHandler(builder, stmt, coord, index); @@ -422,8 +409,7 @@ function lowerBlockStatements( } const frontier = lowerStatement(builder, stmt, coord, index); - // A statement always has a concrete entry node; only an empty *block*, - // never a top-level statement, yields a null entry. + // Only an empty block, never a statement, yields a null entry. const stmtEntry = frontier.entry!; if (entry === null) { @@ -448,8 +434,6 @@ function lowerBlockStatements( waitingStarts = []; }); - // Propagate the trailing `exitFlowId` so the block's own exit flow honors a - // reserved default-flow id when the block ends in a `while`. return { entry, exit, @@ -459,15 +443,14 @@ function lowerBlockStatements( } /** - * Lower a brace-delimited {@link Block}. The caller passes the fully-formed - * coordinate including any branch segment (`_t`, `_e`, `_b`) so - * sibling blocks never share one. + * The caller's `coord` carries any branch segment (`_t`, `_e`, + * `_b`), so sibling blocks never share a coordinate. */ function lowerBlock(builder: Builder, block: Block, coord: string): Frontier { return lowerBlockStatements(builder, block.statements, coord); } -/** Dispatch one statement. `index` is its position in the block, forming `_`. */ +/** `index` is the statement's position in its block, forming `_`. */ function lowerStatement( builder: Builder, stmt: Statement, @@ -557,10 +540,9 @@ function lowerStartEvent(builder: Builder, stmt: AstStartEvent): Frontier { } /** - * The word a position admits, or `undefined` for one it does not. The literal - * return type is what makes every dispatch below carry an arm per word its - * vocabulary lists, so a word cannot be admitted here and lowered as something - * else. + * The word a position admits, or `undefined`. The literal return type makes + * every dispatch below carry an arm per word in its vocabulary, so a word + * cannot be admitted here and lowered as something else. */ function admittedTrigger( vocabulary: readonly W[], @@ -571,11 +553,7 @@ function admittedTrigger( : vocabulary.find((word) => word === written); } -/** - * The trigger a top-level start carries, among the words `START_TRIGGERS` - * admits. A word outside that vocabulary lowers to nothing, leaving the - * validator to report it. - */ +/** A word outside `START_TRIGGERS` lowers to nothing; the validator reports it. */ function startEventDefinition( stmt: AstStartEvent, ): EventDefinition | undefined { @@ -585,11 +563,7 @@ function startEventDefinition( : namedTriggerDefinition(trigger, stmt); } -/** - * The definition an `end` head's trigger lowers to. Both end-carried words are - * payload-free, so the word is the kind; any other word lowers to no - * definition, leaving the validator to report it. - */ +/** Both end words are payload-free, so the word is the kind; any other lowers to nothing. */ function endEventDefinition( trigger: string | undefined, ): EndEventDefinition | undefined { @@ -615,15 +589,16 @@ function lowerEndEvent(builder: Builder, stmt: AstEndEvent): Frontier { * `executionListeners` with every other element's. */ function lowerUserTask(builder: Builder, stmt: AstUserTask): Frontier { - const assignee = attrValue(settingsOf(stmt.items), 'assignee'); - const formKey = attrValue(settingsOf(stmt.items), 'formKey'); - const formRef = readFormRef(settingsOf(stmt.items)); + const settings = settingsOf(stmt.items); + const assignee = attrValue(settings, 'assignee'); + const formKey = attrValue(settings, 'formKey'); + const formRef = readFormRef(settings); const formFields = lowerFormFields(stmt); - const candidateGroups = attrValue(settingsOf(stmt.items), 'candidateGroups'); - const candidateUsers = attrValue(settingsOf(stmt.items), 'candidateUsers'); - const dueDate = attrValue(settingsOf(stmt.items), 'dueDate'); - const followUpDate = attrValue(settingsOf(stmt.items), 'followUpDate'); - const priority = numericOrElAttrValue(settingsOf(stmt.items), 'priority'); + const candidateGroups = attrValue(settings, 'candidateGroups'); + const candidateUsers = attrValue(settings, 'candidateUsers'); + const dueDate = attrValue(settings, 'dueDate'); + const followUpDate = attrValue(settings, 'followUpDate'); + const priority = numericOrElAttrValue(settings, 'priority'); const taskListeners = readTaskListeners(stmt.listeners); builder.flowElements.push({ kind: 'userTask', @@ -703,7 +678,7 @@ function toFormFieldType(type: string): FormFieldType { /** * Source order, which the engine validates in. A key outside - * {@link FORM_CONSTRAINT_NAMES} (`pattern` included) is left to the validator. + * `FORM_CONSTRAINT_NAMES` (`pattern` included) is left to the validator. */ function readFormFieldConstraints( settings: KeyValueAttr[], @@ -717,8 +692,8 @@ function readFormFieldConstraints( /** * `validator` reads the shape a `class:` binding takes. Exhaustive on - * purpose: a name added to {@link FORM_CONSTRAINT_NAMES} stops compiling here - * until it picks a reading. + * purpose: a name added to `FORM_CONSTRAINT_NAMES` stops compiling here until + * it picks a reading. */ function formFieldConstraint( name: FormConstraintName, @@ -738,7 +713,6 @@ function formFieldConstraint( } } -/** Literals yield their bare value; anything else falls back to its `${...}` body, evaluated as EL. */ function renderFormDefault(expr: Expr): string { if (isLiteralString(expr)) { return expr.value; @@ -787,13 +761,6 @@ function lowerServiceTask(builder: Builder, stmt: AstServiceTask): Frontier { ); } -/** - * Build the {@link CodeBinding} whichever of `class`/`expression`/`delegate` - * the block names, in that order. `class` reads through {@link attrValue}, - * which strips the `${...}` wrapper so a bareword stays a dotted Java path; the - * other two read through {@link elAttrValue}, which keeps it, that text - * being what Operaton evaluates as EL. - */ function codeBinding(attrs: KeyValueAttr[]): CodeBinding | undefined { const className = attrValue(attrs, 'class'); if (className !== undefined) { @@ -813,7 +780,6 @@ function codeBinding(attrs: KeyValueAttr[]): CodeBinding | undefined { /** What a binding block with no key resolves to; the validator owns the diagnostic. */ const NO_BINDING: CodeBinding = { kind: 'class', className: '' }; -/** The three code forms first, then `type`, then `topic`, which emits `operaton:type="external"`. */ function serviceTaskBinding(attrs: KeyValueAttr[]): ServiceTaskBinding { return writtenBinding(attrs) ?? NO_BINDING; } @@ -864,7 +830,6 @@ function lowerSendTask(builder: Builder, stmt: AstSendTask): Frontier { ); } -/** No `message` key lowers to no `messageName` at all, not `undefined`: a genuine wait with no correlation. */ function lowerReceiveTask(builder: Builder, stmt: AstReceiveTask): Frontier { const messageName = attrValue(settingsOf(stmt.items), 'message'); builder.flowElements.push({ @@ -893,8 +858,7 @@ function lowerBusinessRuleTask( /** * `decision` names a decision table and takes {@link versionBinding} and - * `mapDecisionResult` alongside it; with no `decision` key the block falls - * through to the same code/topic forms a service task reads. + * `mapDecisionResult` beside it; without it the block reads as a service task's. */ function businessRuleBinding(attrs: KeyValueAttr[]): ServiceTaskBinding { const decisionRef = attrValue(attrs, 'decision'); @@ -942,23 +906,16 @@ function lowerScriptTask(builder: Builder, stmt: AstScriptTask): Frontier { } /** - * Lower `if`/`else if`/`else` to an exclusive-gateway split and join. The - * whole chain is one split with a conditioned flow per branch, so the head - * parens govern that split and an `else if` head takes none. - * - * The trailing `else`, or the implicit fall-through standing in for an absent - * one, is the split's default flow and never carries a condition: Operaton - * rejects a conditioned default. With an `else` present and every branch - * terminating, the join has no incoming flow and is pruned (see - * {@link pruneUnreachableJoin}), reporting `exit: null`, and the join - * settings go with it. + * Lower `if`/`else if`/`else` to an exclusive split and join: one split with a + * conditioned flow per branch, so the head parens govern it and an `else if` + * head takes none. The `else`, or the fall-through standing in for an absent + * one, is the default flow and never carries a condition: Operaton rejects a + * conditioned default. */ function lowerIf(builder: Builder, stmt: IfStatement, x: string): Frontier { const splitId = makeGatewaySplitId(x); const joinId = makeGatewayJoinId(x); const settings = settingsOf(stmt.items); - - // Reserved up-front so it is stable regardless of branch count. const defaultFlowId = reserveDefaultFlowId(builder, splitId); builder.flowElements.push({ @@ -986,7 +943,6 @@ function lowerIf(builder: Builder, stmt: IfStatement, x: string): Frontier { coord: `${x}_e${i}`, condition: renderExpression(ei.condition), })), - // The trailing `else` is the default flow and never carries a condition. ...(stmt.elseBlock !== undefined ? [{ block: stmt.elseBlock, coord: `${x}_e`, flowId: defaultFlowId }] : []), @@ -999,7 +955,6 @@ function lowerIf(builder: Builder, stmt: IfStatement, x: string): Frontier { return { entry: splitId, exit: pruneUnreachableJoin(builder, joinId) }; } -/** One branch of a fork: its body, that body's coordinate, and its incoming flow. */ interface ForkBranch { block: Block; coord: string; @@ -1010,14 +965,10 @@ interface ForkBranch { } /** - * Lower every branch of a fork and rejoin it. An empty branch routes its flow - * straight to the join, and a branch that terminates gets no continuation out - * of it. - * - * `defaultFlowId` is the id the fork reserved for its default flow. When no - * branch claimed it, the fallback runs from the fork to the join: a split whose - * every branch is conditioned would otherwise have nowhere to go when none of - * them holds. + * Lower every branch and rejoin. An empty branch flows straight to the join; a + * terminating one gets no continuation. When no branch claimed the reserved + * `defaultFlowId`, it runs fork -> join, so a fork whose every branch is + * conditioned has somewhere to go when none holds. */ function lowerForkBranches( builder: Builder, @@ -1047,9 +998,9 @@ function lowerForkBranches( } /** - * Lower `while (c) { body }` to a pre-test XOR loop: a loop-head gateway with a - * conditioned flow into the body, an unconditioned default flow out, and a - * back-edge from the body's exit. Never emits `standardLoopCharacteristics`. + * Pre-test XOR loop: the loop gateway takes a conditioned flow into the body, + * an unconditioned default flow out, and the back-edge from the body's exit. + * Never `standardLoopCharacteristics`. */ function lowerWhile( builder: Builder, @@ -1076,15 +1027,13 @@ function lowerWhile( addFlow(builder, body.exit, loopId, undefined, body.exitFlowId); } - // The loop gateway's one non-back-edge outgoing flow is its unconditioned - // default exit; surface the reserved id so the enclosing chain stamps it. return { entry: loopId, exit: loopId, exitFlowId: defaultFlowId }; } /** - * Lower `do { body } while (c)` to a post-test XOR loop: the body runs first, - * and the loop gateway after it holds the conditioned back-edge into the body - * plus an unconditioned default flow out. + * Post-test XOR loop: the body runs first, and the loop gateway after it holds + * the conditioned back-edge into the body plus an unconditioned default flow + * out. */ function lowerDoWhile( builder: Builder, @@ -1111,30 +1060,26 @@ function lowerDoWhile( addFlow(builder, loopId, body.entry, condition); } - // Surface the reserved default-exit id so the enclosing chain stamps it. - const entry = body.entry ?? loopId; - return { entry, exit: loopId, exitFlowId: defaultFlowId }; + return { + entry: body.entry ?? loopId, + exit: loopId, + exitFlowId: defaultFlowId, + }; } /** - * Lower `parallel { { A } { B } ... }` to a fork/join pair; the join is pruned - * when every branch terminates. + * Lower `parallel { { A } { B } ... }` to a fork/join pair: inclusive when any + * branch carries a condition, else an AND pair, on which Operaton ignores a + * condition. * - * A condition on any branch makes both gateways inclusive: every branch whose - * condition holds runs, and the join waits for exactly those. With no condition - * anywhere the pair is an AND fork/join, Operaton ignoring a condition there. - * An `else` branch alone does not make the split inclusive: under inclusive - * semantics the unconditioned siblings always run, so the fallback would be - * dead. - * - * The inclusive fork gets a default flow only where the engine can take it: - * onto the `else` branch when one is written, or straight to the join when - * every branch is conditioned, since such a fork with no default deploys and - * runs, then throws a stuck execution the first time no condition holds. - * `InclusiveGatewayActivityBehavior.execute` takes the default only when no - * other flow was taken, and a flow with no condition always is, so beside an - * unconditioned branch the fallback would be dead, and the printer would spell - * it as an `else` the validator refuses. + * `InclusiveGatewayActivityBehavior.execute` takes the default flow only when + * no other flow was, and an unconditioned flow always is, so the fork gets one + * only where it can run: onto the `else` branch, or straight to the join when + * every branch is conditioned (without it the fork deploys, then throws a + * stuck execution the first time no condition holds). That is also why an + * `else` alone does not make the fork inclusive: beside an unconditioned + * branch it would be dead, and the printer would spell it as an `else` the + * validator refuses. */ function lowerParallel( builder: Builder, @@ -1153,8 +1098,8 @@ function lowerParallel( (stmt.branches.some((b) => b.otherwise) || stmt.branches.every((b) => b.condition !== undefined)); - // Reserved only where a fallback is written: a fork that pushes no flow under - // the id would move an authored collider off it for nothing. + // Claimed only where a flow takes it: an idle claim would rename an authored + // collider for nothing. let defaultFlowId: string | undefined; if (fallback) { defaultFlowId = reserveDefaultFlowId(builder, forkId); @@ -1176,8 +1121,8 @@ function lowerParallel( builder.flowElements.push({ kind: 'parallelGateway', id: joinId, ...join }); } - // Only the first `else` carries the default flow; a second one lowers as a - // plain branch, so invalid input still has one deterministic lowering. + // Only the first `else` carries the default flow, so a second one (invalid) + // still lowers deterministically. const defaultIndex = inclusive ? stmt.branches.findIndex((b) => b.otherwise) : -1; @@ -1197,15 +1142,12 @@ function lowerParallel( } /** - * Lower `await { { A } { B } ... }` to an event-based - * gateway, one intermediate catch event per branch, and an exclusive join. The - * first branch to fire cancels the rest, so exactly one ever runs and the merge - * is a plain XOR join rather than a synchronizing one. - * - * No flow out of the gateway carries a condition: Operaton builds no transition - * for one and routes through the event scope instead, so a condition there - * would be content nothing reads. A branch's own settings land on its catch - * event, which is where the engine's wait state actually is. + * Lower `await { { A } ... }` to an event-based gateway, one catch + * event per branch, and an exclusive join: the first branch to fire cancels + * the rest, so the merge is a plain XOR join. No flow out of the gateway + * carries a condition, since Operaton builds no transition for one and routes + * through the event scope instead; a branch's settings land on its catch + * event, where the engine's wait state is. */ function lowerRace(builder: Builder, stmt: RaceStatement, x: string): Frontier { const raceId = makeGatewayRaceId(x); @@ -1243,24 +1185,16 @@ function lowerRace(builder: Builder, stmt: RaceStatement, x: string): Frontier { } /** - * Lower a `subprocess` or an `attempt` into a nested flow container: its own - * `flowElements`/`sequenceFlows`, the parent's `taken` set. Implicit start/end - * are seeded from the sub-process name, mirroring the top level's process id. - * The container is one opaque activity node, so `entry === exit === name`. - * - * The two heads lower alike apart from the tag: an `attempt` carries the one - * the engine needs before it accepts a cancel end inside the block. + * A nested flow container, one opaque node in the parent's flow. An `attempt` + * differs only by tag, the one the engine needs before it accepts a cancel end + * inside the block. */ function lowerSubProcess( builder: Builder, stmt: AstSubProcess, x: string, ): Frontier { - const nested: Builder = { - flowElements: [], - sequenceFlows: [], - taken: builder.taken, - }; + const nested = nestedBuilder(builder); lowerContainerBody(nested, stmt.body.statements, x, stmt.name); builder.flowElements.push({ @@ -1278,17 +1212,12 @@ function lowerSubProcess( } /** - * Lower a host-less `on` handler into a `triggeredByEvent` sub-process. The - * caught trigger lands on the body's start event, explicit or synthesized. The - * sub-process is not wired into the parent's flow, so this returns nothing; it - * is also invalid BPMN without its trigger start, so an empty body still gets - * start -> flow -> end for {@link ensureHandlerStart} to attach the trigger to. - * - * The handler's own settings land on this sub-process node, except the three - * a timer job takes off the start event that declares it + * Lower a host-less `on` handler into a `triggeredByEvent` sub-process, the + * caught trigger on its start event. Nothing wires it into the parent's flow, + * so this returns nothing. The handler's settings land on the sub-process, + * except the three a timer job takes off the start event that declares it * ({@link splitTimerJobSettings}); none of their readers looks at the - * enclosing sub-process. The printer lifts the three back into the head when - * the start prints no statement of its own. + * enclosing sub-process. */ function lowerOnHandler( builder: Builder, @@ -1299,11 +1228,7 @@ function lowerOnHandler( const x = `${coord}_${index}`; const id = makeEventSubProcessId(x); - const nested: Builder = { - flowElements: [], - sequenceFlows: [], - taken: builder.taken, - }; + const nested = nestedBuilder(builder); lowerContainerBody(nested, stmt.body.statements, x, id); const start = ensureHandlerStart(nested, id); @@ -1330,20 +1255,16 @@ function lowerOnHandler( } /** - * Lower a hosted `on : ` handler into a boundary event inline in - * the host's own container: the node plus its whole body, pushed onto the very - * builder the host was lowered into. - * - * There is no wrapping container. The body's statements become siblings of the - * main flow, so a `goto` crosses between the two in either direction, the only - * way an escape chain can rejoin. The chain runs boundary -> body -> its own - * end event, seeded from the boundary event id so the main flow's end keeps its - * number whatever handlers the container has. + * Lower a hosted `on : ` handler into a boundary event inline + * in the host's container, with no wrapping container: the body's statements + * are siblings of the main flow, so a `goto` crosses between the two in either + * direction, the only way an escape chain can rejoin. The chain runs boundary + * -> body -> its own end, seeded from the boundary id so the main flow's end + * keeps its number whatever handlers the container has. * - * Element order is a constraint: `bpmn-auto-layout` positions an attached event - * from `attachedTo.di.bounds`, so the host shape has to exist before the - * attacher is laid out. A handler always follows its host in the statement - * list, so the host always precedes it in `flowElements`. + * `bpmn-auto-layout` positions an attached event from `attachedTo.di.bounds`, + * so the host shape has to exist before the attacher is laid out; a handler + * follows its host in the statement list, so the host precedes it here too. */ function lowerBoundaryHandler( builder: Builder, @@ -1362,8 +1283,6 @@ function lowerBoundaryHandler( ...readEngineAttributes(stmt), }); - // A handler is a single-block compound, so its body's enclosing coordinate is - // the handler's own ``, the sole-block rule loop bodies follow. const body = lowerBlockStatements( builder, stmt.body.statements, @@ -1373,20 +1292,20 @@ function lowerBoundaryHandler( addFlow(builder, id, body.entry); } - // Terminate the escape chain. An empty body has no entry at all, so the - // boundary event itself is what falls through to the end. + // An empty body has no entry, so the boundary event itself falls through. const exit = body.entry === null ? id : body.exit; if (exit !== null) { const endId = makeEndEventId(id, builder.taken); builder.flowElements.push({ kind: 'endEvent', id: endId }); - // Honor a reserved exit-flow id, as a container body does. addFlow(builder, exit, endId, undefined, body.exitFlowId); } } /** - * The handler body's single start event. `lowerContainerBody` always leaves one - * behind; the synthesis below is a fallback if that guarantee stops holding. + * The handler body's start event. `lowerContainerBody` leaves one behind + * unless `pruneUnreachableJoin` spliced an authored start that spells a later + * join's id, a name the validator refuses; minting one keeps the desugarer + * total over that program. */ function ensureHandlerStart(nested: Builder, id: string): IrStartEvent { const existing = nested.flowElements.find( @@ -1404,11 +1323,10 @@ function ensureHandlerStart(nested: Builder, id: string): IrStartEvent { } /** - * Build the caught {@link EventDefinition} for an `on` handler. Fields with - * nowhere to go (a code on `compensation` or `cancel`, bindings on - * `message`/`signal`) are dropped, and a missing code is catch-all. A word the - * handler position does not admit falls back to error, which is the kind its - * validator message speaks of. + * The caught {@link EventDefinition} of an `on` handler. Fields with nowhere + * to go (a code on `compensation` or `cancel`, bindings on `message`/`signal`) + * are dropped, a missing code is catch-all, and a word the position does not + * admit falls back to error, the kind its validator message speaks of. */ function handlerEventDefinition(stmt: OnHandler): EventDefinition { const trigger = admittedTrigger(ON_TRIGGERS, stmt.trigger); @@ -1452,10 +1370,7 @@ function handlerEventDefinition(stmt: OnHandler): EventDefinition { } } -/** - * The vocabulary's particle table read backwards. Falls back to `duration`, - * which is what a timer with no readable time lands on. - */ +/** `TIMER_PARTICLE_BY_KIND` read backwards; a timer with no readable time lands on `duration`. */ function timerParticleKind( particle: string | undefined, ): 'duration' | 'date' | 'cycle' { @@ -1471,7 +1386,6 @@ function bindingVariable(stmt: OnHandler, field: string): string | undefined { return caughtBindingsOf(stmt.items).find((b) => b.field === field)?.variable; } -/** The id is the authored `name` when present, else the positional `Throw__`. */ function lowerThrow( builder: Builder, stmt: ThrowStatement, @@ -1491,9 +1405,8 @@ function lowerThrow( } /** - * The implementation that makes the engine really send the message. The - * validator holds the binding keys to the `message` trigger, so nothing else - * reaches a binding here. + * The implementation that makes the engine send the message; the validator + * holds the binding keys to the `message` trigger. */ function thrownMessageBinding( def: EventDefinition, @@ -1535,7 +1448,7 @@ function lowerEmit( * admit lowers as an escalation and the validator points the author at * `throw error`. */ -function emitEventDefinition(stmt: EmitStatement): EventDefinition { +function emitEventDefinition(stmt: EmitStatement): EmitEventDefinition { const trigger = admittedTrigger(EMIT_TRIGGERS, stmt.trigger); const code = raisedCodeOf(stmt.items); switch (trigger) { @@ -1546,7 +1459,7 @@ function emitEventDefinition(stmt: EmitStatement): EventDefinition { case 'compensation': return { kind: 'compensation' }; case 'link': - return namedTriggerDefinition('link', stmt); + return { kind: 'link', linkName: code ?? '' }; case 'escalation': case undefined: return { kind: 'escalation', escalationCode: code }; @@ -1600,26 +1513,28 @@ function throwEventDefinition(stmt: ThrowStatement): EndEventDefinition { } } +/** The condition a catch with no readable one waits on. */ +const ALWAYS_TRUE = '${true}'; + /** - * The caught {@link CatchEventDefinition} for an `await`: error, escalation, - * and compensation are raised with `throw`/`emit` and never awaited inline. A - * word the await position does not admit falls back to the always-true - * conditional. + * Error, escalation and compensation are raised with `throw`/`emit` and never + * awaited inline; a word the await position does not admit falls back to the + * always-true conditional. */ function catchEventDefinition( stmt: IntermediateCatchEvent | RaceBranch, ): CatchEventDefinition { const trigger = admittedTrigger(CATCH_TRIGGERS, stmt.trigger); return trigger === undefined - ? { kind: 'conditional', condition: '${true}' } + ? { kind: 'conditional', condition: ALWAYS_TRUE } : namedTriggerDefinition(trigger, stmt); } /** - * The code a payload raises. A bare word names a declaration, which keys by its - * own `code` setting, so `error OrderFailed(code: "order.failed")` reaches the - * engine as `order.failed` however its use sites spell it. Only a code position - * resolves, so a bare word anywhere else falls back to the text written. + * The code a payload raises. A bare word names a declaration, which keys by + * its own `code` setting, so `error OrderFailed(code: "order.failed")` reaches + * the engine as `order.failed` however its use sites spell it; anywhere but a + * code position a bare word is the text written. */ function raisedCodeOf(items: ParenItem[]): string | undefined { const payload = payloadItemOf(items)?.value; @@ -1631,12 +1546,8 @@ function raisedCodeOf(items: ParenItem[]): string | undefined { } /** The trigger words that mean the same thing in every position that takes them. */ -type NamedTrigger = 'message' | 'signal' | 'timer' | 'condition' | 'link'; +type NamedTrigger = (typeof CATCH_TRIGGERS)[number]; -/** - * The {@link CatchEventDefinition} for the five trigger words that mean the - * same thing wherever they are written. - */ function namedTriggerDefinition( trigger: NamedTrigger, stmt: { items: ParenItem[] }, @@ -1658,7 +1569,7 @@ function namedTriggerDefinition( const expr = payloadItemOf(stmt.items)?.value; return { kind: 'conditional', - condition: expr !== undefined ? renderExpression(expr) : '${true}', + condition: expr !== undefined ? renderExpression(expr) : ALWAYS_TRUE, }; } case 'link': @@ -1672,12 +1583,12 @@ function namedTriggerDefinition( } } -/** `calledElement` falls back to `''` when the `process` attribute is absent. */ function lowerCallActivity(builder: Builder, stmt: AstCallActivity): Frontier { - const calledElement = attrValue(settingsOf(stmt.items), 'process') ?? ''; - const binding = versionBinding(settingsOf(stmt.items)); - const businessKey = elAttrValue(settingsOf(stmt.items), 'businessKey'); - const mapper = callVariableMapper(settingsOf(stmt.items)); + const settings = settingsOf(stmt.items); + const calledElement = attrValue(settings, 'process') ?? ''; + const binding = versionBinding(settings); + const businessKey = elAttrValue(settings, 'businessKey'); + const mapper = callVariableMapper(settings); const { inMappings, outMappings } = lowerCallMappings(stmt.mappings); builder.flowElements.push({ @@ -1697,13 +1608,7 @@ function lowerCallActivity(builder: Builder, stmt: AstCallActivity): Frontier { return { entry: stmt.name, exit: stmt.name }; } -/** - * The {@link CallVariableMapper} a call's settings name, class first, matching - * the order Operaton resolves the two attributes in. The class reads through - * {@link attrValue}, which strips the `${...}` wrapper so a bareword stays a - * dotted Java path; the delegate reads through {@link elAttrValue}, which - * keeps it, that text being what Operaton evaluates as EL. - */ +/** Class first, the order Operaton resolves the two attributes in. */ function callVariableMapper( attrs: KeyValueAttr[], ): CallVariableMapper | undefined { @@ -1722,11 +1627,9 @@ function callVariableMapper( } /** - * A {@link VersionBinding} read off `binding`/`version`, shared by a call - * activity's `calledElement` pin and a decision step's decision table pin. - * `version` wins whenever present, even alongside a stray `binding`: the two - * together are a validator error, so the desugarer picks the one BPMN can use. - * A `binding` resolves only for a bare `latest` or `deployment`. + * The `binding`/`version` pin a call activity and a decision step share. + * `version` wins even beside a stray `binding` (the pair is a validator + * error), and a `binding` resolves only for a bare `latest` or `deployment`. */ function versionBinding(attrs: KeyValueAttr[]): VersionBinding | undefined { const versionAttr = attrs.find((a) => a.key === 'version'); @@ -1739,22 +1642,14 @@ function versionBinding(attrs: KeyValueAttr[]): VersionBinding | undefined { isVarRef(bindingAttr.value) && bindingAttr.value.accessors.length === 0 ) { - if (bindingAttr.value.ref.$refText === 'latest') { - return { kind: 'latest' }; - } - if (bindingAttr.value.ref.$refText === 'deployment') { - return { kind: 'deployment' }; - } + const word = bindingAttr.value.ref.$refText; + const kind = CALL_BINDING_VALUES.find((value) => value === word); + if (kind !== undefined) return { kind }; } return undefined; } -/** - * Render a numeric-or-EL attribute value into plain BPMN text: an int or - * decimal yields its digits, a string its bare text, anything else its `${...}` - * body. Shared by a pinned `version`, the `jobPriority`/`priority` settings, - * and a form field's `min`/`max`/`minlength`/`maxlength` bounds. - */ +/** An int or decimal literal stays bare; anything else reads as {@link elText} does. */ function numericOrElValue(expr: Expr): string { const integer = integerLiteralText(expr); if (integer !== undefined) { @@ -1763,13 +1658,9 @@ function numericOrElValue(expr: Expr): string { if (isLiteralDecimal(expr)) { return String(expr.value); } - if (isLiteralString(expr)) { - return expr.value; - } - return renderExpression(expr); + return elText(expr); } -/** First match wins, as in {@link attrValue}. */ function numericOrElAttrValue( attrs: KeyValueAttr[], key: string, @@ -1778,7 +1669,6 @@ function numericOrElAttrValue( return attr === undefined ? undefined : numericOrElValue(attr.value); } -/** Each direction keeps its relative source order. */ function lowerCallMappings(mappings: VariableMapping[]): { inMappings: CallVariableMapping[]; outMappings: CallVariableMapping[]; @@ -1793,11 +1683,9 @@ function lowerCallMappings(mappings: VariableMapping[]): { } /** - * Lower one `in`/`out` mapping. `all` (`*`) copies everything; a bare `target` - * is the same-name shorthand; a single-segment `VarRef` source copies that - * variable by name; anything else becomes its {@link elText}: a string - * literal bare, everything else a `${...}` body. `local` is stamped only - * when set, so the IR never carries `local: false`. + * `all` (`*`) copies everything; a bare `target` is the same-name shorthand; a + * single-segment `VarRef` source copies that variable by name; anything else + * becomes its {@link elText}. */ function lowerCallMapping(mapping: VariableMapping): CallVariableMapping { const local = mapping.local ? ({ local: true } as const) : {}; @@ -1825,17 +1713,16 @@ function lowerCallMapping(mapping: VariableMapping): CallVariableMapping { } /** - * The `goto` produces no node: its `entry` is the target's id and its `exit` is - * `null`, so the enclosing chain's implicit flow lands on the target. + * A `goto` produces no node: its `entry` is the target's id, so the chain's + * implicit flow lands there, and its `exit` is `null`. `$refText` is the id + * verbatim and is there even when the linker could not resolve it, which keeps + * the desugarer total over unresolved gotos. */ function lowerGoto(stmt: GotoStatement): Frontier { - // `$refText` is the target id verbatim and is there even when the linker could - // not resolve it, which keeps the desugarer total over unresolved gotos. - const targetId = stmt.target.$refText; - return { entry: targetId, exit: null }; + return { entry: stmt.target.$refText, exit: null }; } -/** Structural, as {@link EngineAttributeOwner} is: the nine statements that take a repeat clause. */ +/** Structural, as {@link EngineAttributeOwner} is. */ interface RepeatOwner { cardinality?: Expr; collection?: Expr; @@ -1846,13 +1733,12 @@ interface RepeatOwner { } /** - * The repeat clause of a statement, as the key it contributes: a statement - * carrying none spreads nothing at all, not even a `run*` setting written - * beside it (the validator reports that one). A clause always sets a count, a - * collection or both, which is what tells it apart from an absent one: + * A statement carrying no repeat clause spreads nothing, not even a `run*` + * setting written beside it (the validator reports that). A clause always sets + * a count, a collection or both, which is what tells it from an absent one: * `sequential` is a plain boolean the parser leaves `false` either way. - * Nothing in the engine reads a priority off the loop, so a stray - * `runJobPriority` is dropped rather than carried. + * Nothing in the engine reads a priority off the loop, so `runJobPriority` is + * dropped. */ function readLoop(stmt: RepeatOwner): Repeatable { if (stmt.cardinality === undefined && stmt.collection === undefined) { @@ -1882,11 +1768,10 @@ function readLoop(stmt: RepeatOwner): Repeatable { } /** - * A whole number is the one count that goes into the attribute bare: - * `MultiInstanceActivityBehavior.resolveLoopCardinality` `Integer.parseInt`s - * a body with no `${`/`#{` on every run, which only a plain integer - * survives; anything else is wrapped so the engine evaluates it as EL and - * truncates the result with `intValue()` instead. + * Only a plain integer goes in bare: + * `MultiInstanceActivityBehavior.resolveLoopCardinality` `Integer.parseInt`s a + * body with no `${`/`#{` on every run. Anything else is wrapped, so the engine + * evaluates it as EL and truncates with `intValue()`. */ function loopCardinality(expr: Expr): string { if (isLiteralInt(expr)) { @@ -1896,11 +1781,10 @@ function loopCardinality(expr: Expr): string { } /** - * Operaton reads `operaton:collection` as the name of a variable unless the - * text carries `${`, so only the two spellings that mean a name emit one: a - * bare identifier, and a quoted string for a name an identifier cannot spell. - * An accessor such as `order.lines` names no variable that exists and has to - * become an expression or the process cannot run. + * Operaton reads `operaton:collection` as a variable name unless the text + * carries `${`, so only a bare identifier and a quoted string emit a name; an + * accessor such as `order.lines` names no variable and has to become an + * expression or the process cannot run. */ function loopCollection(expr: Expr): string { if (isVarRef(expr) && expr.accessors.length === 0) { @@ -1926,10 +1810,7 @@ function readEngineAttributes(owner: EngineAttributeOwner): EngineAttributes { }; } -/** - * {@link jobSettings} decides what is kept; this says how each field is - * spelled. `keyOf` respells the keys for a second carrier sharing the parens. - */ +/** `keyOf` respells the keys for a second carrier sharing the parens; {@link jobSettings} decides what is kept. */ function readJobSettings( attrs: Setting[], keyOf: (key: string) => string = (key) => key, @@ -1944,9 +1825,9 @@ function readJobSettings( } /** - * The `on start`/`on end` callbacks, in source order. The event word, not the - * element, splits execution from task listeners, so a task event on an element - * with no such lifecycle is dropped here for the validator. + * The event word, not the element, splits execution from task listeners, so a + * task event on an element with no such lifecycle is dropped here for the + * validator. */ function readExecutionListeners( listeners: AstListener[], @@ -1960,10 +1841,7 @@ function readExecutionListeners( return lowered.length > 0 ? lowered : undefined; } -/** - * The task-lifecycle callbacks, in source order. `timeout` has no lifecycle - * transition of its own, so it carries the timer that says when it runs. - */ +/** `timeout` has no lifecycle transition of its own, so it carries the timer that says when it runs. */ function readTaskListeners( listeners: AstListener[], ): TaskListener[] | undefined { @@ -2008,24 +1886,20 @@ function listenerBinding(listener: AstListener): ListenerBinding { } /** - * Carry the block's fields onto the binding, or leave a binding the engine - * hands no field list as it is. The validator reports the write; this only - * declines to carry it. + * Carry the block's fields onto a binding the engine hands a field list; the + * validator reports a write onto one it does not. */ function withDeclaredFields< B extends CodeBinding | Extract, >(binding: B, params: AstIoParameter[]): B { - // Operaton builds the expression behaviour from the expression and the - // result variable alone, with no field list to hand it. if (!carriesFields(binding)) return binding; const fields = readFieldInjections(params); return fields.length === 0 ? binding : { ...binding, fields }; } /** - * The `field` members of a block, in source order. A list, a map, and an inline - * script have no `operaton:field` slot to lower to, so a value in one of those - * forms is left out for the validator to report. + * A list, a map, and an inline script have no `operaton:field` slot, so a + * value in one of those forms is left out for the validator to report. */ function readFieldInjections(params: AstIoParameter[]): FieldInjection[] { return params @@ -2072,9 +1946,9 @@ function withExternalExtras( } /** - * The code is read through the declaration's `code` setting, as a thrown one - * is ({@link raisedCodeOf}); an unresolved reference falls back to the text - * written, which the linker has already reported. + * The code is read through the declaration's `code` setting, as + * {@link raisedCodeOf} does; an unresolved reference falls back to the text + * written. */ function lowerErrorMappings( mappings: AstErrorMapping[], @@ -2095,18 +1969,13 @@ function loweredErrorCode(mapping: AstErrorMapping): string { return code ?? mapping.code.$refText; } -/** - * Partition `input`/`output` into the two {@link IoMapped} lists, keeping each - * direction's source order: the serializer emits and the engine applies in it. - */ function readIoParameters(params: AstIoParameter[]): IoMapped { return ioMapped( - lowerIoParameters(params, 'input'), - lowerIoParameters(params, 'output'), + lowerIoParameters(params, INPUT_DIRECTION), + lowerIoParameters(params, OUTPUT_DIRECTION), ); } -/** The parameters of one direction, in source order. */ function lowerIoParameters( params: AstIoParameter[], direction: string, @@ -2116,7 +1985,6 @@ function lowerIoParameters( .map((param) => ({ name: param.name, value: lowerIoValue(param.value) })); } -/** Lists and maps recurse; anything else becomes the EL text {@link elText} resolves. */ function lowerIoValue(value: AstIoValue): IoValue { if (isListLiteral(value)) { return { kind: 'list', items: value.items.map(lowerIoValue) }; @@ -2149,12 +2017,9 @@ function boolAttrValue( } /** - * Claim the id a gateway holds back for its default flow, before any branch is - * lowered. Without the claim a statement named `default` takes the same string - * for its own incoming flow, since that flow is `Flow__`, - * and the document ends up with two flows under one id, which BPMN forbids. - * Every shape that holds an id back for a flow it has not pushed yet comes - * through here. + * Claim a gateway's default-flow id before any branch is lowered: a statement + * named `default` would otherwise take the same `Flow__default` for + * its own incoming flow, and BPMN forbids two flows under one id. */ function reserveDefaultFlowId(builder: Builder, gatewayId: string): string { const id = resolveCollision(makeDefaultFlowId(gatewayId), builder.taken); @@ -2163,10 +2028,8 @@ function reserveDefaultFlowId(builder: Builder, gatewayId: string): string { } /** - * Emit a sequence flow. `forcedId` creates it with that exact id, for a - * gateway's reserved default flow and a `while` loop's reserved default exit. - * Every such id comes from {@link reserveDefaultFlowId}, which already claimed - * it, so nothing else can be holding it by the time the flow is pushed. + * `forcedId` is a reserved default flow's id, claimed by + * {@link reserveDefaultFlowId} before anything else could take it. */ function addFlow( builder: Builder, @@ -2186,7 +2049,6 @@ function addFlow( }); } -/** Honors a reserved `exitFlowId`; a branch that terminated gets no continuation. */ function joinContinuation( builder: Builder, branch: Frontier, @@ -2198,11 +2060,9 @@ function joinContinuation( } /** - * Drop the synthesized join gateway when nothing flows into it, which happens - * when every branch terminates via `end`/`throw`/`goto` or a nested compound - * that never falls through. A join with zero incoming flows is invalid BPMN. - * The join is always still in place here: its lowering pushed it before the - * branches, and nothing between removes an element. + * Drop the join when nothing flows into it, i.e. every branch terminated; a + * join with no incoming flow is invalid BPMN. The join itself is still in + * place, pushed before the branches. */ function pruneUnreachableJoin(builder: Builder, joinId: string): string | null { if (builder.sequenceFlows.some((flow) => flow.targetRef === joinId)) { @@ -2216,8 +2076,7 @@ function pruneUnreachableJoin(builder: Builder, joinId: string): string | null { } /** - * Seeds the collision set, so a synthesized id never clashes with a named - * element. An on-handler's id is positional and never registered, which + * An on-handler's id is positional and never registered, which * {@link isNamedStatement} encodes by leaving `OnHandler` out; `streamAst` * still walks its body for the names inside it. */ @@ -2232,11 +2091,7 @@ function collectNamedIds(process: Process): Set { /** The `key`/`value` shape every setting carries. */ type KeyValueAttr = { key: string; value: Expr }; -/** - * An element's name and documentation, written as its `label` and - * `documentation` settings. The IR calls the label `name`, since BPMN's - * `name` is the human-facing text. - */ +/** The IR calls the `label` setting `name`, since BPMN's `name` is the human-facing text. */ function namedAttrs(stmt: { items: ParenItem[] }): Named { const attrs = settingsOf(stmt.items); const name = attrValue(attrs, 'label'); @@ -2247,34 +2102,27 @@ function namedAttrs(stmt: { items: ParenItem[] }): Named { }; } -/** - * The first matching setting's value, as the plain string the IR carries. NOT - * for a key the engine evaluates as EL (`expression`, `delegate`, - * `mapperDelegate`, `businessKey`, an io value): {@link elAttrValue} keeps a - * bareword or dotted path wrapped in `${...}` instead of stripping it. - */ +/** The first `key` setting's value as {@link exprText} reads it. */ function attrValue(attrs: KeyValueAttr[], key: string): string | undefined { const attr = attrs.find((a) => a.key === key); return attr === undefined ? undefined : exprText(attr.value); } /** - * Read an expression as the plain BPMN text a key the engine takes as - * written carries (`label`, `documentation`, `class`, ...) rather than - * evaluates as EL: a string literal yields its bare value, a bareword its - * dotted path verbatim, anything else its canonical `${...}` body. For a key - * the engine evaluates as EL, see {@link elText}. + * Text the engine takes as written (`label`, `documentation`, `class`): a + * string literal bare, a bareword or dotted path verbatim, anything else its + * `${...}` body. A key the engine evaluates as EL (`expression`, `delegate`, + * `businessKey`, an io value) reads through {@link elText} instead, which keeps + * a bareword wrapped so it is a variable lookup rather than a fixed string. */ function exprText(value: Expr): string { if (isLiteralString(value)) { - // The lexer already stripped the surrounding quotes. return value.value; } if (isVarRef(value) && value.accessors.length === 0) { return value.ref.$refText; } - // A dotted VarRef renders as `${com.example.X}`; strip the `${...}` wrapper so - // the IR carries the plain dotted path the BPMN attribute expects. + // A dotted VarRef renders as `${com.example.X}`; BPMN wants the bare path. const rendered = renderExpression(value); if (isVarRef(value)) { return stripExpressionWrapper(rendered); @@ -2282,13 +2130,7 @@ function exprText(value: Expr): string { return rendered; } -/** - * Read an expression as the EL text `createExpression` evaluates to what the - * author meant: a string literal is fixed text with no `${...}` slot to - * lose. A bareword, a dotted path, an operator chain, or a raw template - * takes its canonical `${...}`/`#{...}` rendering instead, since the engine - * reads bare text as a fixed string rather than as a variable lookup. - */ +/** Text the engine evaluates as EL; {@link exprText} explains the split. */ function elText(value: Expr): string { if (isLiteralString(value)) { return value.value; @@ -2296,17 +2138,12 @@ function elText(value: Expr): string { return renderExpression(value); } -/** - * The first matching attribute's value as {@link elText} reads it. Unlike - * {@link attrValue} a bareword or dotted `VarRef` stays wrapped in `${...}`, - * which is what Operaton evaluates as EL. - */ +/** The first `key` setting's value as {@link elText} reads it. */ function elAttrValue(attrs: KeyValueAttr[], key: string): string | undefined { const attr = attrs.find((a) => a.key === key); return attr === undefined ? undefined : elText(attr.value); } -/** For dotted-identifier values the grammar parses as a `VarRef` but BPMN wants as text. */ function stripExpressionWrapper(rendered: string): string { if (rendered.startsWith('${') && rendered.endsWith('}')) { return rendered.slice(2, -1); @@ -2314,7 +2151,6 @@ function stripExpressionWrapper(rendered: string): string { return rendered; } -/** Reads one of the process header keys in `PROCESS_HEADER_KEYS`, verbatim as authored. */ function processSetting(process: Process, key: string): string | undefined { return attrValue(settingsOf(process.items), key); } diff --git a/packages/transform/src/cfg-analysis.ts b/packages/transform/src/cfg-analysis.ts index e74023e..dfd6169 100644 --- a/packages/transform/src/cfg-analysis.ts +++ b/packages/transform/src/cfg-analysis.ts @@ -51,10 +51,9 @@ export function analyzeCfg(container: FlowContainer): CfgAnalysis { const dominates = makeDominanceQuery(idom, VIRTUAL_ENTRY); const postDominates = makeDominanceQuery(ipdom, VIRTUAL_EXIT); - // A back-edge is u -> v where v dominates u. Filtering the raw flow list - // keeps every parallel edge and excludes the sentinel edges. Computed once: - // `tryWhile` and `tryDoWhileEntry` each ask for it per emitted node, and a - // straight chain of n tasks would otherwise refilter all n flows n times. + // A back-edge is u -> v where v dominates u; filtering the raw flow list + // keeps every parallel edge and excludes the sentinel edges. Computed once, + // since `tryWhile` and `tryDoWhileEntry` ask for it per emitted node. const backEdgeList = container.sequenceFlows.filter((f) => dominates(f.targetRef, f.sourceRef), ); @@ -115,13 +114,11 @@ function buildGraph(container: FlowContainer): Graph { addEdge(f.sourceRef, f.targetRef); } - // A boundary event and a link catch are each an independent entry beside - // the start: a boundary's token appears when its trigger fires, a link - // catch's when the engine reroutes a flow into a throw of its name, never - // along a flow drawn to either. All three are wired unconditionally rather - // than folded into the no-start fallback. Any other node without a - // predecessor stays unwired: it really is unreachable. A link throw needs - // no wiring of its own: it has no successor and drains to VIRTUAL_EXIT. + // A boundary event and a link catch are each an entry beside the start: a + // boundary's token appears when its trigger fires, a link catch's when the + // engine reroutes a throw of its name, never along a drawn flow. Any other + // node without a predecessor stays unwired: it really is unreachable. A + // link throw has no successor and drains to VIRTUAL_EXIT below. const hasAnyStart = container.flowElements.some( (e) => e.kind === 'startEvent', ); @@ -251,16 +248,11 @@ function reversePostorder(root: string, succ: Map): string[] { /** * `a` dominates `b` when `a` sits on `b`'s idom chain. Reflexive and total. - * - * `idom` is a tree rooted at `root`: every reachable node has exactly one - * parent, so numbering it by one DFS gives each node a pre-order entry time - * and, as its exit time, the last entry time handed out anywhere in its - * subtree. `a` is then an ancestor of `b` (or `b` itself) exactly when `b`'s - * entry time falls inside `a`'s [entry, exit] interval, a classic Euler-tour - * containment test that answers in two map lookups. - * A node absent from `idom` (unknown or unreachable) gets no interval and the - * query answers false for it on either side, which is what "dominated by - * nothing and dominates nothing" requires. + * `idom` is a tree rooted at `root`, so one DFS numbers each node with a + * pre-order entry time and, as its exit time, the last entry time handed out + * in its subtree; `a` is an ancestor of `b` (or `b` itself) exactly when + * `b`'s entry falls inside `a`'s [entry, exit]. A node absent from `idom` + * (unknown or unreachable) gets no interval and answers false on either side. */ function makeDominanceQuery( idom: Map, diff --git a/packages/transform/src/errors.ts b/packages/transform/src/errors.ts index 9fa558f..7c509d6 100644 --- a/packages/transform/src/errors.ts +++ b/packages/transform/src/errors.ts @@ -4,11 +4,10 @@ * Content the IR cannot express is refused before any IR is produced. Content * the IR does not carry but that costs no semantics is dropped with a warning * instead, on the `warnings` channel `xmlToIr` returns. - * `packages/transform/README.md` tabulates which construct lands where. + * `packages/transform/README.md` describes which construct lands where. */ import { - CATCH_TRIGGERS, EMIT_TRIGGERS, FORM_CONSTRAINT_NAMES, formatPlainWordList, @@ -18,10 +17,9 @@ import { } from '@bpmn-script/language'; /** - * Base for every refusal, so a consumer can classify the whole family with one - * `instanceof`. Subclasses declare their fields with `declare` so the class - * field initializers cannot overwrite what `Object.assign` wrote after - * `super()`. + * Base for every refusal, so one `instanceof` classifies the family. + * Subclasses `declare` their fields so class field initializers cannot + * overwrite what `Object.assign` wrote after `super()`. */ export abstract class UnsupportedConstructError extends Error { constructor(message: string, detail: Record) { @@ -106,6 +104,15 @@ export class UnsupportedFormFieldConstraintError extends UnsupportedConstructErr } } +/** The closing sentence of every {@link UnsupportedElementError}. */ +export const SUPPORTED_KINDS_MESSAGE = + 'Only start/end events, throws, emits, boundary events, event ' + + 'handlers, plain tasks, user tasks, service tasks, send tasks, ' + + 'receive tasks, business rule tasks, script tasks, exclusive ' + + 'gateways, parallel gateways, inclusive gateways, event-based ' + + 'gateways, embedded subprocesses, attempt blocks, call activities, ' + + 'and sequence flows are supported.'; + /** * A flow element kind outside the supported subset, such as * `bpmn:adHocSubProcess` or `bpmn:complexGateway`. A supported kind carrying an @@ -121,13 +128,7 @@ export class UnsupportedElementError extends UnsupportedConstructError { super( `The BPMN element ${qname}` + (elementId ? ` (id='${elementId}')` : '') + - ' is a kind that this tool cannot import. ' + - 'Only start/end events, throws, emits, boundary events, event ' + - 'handlers, plain tasks, user tasks, service tasks, send tasks, ' + - 'receive tasks, business rule tasks, script tasks, exclusive ' + - 'gateways, parallel gateways, inclusive gateways, event-based ' + - 'gateways, embedded subprocesses, attempt blocks, call activities, ' + - 'and sequence flows are supported.', + ` is a kind that this tool cannot import. ${SUPPORTED_KINDS_MESSAGE}`, { qname, elementId }, ); } @@ -151,6 +152,8 @@ export class UnsupportedCallActivityError extends UnsupportedConstructError { } } +export type EventPosition = 'start' | 'end' | 'intermediate throw' | 'boundary'; + /** * An event definition kind this tool does not import at that position. The * right kind in the wrong shape (an error throw with no code, a timer with no @@ -158,19 +161,13 @@ export class UnsupportedCallActivityError extends UnsupportedConstructError { */ export class UnsupportedEventDefinitionError extends UnsupportedConstructError { declare readonly elementId: string; - declare readonly eventKind: - 'start' | 'end' | 'intermediate throw' | 'intermediate catch' | 'boundary'; + declare readonly eventKind: EventPosition; /** Moddle `$type`, e.g. `bpmn:TerminateEventDefinition`. */ declare readonly definitionType: string; constructor( elementId: string, - eventKind: - | 'start' - | 'end' - | 'intermediate throw' - | 'intermediate catch' - | 'boundary', + eventKind: EventPosition, definitionType: string, ) { super( @@ -205,10 +202,9 @@ const EVENT_SURFACE_NOTE = /** * A supported event definition kind shaped in a way the DSL surface cannot - * express. `detail` names the shape; the sentence after it says what to write - * instead. A refusal outside the handler/throw/emit surface passes its own - * `remedy`, so the reader is told the move that fixes the document rather than - * a rule that does not describe it. + * express. `detail` names the shape; a refusal outside the handler/throw/emit + * surface passes its own `remedy`, so the reader is told the move that fixes + * the document. */ export class UnsupportedEventFeatureError extends UnsupportedConstructError { declare readonly elementId: string; @@ -320,13 +316,10 @@ export class UnsupportedFormReferenceError extends UnsupportedConstructError { } /** - * A condition Operaton evaluates outside UEL, or refuses: a sequence flow's - * `bpmn:conditionExpression`, or a conditional event definition's - * `bpmn:condition`. Both reach Operaton's `parseConditionExpression`, so - * `detail` names the same two shapes either way: a `language` attribute, from - * which Operaton builds a `ScriptCondition` and runs the body in that - * language, never as the UEL expression this tool writes; or an `xsi:type` - * other than `tFormalExpression`, which fails the deployment. + * A sequence flow's `bpmn:conditionExpression` or a conditional event + * definition's `bpmn:condition` that Operaton's `parseConditionExpression` + * runs outside UEL (a `language` attribute builds a `ScriptCondition`) or + * refuses (an `xsi:type` other than `tFormalExpression`); `detail` names which. */ export class UnsupportedConditionExpressionError extends UnsupportedConstructError { declare readonly elementId: string; @@ -403,10 +396,7 @@ function friendlyEventDefinition(definitionType: string): string { return local.replace(/EventDefinition$/, '').toLowerCase() || 'special'; } -function supportedKindsMessage( - eventKind: - 'start' | 'end' | 'intermediate throw' | 'intermediate catch' | 'boundary', -): string { +function supportedKindsMessage(eventKind: EventPosition): string { switch (eventKind) { case 'start': return ( @@ -422,8 +412,6 @@ function supportedKindsMessage( ); case 'intermediate throw': return `An emit supports ${formatPlainWordList(EMIT_TRIGGERS)}.`; - case 'intermediate catch': - return `An await supports ${formatPlainWordList(CATCH_TRIGGERS)}.`; case 'boundary': return ( `A boundary event supports ${formatPlainWordList(BOUNDARY_TRIGGERS)}, ` + @@ -434,9 +422,8 @@ function supportedKindsMessage( /** * `bpmn-auto-layout`'s grid solver throws on some validator-clean flow shapes - * (a `goto` restructuring among them). `xml` is the document as serialized - * before the layout call: no `bpmndi:` diagram, but otherwise the document - * Operaton deploys the same as a laid-out one + * (a `goto` restructuring among them). `xml` is the document with no + * `bpmndi:` element, for any process; Operaton deploys it the same * (`BpmnParse.parseDiagramInterchangeElements` reads a diagram only when one * is present), so a caller can fall back to it. */ diff --git a/packages/transform/src/index.ts b/packages/transform/src/index.ts index d564460..008f6e5 100644 --- a/packages/transform/src/index.ts +++ b/packages/transform/src/index.ts @@ -72,8 +72,16 @@ export { export { parseJuel, renderRawFallback } from './juel.js'; export type { JuelNode, Accessor, BinaryOp, ExprResult } from './juel.js'; -export { irToXml, type IrToXmlOptions } from './ir-to-xml.js'; -export { xmlToIr } from './xml-to-ir.js'; +export { irToXml } from './ir-to-xml.js'; +export { + COMPENSATION_BOUNDARY_DETAIL, + CONNECTOR_CONSTRUCT, + dataConstructDropMessage, + IS_FOR_COMPENSATION_DETAIL, + loopDroppedMessage, + manualTaskMessage, + xmlToIr, +} from './xml-to-ir.js'; export type { ImportWarning, ImportWarningCategory } from './xml-to-ir.js'; export { UnsupportedConstructError, diff --git a/packages/transform/src/ir-to-dsl.ts b/packages/transform/src/ir-to-dsl.ts index dcdd5b0..e764907 100644 --- a/packages/transform/src/ir-to-dsl.ts +++ b/packages/transform/src/ir-to-dsl.ts @@ -1,23 +1,21 @@ /** - * Restructuring IR -> DSL, the inverse of `astToIr`: a flat, BPMN-shaped - * {@link BpmnProcess} back into source that re-parses and re-desugars to an - * equivalent IR. + * Restructuring IR -> DSL, the inverse of `astToIr`: a flat {@link BpmnProcess} + * back into source that re-parses and re-desugars to an equivalent IR. * * Structure comes from the dominator analysis in `cfg-analysis.ts` matched - * against a fixed pattern catalog; whatever it cannot fold degrades to - * `goto`. See ADR 0009, Use Dominator/Post-Dominator Analysis for IR-to-DSL - * Restructuring, for the catalog, the totality guarantee, and the edges that - * have no `goto` form and leave an {@link UNSTRUCTURED_MARKER}. - * - * Every gateway a pattern matches is elided, which is what makes DSL -> IR -> - * DSL idempotent: the desugarer derives gateway ids from structural - * coordinates, so one that never prints is re-synthesized under the same id. + * against the pattern catalog of ADR 0009; whatever it cannot fold degrades to + * `goto`, and an edge with no `goto` form leaves an {@link UNSTRUCTURED_MARKER}. + * Every gateway a pattern matches is elided: the desugarer derives gateway ids + * from structural coordinates, so one that never prints is re-synthesized under + * the same id, which is what makes DSL -> IR -> DSL idempotent. */ import { CALL_MAPPER_KEY_BY_KIND, DATE_PATTERN_KEY, END_TRIGGERS, + ENGINE_KEYS, + type EngineKey, EXPRESSION_OPEN, EXTERNAL_TASK_EL_NAME, isReservedName, @@ -26,6 +24,7 @@ import { runSettingKey, TIMER_PARTICLE_BY_KIND, TYPE_BINDING_KEY, + USER_TASK_VERBATIM_KEYS, } from '@bpmn-script/language'; import type { BpmnProcess, @@ -85,35 +84,26 @@ import { export const INDENT = ' '; /** - * The lines one construct prints on. A fenced body keeps its newlines inside a - * single entry: the text between the fences is the script itself, and an - * enclosing block indents whole entries, so splitting one would rewrite the - * code it holds. + * One entry per line, except a fenced script body, which keeps its newlines + * inside one entry: an enclosing block indents whole entries, and indenting + * inside the fence would rewrite the code. */ type Lines = string[]; -/** - * The names one print writes: the name a raised code is written under, per - * kind ({@link codeDeclarations}), and the name an id the script cannot spell - * prints as ({@link printedNames}). - */ interface PrintNames { + /** The name each raised code is written under, from {@link codeDeclarations}. */ error: Map; escalation: Map; - /** Only the ids that print under another name; every other id prints as itself. */ + /** Only the ids that print under another name ({@link printedNames}); every other id prints as itself. */ printed: ReadonlyMap; } -/** The name `id` prints under, at every site that writes an element or process id. */ +/** Every site that writes an element or process id goes through here. */ function nameOf(names: PrintNames, id: string): string { return names.printed.get(id) ?? id; } -/** - * What the start and end predicates read of a container: its id, which seeds - * the minted terminals, and its elements, for the boundaries whose escape - * chains end in a minted end and for the first plain start. - */ +/** The id seeds the minted terminal ids; the elements find the boundaries and the first plain start. */ export type PrintContainer = Pick; export type PrintWarningCategory = @@ -167,20 +157,15 @@ export function irToDsl(process: BpmnProcess): { } /** - * A split or a merge is derived from the block structure and has no statement - * of its own, so the text on one is lost on the way out. Every other elided - * label is reported by `xmlToIr`, which is why this covers gateways alone: a + * Gateways alone: every other elided label is reported by `xmlToIr`, and a * wider rule would report the same drop twice to a caller printing both - * channels. A label and documentation are reported apart, one warning per - * fact, so a caller sorting by category sees each of them once. + * channels. */ function warnGatewayText( container: FlowContainer, warnings: PrintWarning[], ): void { for (const el of container.flowElements) { - // Keyed on the container shape, so a nested split is reached whatever - // container it sits in. if ('flowElements' in el) { warnGatewayText(el, warnings); continue; @@ -212,25 +197,19 @@ function warnGatewayText( } /** - * The model, not the print, is where this one comes from: the script writes out - * the name the model holds, and the compiler turns that writing down on the way - * back in. The report is what tells the reader it is the model to repair. - * - * The emitter reports a plain synthesized end printed for its position itself, - * since only it knows that position. - * - * `suppressed` describes the container being walked, false at the top: a start - * in the process body prints its own trigger. + * A reserved name is the model's to repair, so it is reported ahead of the + * print. A plain synthesized end that prints for its position is reported by + * the emitter, which alone knows the position. */ function warnRefusedStatements( container: FlowContainer, names: PrintNames, warnings: PrintWarning[], - suppressed = false, + startTriggerSuppressed = false, ): void { for (const el of container.flowElements) { if ( - !isElidedOnPrint(el, container, suppressed) && + !isElidedOnPrint(el, container, startTriggerSuppressed) && isReservedName(nameOf(names, el.id), container.id) ) { warnings.push(reservedNameWarning(el.id)); @@ -253,18 +232,14 @@ function reservedNameWarning(elementId: string): PrintWarning { } /** - * The name each id prints under where the script cannot spell the id itself: - * the process id and every element that writes its id as a statement name or - * a `goto` target. A gateway, a boundary and an event sub-process never write - * theirs, so they keep it and draw no report. A minted name is resolved - * against every id in the document and every name minted before it, since a - * name written twice leaves every jump to it ambiguous. - * - * The rebuilt document carries the new id, and the engine keys history - * (`HistoricActivityInstance.getActivityId`), migration plans - * (`MigrationPlanBuilder.mapActivities`) and a modification - * (`InstantiationBuilder.startBeforeActivity`) on the activity id, so the - * report says the run is not the same. + * Every id the script writes, as the process header, a statement name or a + * `goto` target, and cannot spell; a gateway, a boundary and an event + * sub-process never write theirs. A minted name is resolved against every id + * in the document and every name minted before it, since a name written twice + * leaves every jump to it ambiguous. The rebuilt document carries the new id, + * which the engine keys history (`HistoricActivityInstance.getActivityId`), + * migration plans (`MigrationPlanBuilder.mapActivities`) and a modification + * (`InstantiationBuilder.startBeforeActivity`) on, hence "not the same". */ function printedNames( process: BpmnProcess, @@ -304,10 +279,9 @@ function printedNames( } /** - * One per container. A sub-process's body lives in the child container's - * arrays, so the parent's CFG never sees inside it and treats the sub-process - * as one opaque node. Cross-container edges cannot exist, so no region spans - * two containers. + * One per container: a sub-process's body lives in the child container's + * arrays, so the parent's CFG treats the sub-process as one opaque node and no + * region spans two containers. */ class Emitter { private readonly cfg: CfgAnalysis; @@ -316,7 +290,6 @@ class Emitter { private readonly outgoingBySource = new Map(); private readonly emittedNodes = new Set(); private readonly consumedFlows = new Set(); - /** The gateways whose job settings a statement head has printed. */ private readonly settingsTaken = new Set(); /** * The `await`/`parallel` branch each statement was printed in, as the path @@ -345,7 +318,6 @@ class Emitter { private readonly container: FlowContainer, /** Shared with every nested container, so one process yields one report. */ private readonly warnings: PrintWarning[], - /** The header's declarations and the printed names, which every use site in the body writes. */ private readonly names: PrintNames, /** * An event sub-process's start prints its trigger in the `on` header, so @@ -379,22 +351,19 @@ class Emitter { } /** - * The order of the first four passes is a constraint. A node belongs to the - * chain that reaches it from one of the container's own entries, a start or - * a node no flow enters (a link catch above all), so every such chain is - * walked before the boundary handlers and a handler whose body jumps to one - * degrades to a `goto` there, its target being emitted already. A boundary - * escape chain is reached from no entry, so walking it before the orphan - * sweep stops that sweep printing the chain as detached top-level statements. + * The pass order is the ownership rule: the pass that walks a node first + * owns it in the print. Every chain a container entry reaches (a start, or a + * node no flow enters, a link catch above all) is walked before the boundary + * handlers, so a handler whose body jumps to one degrades to a `goto` there; + * a boundary escape chain is reached from no entry, so it is walked before + * the orphan sweep would print it as detached top-level statements. */ emit(): string[] { const lines: string[] = []; - // 1. Structured emission from each start event. An elided start is - // re-derived by the compiler only at the head of a body that opens with - // no `start`, so its chain is walked first and alone; anywhere else it - // lands after another chain's `end` as a dangling `goto` and the compiler - // re-derives no start for it. + // 1. The elided start first and alone: the compiler re-derives it only at + // the head of a body that opens with no `start`, and anywhere else its + // chain lands after another chain's `end` as a dangling `goto`. for (const el of this.container.flowElements) { if ( el.kind === 'startEvent' && @@ -442,8 +411,8 @@ class Emitter { } } - // 5. Final goto sweep. A jump carries the route and nothing else, so a - // condition on one of these goes the way a fall-through's does. + // 5. Every route left: a jump carries no condition, so `consume` reports + // one the way it does for a fall-through. for (const f of this.container.sequenceFlows) { if (!this.consumedFlows.has(f.id)) { this.consume(f); @@ -453,10 +422,9 @@ class Emitter { // A plain synthesized end stays out only at its block's tail, the one // position the compiler re-derives it at, and whether a flow statement - // followed it is known only now. The boundary blocks and handlers appended + // followed it is known only here. The boundary blocks and handlers appended // below are lowered out of chain, so they do not count as one. Reverse - // order keeps every lower index valid while splicing. A boundary body is - // one chain in its own array, so its end is always that array's tail. + // order keeps every lower index valid while splicing. const printedEnds: string[] = []; for (const d of this.deferredEnds.toReversed()) { if (d.index >= d.lines.length) continue; @@ -508,13 +476,13 @@ class Emitter { } /** - * Every unprinted start leaving for the same step as `start` prints with - * it, back to back, and that step's chain is walked once under them: starts + * Every unprinted start leaving for the same step as `start` prints with it, + * back to back, and the step's chain is walked once under them, since starts * written back to back all enter the statement after them. Walked one at a - * time, the second start would find the step printed and jump onto it, - * which a jump cannot do for an elided end, and a `start` after the first - * chain would leave that chain's elided end off its block's tail. A start - * leaving on several routes is a split of its own and prints as one. + * time, the second start would find the step printed and jump onto it, which + * a jump cannot do for an elided end, and a `start` after the first chain + * would leave that chain's elided end off its block's tail. A start leaving + * on several routes is a split of its own and prints as one. */ private emitStartGroup( start: Extract, @@ -589,14 +557,13 @@ class Emitter { } /** - * Taken only while the merge is an unprinted gateway with one route out, - * the shape the compiler synthesizes and the printed keys land on again. A - * merge with a second route is a statement of its own and keeps its - * settings for that head; one already printed carries them nowhere, and - * the sweep reports it. Counted over the model's routes, not the unprinted - * ones: an enclosing loop spends the back-edge a merge at its body's tail - * leaves by before the body is walked, and that merge is still the - * pass-through it elides. + * Only an unprinted merge with one route out, the shape the compiler + * synthesizes and lands the printed keys on again; a merge with a second + * route is a statement of its own, and one already printed carries them + * nowhere, which the sweep reports. Counted over the model's routes, not the + * unprinted ones: an enclosing loop spends the back-edge a merge at its + * body's tail leaves by before the body is walked, and that merge is still + * the pass-through it elides. */ private takeJoinSettings(join: string | undefined): string[] { if (join === undefined) return []; @@ -614,7 +581,6 @@ class Emitter { return jobSettingItems(el, joinSettingKey); } - /** The routes of `flows` no construct has printed yet, in IR order. */ private unconsumed(flows: readonly SequenceFlow[]): SequenceFlow[] { return flows.filter((f) => !this.consumedFlows.has(f.id)); } @@ -672,7 +638,6 @@ class Emitter { depth: number, ): void { if (depth > MAX_NESTING_DEPTH) { - // Flush the arrival as a goto rather than overflowing the stack. if (node !== undefined) this.pushGoto(node, lines); return; } @@ -690,7 +655,6 @@ class Emitter { } } - /** Returns the next id in the fall-through chain, or {@link STOP}. */ private emitNode( id: string, stop: string | undefined, @@ -729,13 +693,6 @@ class Emitter { return this.emitRaceGateway(el, stop, lines, depth); } - // A fenced body is opaque multi-line text, so it prints as a line group. - if (el.kind === 'scriptTask') { - this.emittedNodes.add(id); - lines.push(...renderScriptTask(el, this.names)); - return this.followLinear(id, stop, lines, depth); - } - if (el.kind === 'subProcess') { // An event sub-process prints in the trailing handler pass and is // entered by its trigger alone; the import refuses a flow into one, so @@ -760,8 +717,8 @@ class Emitter { return this.followLinear(id, stop, lines, depth); } - // Not reached by flow either. The plain-node case below would emit nothing - // and lose the element, so a malformed inbound edge prints its block here. + // A boundary has no inbound flow either; a malformed one prints the block + // here rather than losing the element. if (isBoundary(el)) { this.emitBoundaryHandler(el, lines, depth); return STOP; @@ -791,18 +748,14 @@ class Emitter { } /** - * The routes leaving a statement. One is the fall-through the next statement - * takes. More than one is a fork with the statement as its split: - * `BpmnActivityBehavior.performOutgoingBehavior` leaves by every route whose - * condition holds or that carries none and by the `default` alone when none - * was taken, which is the inclusive fork's rule, and with nothing weighed - * and no fallback it is the parallel fork's. One weighed route beside the - * fallback is the shape a choice routes the same way, and keeps the - * `if`/`else` the choice prints. - * - * What splits is read off the model rather than off the routes left to print: - * a route an enclosing loop has already printed as its closing brace is one - * the step takes beside the others all the same. + * One route is the fall-through; more is a fork with the statement as its + * split. `BpmnActivityBehavior.performOutgoingBehavior` leaves by every route + * whose condition holds or that carries none, and by the `default` alone when + * none was taken: the inclusive fork's rule, or with nothing weighed and no + * fallback the parallel fork's; one weighed route beside the fallback routes + * like a choice and keeps its `if`/`else`. Read off the model rather than off + * the routes left to print: a route an enclosing loop has already printed as + * its closing brace is one the step takes beside the others all the same. */ private followLinear( id: string, @@ -827,11 +780,8 @@ class Emitter { } /** - * Given a clean post-dominating join, the branch bodies are the full - * sub-regions up to it; without one, each body is a single `goto target`, so - * every edge still survives. Keyed on the id rather than on a gateway, - * because a step whose own routes split reaches the same chain with no node - * of its own there. + * Keyed on the id rather than on a gateway: a step whose own routes split + * reaches the same chain with no node of its own there. */ private emitChoice( splitId: string, @@ -850,10 +800,9 @@ class Emitter { } /** - * The routes taken at one position, given as a list rather than read off the - * split, so a construct that has already spent some of a node's routes can - * hand the rest to the same chain. The loop emitters do: the routes their - * pattern does not spend are taken at the position after the closing line. + * The routes are given as a list rather than read off the split, so the loop + * emitters can hand the routes their pattern did not spend to the same chain + * at the position after the closing line. */ private emitRoutes( splitId: string, @@ -862,17 +811,12 @@ class Emitter { lines: string[], depth: number, ): string | typeof STOP { - // A choice does read a condition written on the route taken when no other - // holds, and the engine refuses the model at deployment for carrying one; - // a step skips its fallback while weighing and takes it when nothing held, - // so there the condition is weighed nowhere. Asked of the routes the model - // gives the split, ahead of the two shapes below that print without a - // chain. + // Ahead of the two early returns: the report reads the model's routes, + // whatever prints. this.warnFallbackCondition(splitId); if (outs.length === 0) return STOP; if (outs.length === 1) { - // One route on: the fall-through, or a degenerate single-out gateway. return this.takeFallThrough(outs[0]!, stop, lines); } @@ -900,23 +844,17 @@ class Emitter { depth, ); - // Stop when the gateway was unstructured and every branch jumped away. return join !== undefined ? this.continueAt(join, stop, lines) : STOP; } /** - * Every route gets a form: one carrying a condition heads its own branch with - * it, and one carrying none heads a branch as `true`, takes the chain's - * `else`, or runs straight into the join as the fall-through, which is - * written as nothing at all. - * - * An `if` chain has one `else`, so an earlier route with no condition heads - * its branch with `true`. A split reaches that only with a second - * unconditioned route, which the desugarer never writes. - * - * `fallback` is the route the model takes when no condition holds, and it - * gets the `else` ahead of any other candidate, which is what keeps the chain - * total where the model was. + * Every route gets a form: a conditioned one heads a branch with its + * condition, and an unconditioned one heads a branch as `true`, takes the one + * `else`, or, running straight into the join, prints as nothing. The `else` + * goes to the model's fallback first, which keeps the chain total where the + * model was; then to the route into the join, since a `true` head over that + * empty branch would leave the rest unreachable; then to the last + * unconditioned route, a second one being a shape the desugarer never writes. */ private emitIfChain( weighed: SequenceFlow[], @@ -928,9 +866,6 @@ class Emitter { lines: string[], depth: number, ): void { - // A route straight to the join is the fall-through an `else`-less chain - // already has, so it prints as nothing and takes the `else` when it can: a - // `true` head over that empty branch would leave the rest unreachable. const elseFlow = fallback ?? unweighed.find((f) => f.targetRef === join) ?? @@ -942,8 +877,6 @@ class Emitter { .map((f): [string, SequenceFlow] => ['true', f]), ]; - // At least one head: this runs on two routes or more, and at most one of - // them is the `else`. heads.forEach(([condition, f], i) => { lines.push( i === 0 @@ -963,11 +896,9 @@ class Emitter { } /** - * The routes of a split sorted the way the chain and the fork block read - * them. The fallback is the `else` whatever else it carries: heading its - * branch with the condition would put the branch on a run of its own and - * leave the split with nowhere to go when nothing holds. Desugared IR has at - * most one unconditioned flow; imported IR may carry more. + * The fallback counts as unconditioned whatever it carries: heading its + * branch with the condition would put it on a run of its own and leave the + * split with nowhere to go when nothing holds. */ private weighRoutes( splitId: string, @@ -991,12 +922,10 @@ class Emitter { } /** - * A fork or a race the catalog cannot fold, every route leaving as a jump - * under the condition it carried. Each jump takes a branch of its own because - * a jump ends its block, so a second one written beside the first could never - * run. The chain keeps the conditions and loses the split's kind, which the - * marker names and {@link degradedSplitWarning} reports; the settings go - * with it, which the sweep reports. + * Every route leaves as a jump under its condition, each in a branch of its + * own since a jump ends its block. The split's kind is lost, which the marker + * names and {@link degradedSplitWarning} reports, and its settings with it, + * which the sweep reports. */ private emitJumps( splitId: string, @@ -1023,13 +952,11 @@ class Emitter { } /** - * The checks below establish that the branch region belongs to this gateway - * and re-enters at the join. `undefined` means the gateway is unstructured. - * - * A join past `stop` is refused: inside a loop body the body dominates - * everything after the loop, so a split whose routes leave the loop and - * reconverge behind it would pass the dominance checks, walk the staying - * route past the printed loop head and drop the back edge. + * `undefined` when the split is unstructured. A join past `stop` is refused: + * inside a loop body the body dominates everything after the loop, so a + * split whose routes leave the loop and reconverge behind it would pass the + * dominance checks, walk the staying route past the printed loop head and + * drop the back edge. */ private cleanJoin( splitId: string, @@ -1041,7 +968,6 @@ class Emitter { if (stop !== undefined && !this.cfg.postDominates(stop, join)) { return undefined; } - if (!this.cfg.postDominates(join, splitId)) return undefined; if (!this.cfg.dominates(splitId, join)) return undefined; for (const f of outs) { if (f.targetRef === join) continue; @@ -1052,28 +978,23 @@ class Emitter { /** * Where the routes come back together when the post-dominator queries - * cannot say: the nearest node every live route reaches, read off the - * model's routes and bounded by `stop`. A branch that can end puts the - * split's post-dominator at the exit, and a step that loops on itself - * leaves the container with no post-dominators at all, so the clean join - * and the survivors' chains both miss a merge the routes plainly share, and - * a branch walked past it prints the continuation inside the block. + * cannot say: a branch that can end puts the split's post-dominator at the + * exit, and a step that loops on itself leaves the container with no + * post-dominators at all, so {@link cleanJoin} and {@link recoveredForkJoin} + * both miss a merge the routes plainly share. * - * A route straight into a sink is a branch that ends and never counts. Of - * the rest, the routes that reach the block's exit, which is `stop` or, - * at the top of the container, its elided end, are the live ones; where - * none does, a route is live when it reaches a node another route reaches, - * and where none does that either, every one is. Among the nodes the live - * routes share, in the first one's breadth order: a merge, a gateway with - * one route out, ahead of a step on the way to it, since the compiler - * lowers every block with a merge and a step ahead of it belongs to the - * branch; then the exit; then the nearest step no live route can end or - * leave before, except for a lone live route heading a branch with its - * condition, whose whole chain is that branch and which continues at the - * farthest such step. `joinKind` narrows the answer to a merge of that kind - * the split dominates, the only node a fork or a race synchronizes at: a - * merge entered from outside the block as well would synchronize that - * entry too once the block's own join stands for it. + * Read off the model's routes, bounded by `stop`. A route straight into a + * sink never counts. The live routes are those reaching the block's exit + * (`stop`, or the elided end at the top of the container); where none does, + * those reaching a node another route reaches; where none does that either, + * all of them. The answer is the first node in the first live route's + * breadth order that every live route reaches: a merge (a gateway with one + * route out) ahead of a step on the way to it, since the compiler lowers + * every block with a merge; then the exit; then the nearest step no live + * route can end or leave before. `joinKind` narrows a fork's or a race's + * answer to a merge of that kind the split dominates, the only node it + * synchronizes at: a merge entered from outside the block as well would + * synchronize that entry too. */ private convergence( splitId: string, @@ -1117,14 +1038,14 @@ class Emitter { (el.kind === kind && this.cfg.dominates(splitId, n))) ); }; - // A lone live route's reach holds every merge on its way, a nested or a - // later block's beside this block's own, and the graph does not tell - // them apart in general: the compiler keeps a join one route enters. - // What it does tell: this block's join is entered by the lone route - // alone where a nested if/else's is entered by each of its routes, and a - // choice's join is a merge of its own kind where a nested fork's is not. - // A nested guard clause's join is entered by one route too, so it can - // still be taken first; the block's tail then prints after the block. + // A lone live route reaches every merge on its way, nested and later + // blocks' beside this block's own, and the graph cannot tell them apart + // in general (the compiler keeps a join one route enters). It can tell + // this much: this block's join is entered by the lone route alone where a + // nested if/else's is entered by each of its routes, and a choice's join + // is a merge of its own kind where a nested fork's is not; a nested guard + // clause's one-in join can still be taken first, and the block's tail + // then prints after the block. const ownKind = joinKind ?? this.byId.get(splitId)?.kind; const merge = live.length === 1 @@ -1142,6 +1063,8 @@ class Emitter { // authored end and jumped to; the guard clause keeps it that way. return live.length === 1 && stop === undefined ? undefined : exit; } + // A lone weighed route's whole chain is its branch, so it continues at the + // farthest step that holds rather than the nearest. const [only] = live; const weighed = live.length === 1 && @@ -1187,13 +1110,10 @@ class Emitter { } /** - * A guard clause has no clean join: one branch terminates while the else-less - * default carries the main flow. Treating that default as the continuation - * prints it after the `if` instead of as a bare `goto` at the split. - * - * Reads the routes the chain leaves unweighed rather than the split's own - * edges, so the one route the model takes when no condition holds is the - * continuation whether it carries a condition or not. + * A guard clause has no clean join: one branch terminates while the + * unconditioned route carries the main flow, which prints after the `if` + * rather than as a bare `goto`. Reads the routes the chain leaves unweighed, + * so the fallback is the continuation whether it carries a condition or not. */ private guardClauseContinuation( unconditioned: SequenceFlow[], @@ -1203,16 +1123,13 @@ class Emitter { /** * A split inside a loop body or a branch whose every route is conditioned: - * a leaving route puts the split's immediate post-dominator outside the - * region (the exit, or a node behind the loop when the leaving route and the - * loop exit share an end), so there is no clean join, and with no - * unconditioned route no guard clause, yet the routes that stay inside run + * a leaving route puts the split's post-dominator outside the region, so + * there is no clean join and no guard clause, yet the routes that stay run * into `stop`, which is the continuation. Only the leaving routes print as - * jumps; as jumps throughout, the staying route's steps would be hoisted - * out of the loop and their edge into its folded head dropped. - * - * A route straight into `stop` counts as staying (`postDominates` is - * reflexive) and prints as an empty branch. + * jumps; as jumps throughout, the staying route's steps would be hoisted out + * of the loop and their edge into its folded head dropped. A route straight + * into `stop` stays, `postDominates` being reflexive, and prints as an empty + * branch. */ private enclosingContinuation( splitId: string, @@ -1251,33 +1168,26 @@ class Emitter { ) { this.emitFrom(entry, join, body, depth + 1); } else { - // The target escapes the [split, join) region: preserve the edge as a goto. this.pushGoto(entry, body); } for (const l of body) lines.push(INDENT + l); } /** - * Whether the entry sits inside `[split, join)` and can be walked inline. - * Four shapes qualify: an ordinary body that re-merges, so `join` - * post-dominates `entry`; a guard clause whose entry is a synthesized - * terminal the split owns and that terminates before the join, which has no - * continuation to relocate and prints the same statement in either scope; - * a guard clause whose entry is a bare authored end that the split's route - * reaches as its only incoming flow; and a guard clause whose entry is a - * gateway the split owns, which a jump could not name anyway, and whose - * routes the walk sorts with `join` as their stop node. + * Four shapes walk inline: a body the join post-dominates; a synthesized + * terminal the split owns, which prints the same statement in either scope; + * a bare authored end the split's route alone enters; and a gateway the + * split owns, which a jump could not name anyway. * * An authored entry with a chain of its own is decided by the block's tail. * A jump hoists the chain behind everything walked from the starts; where - * that ends on the plain end the printer leaves out, the chain would follow - * that end on the page and push it off the one position the compiler - * re-derives it at, so the chain prints inside the branch. Where the block - * ends on authored terminals the chain stays a `goto` at its authored - * scope, so the coordinate-derived ids of its unnamed events survive the - * round trip. A chain that runs into the elided end is that tail itself and - * stays a `goto` as well: printed inside the branch, the end would print - * under its reserved id. + * that ends on the plain end the printer leaves out, the chain would push + * that end off the one position the compiler re-derives it at, so the chain + * prints inside the branch. Where the block ends on authored terminals the + * chain stays a `goto` at its authored scope, so the coordinate-derived ids + * of its unnamed events survive the round trip; and a chain that reaches the + * elided end is that tail itself and stays a `goto` as well, since inside + * the branch the end would print under its reserved id. */ private branchStaysInRegion( entry: string, @@ -1328,13 +1238,11 @@ class Emitter { /** The post-loop continuation, or `undefined` when the pattern misses. */ private tryWhile( - loop: FlowElement, + loop: Extract, stop: string | undefined, lines: string[], depth: number, ): string | typeof STOP | undefined { - if (loop.kind !== 'exclusiveGateway') return undefined; - // Unconditioned mirrors `tryDoWhileEntry`'s conditioned requirement, so the // two patterns never both fire. A route from the head back into itself is // no body: matched here it would print the exit route as the body. @@ -1365,12 +1273,9 @@ class Emitter { } /** - * Post-test `do { body } while (c)`, recognized at the body entry because the - * body runs before the test and would otherwise print as a plain statement. - * The pattern: an exclusive gateway `L` with a conditioned back-edge - * `L -> node`, where `node` dominates `L`. The conditioned back-edge is what - * tells this from a pre-test `while`, which would otherwise match here - * through its inner join-to-head edge. + * An exclusive gateway with a conditioned back-edge into `node`, which + * dominates it. The condition is what tells a post-test loop from a pre-test + * `while`, which would otherwise match here through its join-to-head edge. */ private tryDoWhileEntry( node: string, @@ -1392,27 +1297,25 @@ class Emitter { const loopId = backEdge.sourceRef; const outs = this.unconsumedOut(loopId); - // The back-edge carries the loop condition; the routes beside it are taken - // where the loop leaves off. - const cond = backEdge; this.emittedNodes.add(loopId); - this.consumedFlows.add(cond.id); + this.consumedFlows.add(backEdge.id); const rest = this.takeRest(outs); const settings = this.takeHeadSettings(loopId); lines.push('do {'); this.emitBranch(node, loopId, lines, depth); - lines.push(`} while (${renderCondition(cond)})${headSettings(settings)}`); + lines.push( + `} while (${renderCondition(backEdge)})${headSettings(settings)}`, + ); return this.emitRoutes(loopId, rest, stop, lines, depth); } /** - * The routes of `outs` the caller has not spent on its own pattern, marked - * printed here so a jump landing on the loop head while the body is walked - * reads the same routing it did before. {@link emitRoutes} takes them from - * the position the loop leaves off at, which is where the model takes them. + * The routes the loop pattern did not spend, marked printed before the body + * is walked so a jump landing on the head reads the same routing it did + * before; {@link emitRoutes} takes them where the loop leaves off. */ private takeRest(outs: SequenceFlow[]): SequenceFlow[] { const rest = this.unconsumed(outs); @@ -1421,17 +1324,11 @@ class Emitter { } /** - * A clean fork prints `parallel { { } { } }` with both gateways elided. A - * branch terminating before the join still prints that way, resuming after - * the join the survivors share ({@link recoveredForkJoin}). - * - * A fork whose branches carry conditions prints the same block with a head on - * each branch. One rule reads it back: a condition on any branch means the - * branches are weighed one by one, none anywhere means they all run. - * - * Keyed on the split's id and kind rather than on a gateway: a step whose - * own routes split reaches the same block with the kind its routes give it, - * and its head settings are none. + * Both fork kinds print one `parallel` block: a head on any branch reads + * back as the fork that weighs its branches, none anywhere as the one that + * opens them all. Keyed on the split's id and kind rather than on a gateway: + * a step whose own routes split reaches the same block with the kind its + * routes give it, and its head settings are none. */ private emitFork( splitId: string, @@ -1499,11 +1396,9 @@ class Emitter { } /** - * `else `, `if (c) ` or nothing. The fallback heads its branch as the - * fallback whatever else it carries: a fork takes it when no other branch was - * taken, and a condition written on it is weighed nowhere, so printing that - * condition would put the branch back on a run of its own. A fork that weighs - * nothing heads no branch at all. + * The fallback heads its branch as `else` whatever it carries: the fork + * weighs no condition on it, and printing one would put the branch on a run + * of its own. */ private branchHead(fork: Fork, flow: SequenceFlow): string { if (fork.kind !== 'inclusiveGateway') return ''; @@ -1514,9 +1409,8 @@ class Emitter { } /** - * The fallback edge that goes nowhere but the merge, which prints as nothing. - * A condition on it is weighed nowhere, so it leaves the edge implicit all - * the same; {@link warnFallbackCondition} reports the condition either way. + * A condition on it changes nothing, the fork weighing none on its fallback, + * so it stays implicit; {@link warnFallbackCondition} reports the condition. */ private isImplicitFallback( fork: Fork, @@ -1531,12 +1425,6 @@ class Emitter { } /** - * An unconditioned route is taken whatever the conditions do, so only a split - * whose every route is weighed and which names none to take when none holds - * can be left with nowhere to go. The printed block falls through once its - * branches are done, handing that split a fallback it did not have, which is - * a change in what runs. - * * Read off the model rather than from the caller's list: the engine weighs * every route the element has, so one an enclosing construct has already * printed keeps the element from running out of routes all the same. @@ -1564,10 +1452,8 @@ class Emitter { } /** - * The same route as the flow it is, and `undefined` for a split that names - * one it has no route for. The engine looks the name up among the routes the - * split has and raises where it finds none, so a name alone is not a route - * the model can take. + * `undefined` for a split naming a route it does not have: the engine looks + * the name up among the split's routes and raises where it finds none. */ private splitFallbackFlow(splitId: string): SequenceFlow | undefined { const fallbackId = this.splitFallbackFlowId(splitId); @@ -1577,16 +1463,10 @@ class Emitter { } /** - * The mirror of {@link warnInventedFallback}: a fork whose fallback nothing - * can reach, because a branch beside it carries no condition and so runs - * whatever the conditions do. {@link branchHead} writes that fallback as an - * `else`, which the validator refuses for the same reason, and the model is - * where the refusal comes from rather than the printing, so the fallback is - * written out and reported instead of dropped. - * - * Reads the branches that print, after {@link isImplicitFallback} has taken - * out the fallback going nowhere but the merge: that one leaves no `else` - * behind and so nothing to report. + * The mirror of {@link warnInventedFallback}: a fallback nothing can reach, + * written out as an `else` the validator refuses, since the model is where + * the refusal comes from. Reads the branches that print: a fallback going + * nowhere but the merge leaves no `else` behind and nothing to report. */ private warnDeadFallback(fork: Fork, branches: SequenceFlow[]): void { if (fork.kind !== 'inclusiveGateway') return; @@ -1600,12 +1480,10 @@ class Emitter { } /** - * A condition on the fallback itself, reported once per split off the - * model's routes, whatever the fallback ends up printed as: the `else` of a - * chain or a block, the plain route on, or nothing. A choice weighs its - * fallback among the others and the engine refuses to deploy one carrying a - * condition there; a fork and a step skip it while weighing and take it when - * nothing held, so the condition is weighed nowhere and the run is the same. + * Reported once per split off the model's routes, however the fallback + * prints. A choice weighs its fallback among the others and the engine + * refuses to deploy one carrying a condition; a fork and a step skip it while + * weighing, so there the condition is weighed nowhere. */ private warnFallbackCondition(splitId: string): void { if (this.splitFallbackFlow(splitId)?.conditionExpression === undefined) { @@ -1619,11 +1497,9 @@ class Emitter { } /** - * A condition on a branch of a fork that opens every branch. The engine - * reads no condition here, and {@link branchHead} writes none, because a - * condition written on a branch is what tells a fork that weighs its - * branches from one that does not: printing it would read back as the other - * fork. The condition is dropped there, and reported here. + * Dropped by {@link branchHead}: a condition on a branch is what tells the + * weighing fork from the opening one, so printing it would read back as the + * other. */ private warnUnweighedBranchCondition(fork: Fork, outs: SequenceFlow[]): void { if (fork.kind !== 'parallelGateway') return; @@ -1643,14 +1519,14 @@ class Emitter { } /** - * When a branch terminates at a `throw` or `end` the fork has no clean - * post-dominator, but the survivors still reconverge at a real merge of kind + * With a branch ending at a `throw` or `end` the fork has no clean + * post-dominator, but the survivors still reconverge at a merge of * `joinKind`. Each branch contributes, nearest first, the fork-dominated - * gateways of that kind on its post-dominator chain. Branches of one fork - * share no node before reconverging, so the first candidate common to every - * chain is the join, and the nearest keeps the continuation at this fork - * rather than a sibling's nested block. The `cur !== forkId` guard rejects a - * back-edge fork that post-dominates itself. + * merges of that kind on its post-dominator chain; branches of one fork share + * no node before reconverging, so the first candidate common to every chain + * is the join, and nearest keeps the continuation at this fork rather than in + * a sibling's nested block. `cur !== forkId` rejects a back-edge fork that + * post-dominates itself. */ private recoveredForkJoin( forkId: string, @@ -1685,11 +1561,10 @@ class Emitter { } /** - * `await { { } { } }`: every branch opens on a wait, and the - * first to resolve cancels the rest. The pattern is keyed on the waits alone, - * so it still holds with no merge to return to, which is the shape a race - * whose every branch ends takes. A branch opening on anything else is not a - * race at all and degrades, every edge keeping a jump or a marker. + * Every branch opens on a wait, and the pattern is keyed on the waits alone, + * so it holds with no merge to return to, the shape of a race whose every + * branch ends. A branch opening on anything else degrades, every edge keeping + * a jump or a marker. */ private emitRaceGateway( race: Extract, @@ -1736,8 +1611,8 @@ class Emitter { this.emittedNodes.add(el.id); const branchLines: string[] = []; if (body !== undefined) { - // The wait leads straight into its body with nothing written between - // them, so the edge goes the way a fall-through does. + // Nothing is written between the wait and its body, so `consume` + // reports a condition on the edge as on a fall-through. this.consume(body); this.emitBranch(body.targetRef, join, branchLines, depth, outs[i]!.id); } @@ -1757,10 +1632,9 @@ class Emitter { } /** - * The wait a race branch opens on and the edge into its body, or `undefined` - * when the branch is not a wait the block form can hold: an already-printed - * one would be printed twice, and one that routes on has no single body. - * Reads only, so a sibling branch missing costs nothing. + * `undefined` where the branch is not a wait the block can hold: an + * already-printed one would print twice, and one that splits has no single + * body. Reads only, so a sibling missing costs nothing. */ private raceWait(target: string): RaceWait | undefined { const el = this.byId.get(target); @@ -1772,11 +1646,9 @@ class Emitter { } /** - * Marks a route printed as plain flow, one statement leading to the next - * with nothing written between them, and reports a condition on it: there is - * no place between the two statements to write one. Every route the script - * writes that way goes through here, so the report cannot be forgotten at one - * of them. + * A route printed as plain flow, with no place between the two statements + * for its condition, which is reported. Every such route goes through here, + * so the report cannot be forgotten at one of them. */ private consume(flow: SequenceFlow): void { this.consumedFlows.add(flow.id); @@ -1785,7 +1657,6 @@ class Emitter { if (warning !== undefined) this.warnings.push(warning); } - /** The single route on from a position, taken as the fall-through. */ private takeFallThrough( flow: SequenceFlow, stop: string | undefined, @@ -1796,17 +1667,12 @@ class Emitter { } /** - * The report a dropped condition takes, which turns on what reads it. A fork - * that opens every route and a wait that takes the first to resolve both - * weigh it nowhere, so the run is the same without it; everywhere else the - * engine reads it and the run is not. - * - * The route a split names as its fallback is neither: the split's own report - * ({@link warnFallbackCondition}) covers it however it prints. - * - * A route beside a fallback the split weighs nothing on is neither again: the - * model has that fallback left to leave by, so the drop reads as a divergence - * rather than as the failure a route without one takes. + * Which report a dropped condition takes turns on what reads it: a fork that + * opens every route and a wait weigh it nowhere, everywhere else the engine + * reads it. The fallback itself is covered by {@link warnFallbackCondition}. + * A route beside an unconditioned fallback diverts the run rather than + * failing it, since the model has the fallback left to leave by; a fallback + * the split weighs is reported on the split and does not count. */ private droppedConditionWarning( flow: SequenceFlow, @@ -1820,8 +1686,6 @@ class Emitter { case 'eventBasedGateway': return raceConditionWarning(sourceId); default: { - // A fallback the split weighs is a different shape, reported on the - // split in its own right, so only a plain one answers here. const fallback = this.splitFallbackFlow(sourceId); return fallback !== undefined && fallback.conditionExpression === undefined @@ -1832,11 +1696,9 @@ class Emitter { } /** - * The arrival rule every fall-through follows. Returning a merge node does - * not elide it: a synthesized join gateway has one remaining out-edge once - * the branch edges are consumed, so `emitNode` passes through it and prints - * nothing, reproducing the desugarer's elision, while a real node that - * happens to be the merge point prints its normal statement. + * Returning a merge does not elide it: a synthesized join has one out-edge + * left once the branch edges are consumed, so `emitNode` passes through it + * and prints nothing, while a real node at the merge prints its statement. */ private continueAt( target: string, @@ -1868,16 +1730,15 @@ class Emitter { } /** - * The first node with a statement form, the only kind a `goto` can name. A + * The first node with a statement form, the only kind a `goto` can name: a * jump into a gateway re-runs its routing, so it means the same as a jump at * the successor whenever that routing has one outcome. The walk crosses the - * single unrealized out-edge, and once all are realized, the sole out-edge of - * a gateway that never had a choice: consumption records that an edge printed - * as structured flow, not that it stopped existing. - * - * `undefined` for a routing with more than one outcome, a revisited gateway, - * or a node the emitter drops on print. An unknown id is named verbatim, - * being a dangling IR reference. + * single unrealized out-edge, or once all are realized the sole out-edge of a + * gateway that never had a choice, since consumption records that an edge + * printed as structured flow, not that it stopped existing. `undefined` for + * a routing with more than one outcome, a revisited gateway, or a node the + * emitter drops on print; an unknown id is named verbatim, being a dangling + * IR reference. */ private forwardToRealTarget( target: string, @@ -1943,29 +1804,16 @@ class Emitter { ); } case 'intermediateThrowEvent': { - // Only escalation, signal, message, compensation, and link are - // emittable: an error aborts its path (`throw error`) and the rest - // have no throw surface. - const def = el.eventDefinition; - const settings = [...throwBindingSettings(el), ...jobSettingItems(el)]; - switch (def.kind) { - case 'escalation': - case 'signal': - case 'message': - case 'compensation': - case 'link': { - const trigger = renderTrigger(def, this.names); - return bracketed( - `emit ${trigger.head}${this.terminalNameSuffix(el)}`, - [...trigger.items, ...settings], - structuredMembers(el), - ); - } - default: - throw new Error( - `irToDsl: intermediate throw '${el.id}' carries a ${def.kind} definition; only escalation, signal, message, compensation, or link can be emitted.`, - ); - } + const trigger = renderTrigger(el.eventDefinition, this.names); + return bracketed( + `emit ${trigger.head}${this.terminalNameSuffix(el)}`, + [ + ...trigger.items, + ...throwBindingSettings(el), + ...jobSettingItems(el), + ], + structuredMembers(el), + ); } case 'intermediateCatchEvent': { const trigger = renderTrigger(el.eventDefinition, this.names); @@ -1989,19 +1837,17 @@ class Emitter { return renderReceiveTask(el, this.names); case 'callActivity': return renderCallActivity(el, this.names); - // These print as a line group in `emitNode`/`emitBoundaryHandler`, and are - // listed so the type checker still catches a new kind. case 'scriptTask': - return undefined; + return renderScriptTask(el, this.names); + // Printed by `emitNode` (a gateway as a construct, a sub-process and a + // boundary as a block); listed so the type checker still catches a new + // kind. case 'subProcess': - return undefined; case 'boundaryEvent': - return undefined; case 'exclusiveGateway': case 'parallelGateway': case 'inclusiveGateway': case 'eventBasedGateway': - // An unrecognized gateway emits nothing; its edges become gotos. return undefined; default: { const exhaustive: never = el; @@ -2025,7 +1871,6 @@ interface Fork { fallbackId: string | undefined; } -/** The word the marker names a lost split by. */ const SPLIT_KIND_WORD: Record = { exclusiveGateway: 'exclusive', parallelGateway: 'parallel', @@ -2041,8 +1886,8 @@ interface RaceWait { /** * Printed where an edge the emitter can neither name nor place would have gone, - * instead of fabricating a target. It stays in the source as the reader's - * pointer at the place needing repair; the warning below is the report. + * instead of fabricating a target; the warning is the report, the marker the + * reader's pointer at the place needing repair. */ export const UNSTRUCTURED_MARKER = '// unstructured region: hand-repair required'; @@ -2067,11 +1912,8 @@ function droppedEdgeWarning(target: string): PrintWarning { } /** - * A split whose branches the catalog cannot fold keeps its edges as jumps, - * each under the condition it carried, and loses the split itself: the marker - * line above the jumps names it. An edge with no name to jump to takes the - * marker {@link droppedEdgeWarning} reports instead, which is why this one - * counts no branches. + * An edge with no name to jump to takes {@link droppedEdgeWarning}'s marker + * instead, which is why this one counts no branches. */ function degradedSplitWarning(splitId: string): PrintWarning { return { @@ -2089,18 +1931,11 @@ function degradedSplitWarning(splitId: string): PrintWarning { }; } -/** - * Where {@link degradedSplitWarning} names a split whose branches the script - * cannot fold, this names one with nothing to fold: the split prints as - * nothing and the run ends where the script's block does. What the model does - * there depends on the kind, and the sentence says which. - */ function emptySplitWarning(kind: Gateway['kind'], id: string): PrintWarning { const model = { - // `ParallelGatewayActivityBehavior.execute` leaves by no route, and - // `PvmExecutionImpl.leaveActivityViaTransitions` ends the execution. At - // top level that is the whole run; inside a fork's branch it is that - // branch alone, and the join it never reaches keeps the fork waiting. + // `ParallelGatewayActivityBehavior.execute` leaves by no route and + // `PvmExecutionImpl.leaveActivityViaTransitions` ends the execution: the + // whole run at top level, one branch inside a fork. parallelGateway: "The engine ends the run here too, so outside a fork's branch the " + 'process runs the same without it; inside one, only that branch ends ' + @@ -2128,12 +1963,7 @@ function emptySplitWarning(kind: Gateway['kind'], id: string): PrintWarning { }; } -/** - * A jump printed outside the `await`/`parallel` branch its target was printed - * in. The validator refuses it, since a branch's steps run only when the whole - * block is reached, and the model is where the route comes from, so the jump - * is written and reported rather than dropped. - */ +/** Written and reported rather than dropped: the model is where the route comes from. */ function crossBranchJumpWarning(target: string): PrintWarning { return { elementId: target, @@ -2173,12 +2003,6 @@ function raceConditionWarning(raceId: string): PrintWarning { }; } -/** - * A condition the engine weighs, on a route the script writes as the plain - * step-to-step fall-through. Unlike the conditions a wait or an unweighing - * fork carries, this one changes what runs when it is left out, which is why - * it reads nothing like {@link raceConditionWarning}. - */ function droppedFlowConditionWarning(sourceId: string): PrintWarning { return { elementId: sourceId, @@ -2194,13 +2018,6 @@ function droppedFlowConditionWarning(sourceId: string): PrintWarning { }; } -/** - * The same condition on a route out of a split that names the route to take - * when none of its conditions holds. The engine takes no route it weighs - * false, and here it has the fallback left, so the model routes on where - * {@link droppedFlowConditionWarning} says it fails. It takes a route the - * script does not, with nothing raised to mark the difference. - */ function divertedRunWarning(splitId: string): PrintWarning { return { elementId: splitId, @@ -2232,11 +2049,6 @@ function unweighedBranchWarning(forkId: string): PrintWarning { }; } -/** - * A condition on the fallback of a fork that opens every branch whose condition - * holds. It takes the fallback only when it took no branch, and reads no - * condition on the fallback while choosing, so the condition changes nothing. - */ function forkFallbackConditionWarning(forkId: string): PrintWarning { return { elementId: forkId, @@ -2249,13 +2061,6 @@ function forkFallbackConditionWarning(forkId: string): PrintWarning { }; } -/** - * The same condition on the fallback of a choice, which takes one route and - * weighs the fallback among the others rather than holding it back. A model - * carrying one there is refused at deployment, so where - * {@link forkFallbackConditionWarning} reports a condition that changes - * nothing, this reports a model that never gets to run. - */ function choiceFallbackConditionWarning(splitId: string): PrintWarning { return { elementId: splitId, @@ -2295,18 +2100,13 @@ function droppedSettingWarning(gatewayId: string): PrintWarning { }; } -/** - * A pathological IR degrades to a `goto` rather than overflowing the stack. - * Unpinned: reaching it takes an IR nested a thousand blocks deep, which no - * fixture builds. - */ +/** A pathological IR degrades to a `goto` rather than overflowing the stack. */ const MAX_NESTING_DEPTH = 1000; /** - * In print order, after the label and documentation {@link namedSettings} - * writes. The IR field name is also the DSL key. Exported so a test can hold - * the pair against `PROCESS_HEADER_KEYS`: a key the vocabulary gains and this - * table does not would print nothing and round-trip as a silent drop. + * In print order after {@link namedSettings}; the IR field name is the DSL key. + * Exported so a test can hold it against `PROCESS_HEADER_KEYS`, since a key the + * vocabulary gains and this table lacks would round-trip with no report. */ export const PROCESS_HEADER_SETTINGS = [ ['versionTag', quoteLiteral], @@ -2338,11 +2138,7 @@ function isBoundary( return el.kind === 'boundaryEvent'; } -/** - * The definitions an `end` statement spells in its own head rather than - * raising, one per word the surface takes there. Both keep the label a `throw` - * would drop, and the word each prints is its kind. - */ +/** The definitions an `end` spells in its own head rather than raising; the word printed is the kind. */ const END_CARRIED_KINDS = new Set(END_TRIGGERS); /** Read by the print and by the elision, so the two cannot drift. */ @@ -2354,18 +2150,14 @@ function isEndCarried( /** * Whether the id is absent from the printed form, leaving a `goto` nothing to - * resolve against. `xmlToIr` asks the same question to report the label an + * resolve against; `xmlToIr` asks the same question to report the label an * elided start or end takes with it, so the two answers cannot drift apart. - * - * For a plain end the answer is only that the printer may drop it; `emitNode` - * and `Emitter.emit` decide by position whether it does. `forwardToRealTarget` - * reads this half alone, so a jump into such an end is dropped and marked - * whether or not the end ends up printed. - * - * `container` is the one the element sits in, whose id the minted start and - * end carry. The compiler re-derives a dropped start at the body's head and - * nowhere else, so only the first plain start under the minted id can go: a - * second one prints under its own id. + * For a plain end the answer is only that the printer may drop it, `emitNode` + * and `Emitter.emit` deciding by position whether it does, and + * `forwardToRealTarget` reads this half alone, so a jump into such an end is + * dropped and marked whether or not the end ends up printed. The compiler + * re-derives a dropped start at the body's head and nowhere else, so only the + * first plain start under `container`'s minted id can go. */ export function isElidedOnPrint( el: FlowElement, @@ -2390,10 +2182,8 @@ export function isElidedOnPrint( // Every other definition prints as a `throw`, which drops a synthesized name. return !isEndCarried(el.eventDefinition); case 'intermediateThrowEvent': - // Spells the id through `terminalNameSuffix`, which drops a synthesized one. - return isSynthesizedTerminalId(el.id, el.kind, container); case 'intermediateCatchEvent': - // Spells the id through `terminalNameSuffix`, which drops a synthesized one. + // Spelled through `terminalNameSuffix`, which drops a synthesized id. return isSynthesizedTerminalId(el.id, el.kind, container); case 'boundaryEvent': // Prints as `on : `, keyed on the host. @@ -2412,7 +2202,7 @@ export function isElidedOnPrint( case 'parallelGateway': case 'inclusiveGateway': case 'eventBasedGateway': - // Callers forward past a gateway rather than ask, so this arm is unreachable. + // A gateway never writes its id. return true; default: { const exhaustive: never = el; @@ -2446,14 +2236,11 @@ function isPlainUnnamed( } /** - * A synthesized start or end has no authored name and the grammar's `name=ID` - * is mandatory, so it is dropped unless its block carries content. Every - * reader goes through {@link isElidedOnPrint}: a reason to print reaching one - * but not another would leave a jump naming a statement that never gets - * emitted. - * - * A label is not such a reason: printing the id to carry one writes a name the - * validator rejects, so the label is reported as an import warning instead. + * Every reader goes through {@link isElidedOnPrint}: a reason to print + * reaching one reader but not another would leave a jump naming a statement + * never emitted. A label is not such a reason: printing the id to carry one + * writes a name the validator rejects, so the label is reported on import + * instead. */ function carriesPrintableContent( el: Extract, @@ -2477,30 +2264,24 @@ function structuredMembers(el: SettingsCarrier): Lines[] { return [...ioParameters(el), ...listenerMembers(el)]; } -/** Fixed order, so the parens stay stable across runs; `keyOf` respells each key for a second carrier sharing the parens. */ +/** In the vocabulary's order, so the parens stay stable across runs; `keyOf` respells each key for a second carrier sharing the parens. */ function jobSettingItems( el: JobSettings, keyOf: (key: string) => string = (key) => key, ): string[] { - const settings: string[] = []; - if (el.asyncBefore === true) { - settings.push(setting(keyOf('asyncBefore'), 'true')); - } - if (el.asyncAfter === true) { - settings.push(setting(keyOf('asyncAfter'), 'true')); - } - if (el.exclusive === false) { - settings.push(setting(keyOf('exclusive'), 'false')); - } - if (el.jobPriority !== undefined) { - settings.push( - setting(keyOf('jobPriority'), renderNumericValue(el.jobPriority)), - ); - } - if (el.retryCycle !== undefined) { - settings.push(setting(keyOf('retryCycle'), quote(el.retryCycle))); - } - return settings; + const text: Record = { + asyncBefore: el.asyncBefore === true ? 'true' : undefined, + asyncAfter: el.asyncAfter === true ? 'true' : undefined, + exclusive: el.exclusive === false ? 'false' : undefined, + jobPriority: + el.jobPriority === undefined + ? undefined + : renderNumericValue(el.jobPriority), + retryCycle: el.retryCycle === undefined ? undefined : quote(el.retryCycle), + }; + return ENGINE_KEYS.flatMap((key) => + text[key] === undefined ? [] : [setting(keyOf(key), text[key])], + ); } /** @@ -2583,7 +2364,6 @@ function listenerMembers(el: SettingsCarrier): Lines[] { return members; } -/** A script binding is the one that keeps a body, its fence being where the code goes. */ function renderListener( event: string, binding: ListenerBinding, @@ -2591,7 +2371,7 @@ function renderListener( ): Lines { const clause = timer !== undefined - ? ` ${TIMER_PARTICLE[timer.timerKind]} ${quote(timer.expression)}` + ? ` ${TIMER_PARTICLE_BY_KIND[timer.timerKind]} ${quote(timer.expression)}` : ''; const head = `on ${event}${clause}`; return binding.kind === 'script' @@ -2626,22 +2406,19 @@ function renderCodeBinding(binding: CodeBinding): string { /** * The closing fence sits directly after `code` with no injected newline: the * split on the way in strips only the newline after the language tag, so a - * newline added here would be re-absorbed into the body on re-parse. The result - * carries its own newlines, so a statement holding one is a line group. + * newline added here would be re-absorbed into the body on re-parse. */ function renderFence(format: string, code: string): string { return `\`\`\`${format}\n${code}\`\`\``; } /** - * The validator refuses these in authored source, so an id carrying the form - * its own kind is minted with is synthesized: the exact start and end minted - * for `container` (and for a boundary escape in it), and the `Throw_` and - * `Catch_` prefixes, which are positional and no modeler writes. An id - * carrying another kind's template is an authored name and has to keep - * printing. An end answers to the throw prefix as well, because `throw` - * lowers to an end event; a catch answers to its own prefix only, since - * nothing else lowers to one. + * An id carrying the form its own kind is minted with, which the validator + * refuses in authored source: the exact start and end minted for `container` + * (and for a boundary escape in it), and the positional `Throw_`/`Catch_` + * prefixes no modeler writes. An id carrying another kind's template is an + * authored name and keeps printing; an end answers to the throw prefix too, + * since `throw` lowers to an end event, and a catch to its own prefix only. */ function isSynthesizedTerminalId( id: string, @@ -2700,9 +2477,9 @@ function renderThrow( /** * Engine attributes come off the sub-process, plus the timer job's off the - * start when that start prints no statement: the compiler puts the three on - * the start a timer head synthesizes ({@link timerJob}), and a start that - * prints keeps them on its own line. + * start when that start prints no statement: the compiler puts them on the + * start a timer head synthesizes ({@link splitTimerJobSettings}), and a start + * that prints keeps them on its own line. */ function buildOnHeader( handler: Extract, @@ -2719,7 +2496,7 @@ function buildOnHeader( } const trigger = renderTrigger(start.eventDefinition, names); const lifted = isElidedOnPrint(start, handler, true) - ? jobSettingItems(timerJob(start)) + ? jobSettingItems(splitTimerJobSettings(start).timer) : []; return bodyHeader( `on ${trigger.head}`, @@ -2733,17 +2510,6 @@ function buildOnHeader( ); } -/** - * The settings of the job a timer start declares, which an `on timer` head - * authors for the start it synthesizes ({@link splitTimerJobSettings}); the - * async flags are the sub-process's own. - */ -function timerJob( - start: Extract, -): JobSettings { - return splitTimerJobSettings(start).timer; -} - /** `attachedToRef` prints under the host's name; refusing a bad host belongs to validation. */ function buildBoundaryHeader( boundary: Extract, @@ -2761,18 +2527,11 @@ function buildBoundaryHeader( ); } -/** The vocabulary's table, narrowed so a new IR timer kind without a word fails to compile. */ -const TIMER_PARTICLE: Record< - Extract['timerKind'], - string -> = TIMER_PARTICLE_BY_KIND; - /** - * The trigger clause: the word it opens on, and the items its parens lead with. - * The payload leads them, so a name, a code and a duration are written the same - * way wherever a trigger is; a catch binding follows as a setting naming the - * variable the event data lands in, and the element's own settings after that. - * A condition prints unquoted, being an expression rather than text. + * The payload leads the items, so a name, a code and a duration are written + * the same way wherever a trigger is; a catch binding follows, and the + * element's own settings after that. A condition prints unquoted, being an + * expression rather than text. */ function renderTrigger( def: EventDefinition, @@ -2835,8 +2594,7 @@ function payloadItem(code: string | undefined): string[] { /** * A code is written as the name of the declaration carrying it, which is what - * lets `error OrderFailed(code: "order.failed")` be raised by a word. Absent - * for a catch-all, as {@link payloadItem} is. + * lets `error OrderFailed(code: "order.failed")` be raised by a word. */ function codeItem( names: ReadonlyMap, @@ -2853,7 +2611,7 @@ function renderTimerTrigger(def: Extract): { head: string; items: string[]; } { - const particle = TIMER_PARTICLE[def.timerKind]; + const particle = TIMER_PARTICLE_BY_KIND[def.timerKind]; const time = quote(def.expression); return { head: 'timer', @@ -2875,10 +2633,7 @@ function eventBindingSettings( return parts; } -/** - * The flag saying a handler leaves its scope running. A flag is bare and closes - * the parens, after the payload and the settings. - */ +/** A flag is bare and closes the parens, after the payload and the settings. */ function alongsideFlag(nonInterrupting: boolean): string[] { return nonInterrupting ? ['alongside'] : []; } @@ -2913,9 +2668,15 @@ function renderUserTask( names: PrintNames, ): Lines { const settings = namedSettings(el); - for (const [key, render] of USER_TASK_SETTINGS) { + for (const key of USER_TASK_VERBATIM_KEYS) { const value = el[key]; - if (value !== undefined) settings.push(setting(key, render(value))); + if (value === undefined) continue; + settings.push( + setting( + key, + key === 'priority' ? renderNumericValue(value) : quote(value), + ), + ); } if (el.formRef !== undefined) { settings.push( @@ -2933,22 +2694,7 @@ function renderUserTask( ); } -/** In print order. The IR field name is also the DSL keyword. */ -const USER_TASK_SETTINGS = [ - ['assignee', quote], - ['formKey', quote], - ['candidateGroups', quote], - ['candidateUsers', quote], - ['dueDate', quote], - ['followUpDate', quote], - ['priority', renderNumericValue], -] as const; - -/** - * One field per line, or a block of its own where a field carries extras: a - * form is a member list, so it prints that way itself. The braces are written - * even with no fields, `form` alone being no rule. - */ +/** The braces are written even with no fields, `form` alone being no rule. */ function renderFormBlock(formFields: FormField[]): Lines { return [ 'form {', @@ -3013,10 +2759,6 @@ function fieldSettings(field: FormField): string[] { return settings; } -/** - * A flag writes the literal `true`; a bound prints as `renderNumericValue` - * prints a priority; `validator` is always quoted, like `class:`. - */ function renderFormConstraint(constraint: FormFieldConstraint): string { switch (constraint.name) { case 'required': @@ -3054,11 +2796,7 @@ function renderFormValue(value: FormFieldValue): string { : `${value.id} ${quoteLiteral(value.label)}`; } -/** - * `property = ` lines, of a form field's block or an external - * task's. `quote`, not `quoteLiteral`: a `${...}` value has to re-lex as a - * raw expression so it lowers back to the same text. - */ +/** `quote`, not `quoteLiteral`: a `${...}` value has to re-lex as a raw expression. */ function propertyMembers(properties: ExtensionProperty[] | undefined): Lines[] { return (properties ?? []).map((p) => [ `property ${p.key} = ${quote(p.value)}`, @@ -3133,7 +2871,6 @@ function renderServiceTask( ); } -/** The settings spelling out an execution binding, whatever carries it. */ function bindingSettings(binding: ServiceTaskBinding): string[] { switch (binding.kind) { case 'class': @@ -3210,11 +2947,9 @@ function renderCallActivity( settings.push(setting('businessKey', quote(el.businessKey))); } - // After `businessKey` and before the engine settings, which is the order - // `CallActivity.own` offers the keys in. Both values print through - // `quote()`: the delegate body has to re-lex as an expression, and - // `quoteLiteral` escapes a leading `${` to stop exactly that; the class - // prints through `quote()` like every other class binding. + // Between `businessKey` and the engine settings, the order `CallActivity.own` + // offers the keys in. Both print through `quote()`: the delegate body has to + // re-lex as an expression, which `quoteLiteral` would escape. if (el.mapper !== undefined) { settings.push( setting( @@ -3311,11 +3046,7 @@ function renderScriptTask( return lines; } -/** - * The human-facing text an element carries, leading the keyed settings - * wherever it carries any. The two travel together so a surface cannot gain - * one and forget the other. - */ +/** The two travel together, so a surface cannot gain one and forget the other. */ function namedSettings(el: Named): string[] { return [ ...(el.name === undefined ? [] : [setting('label', quoteLiteral(el.name))]), @@ -3325,7 +3056,6 @@ function namedSettings(el: Named): string[] { ]; } -/** The one spelling a setting takes; a payload and a flag are written without a key. */ function setting(key: string, value: string): string { return `${key}: ${value}`; } @@ -3349,7 +3079,6 @@ function bodyHeader(head: string, settings: string[], members: Lines[]): Lines { return lines; } -/** One member per line inside the braces, or no braces at all. */ function withMembers(head: string, members: Lines[]): Lines { if (members.length === 0) return [head]; return [`${head} {`, ...members.flat().map((line) => INDENT + line), '}']; @@ -3367,23 +3096,15 @@ function headSettings(settings: string[]): string { /** A literal count prints bare; anything else is an expression. */ export const BARE_CARDINALITY = /^\d+$/; -/** - * The grammar's `ID` terminal. The clause writes the name each run sees bare - * and has no other form for it, so this is also the test the import direction - * refuses by. A collection spelled as a name prints bare by the same test; - * anything else is an expression. - */ +/** The grammar's `ID` terminal: an element variable and a bare collection print by it, and the import refuses by it. */ export const BARE_ELEMENT_VARIABLE = ID_SHAPED; /** - * The name every code is raised by, one map per kind because the two are - * declared separately, and the header lines that give each code that name. - * - * A code nothing declares still gets a declaration, exactly as `irToXml` still - * gives it a root: a use site is a bare name and a name with nothing to resolve - * to is an error, so a hand-built IR would otherwise print source that does not - * compile. Names are claimed across both kinds at once, since a use site - * resolves in one scope holding all of them. + * One map per kind, since the two are declared apart, and names claimed across + * both, since a use site resolves in one scope holding all of them. A code + * nothing declares still gets a declaration, as `irToXml` still gives it a + * root: a use site is a bare name, and a hand-built IR would otherwise print + * source that does not compile. */ function codeDeclarations(process: BpmnProcess): { lines: string[]; @@ -3430,18 +3151,15 @@ function codeDeclarations(process: BpmnProcess): { /** * A `var : any` line per variable the body reads bare and nothing - * types, in first-appearance order over one fixed walk. BPMN has no slot for - * a declaration, so the print declares what it writes in a variable position: - * the roots of a condition, a count, an `until`, an error mapping's `when`, a - * conditional trigger, an `in` source and a bare collection, which are the - * positions the validator reads a variable at, minus what its symbol table - * already holds (`DefaultVariableSymbolProvider.collect`): a form field, a - * catch binding, an io parameter name, an element variable, and the engine's - * loop counters once anything repeats. `externalTask` inside a mapping and an - * `out` source are exempt there too, the one evaluated on the external task - * and the other in the called process. The type is `any` because a read says - * nothing more, and every declaration of one name has to agree on the type, - * so a second line for a typed name would be an error rather than a duplicate. + * types, in first-appearance order. BPMN has no slot for a declaration, so the + * print declares what it writes in a variable position (a condition, a count, + * an `until`, an error mapping's `when`, a conditional trigger, an `in` source, + * a bare collection) minus what the validator's symbol table already holds + * (`DefaultVariableSymbolProvider.collect`: a form field, a catch binding, an + * io parameter name, an element variable, and the loop counters once anything + * repeats); `externalTask` in a mapping and an `out` source are exempt there + * too. The type is `any` because a read says nothing more, and every + * declaration of one name has to agree on the type. */ function variableDecls(process: BpmnProcess): string[] { const reads = new Set(); @@ -3545,10 +3263,9 @@ function* varRoots(node: JuelNode): Generator { } /** - * The repeat clause of an activity, with a leading space, or the empty string - * when it runs once. A collection spelled as a plain name prints bare and - * anything else quotes, because Operaton reads a bare `operaton:collection` as - * the name of a variable and only a `${...}` body as an expression. + * A collection spelled as a plain name prints bare and anything else quotes, + * because Operaton reads a bare `operaton:collection` as the name of a + * variable and only a `${...}` body as an expression. */ function repeatClause(el: Repeatable): string { const loop = el.loop; @@ -3587,26 +3304,21 @@ function renderRawCondition(body: string): string { } /** - * The quoting for a value the engine evaluates, which a body opening with - * `${` or `#{` has to keep re-lexing as: an expression, a delegate, a timer - * body, an io value, a collection. The grammar reads a raw template with the - * same escapes as a literal, so one escaper serves both and this is its exact - * inverse. Prose and a declared name take {@link quoteLiteral}. + * For a value the engine evaluates, which a body opening with `${` or `#{` + * has to keep re-lexing as. The grammar reads a raw template with the same + * escapes as a literal, so one escaper serves both; prose and a declared name + * take {@link quoteLiteral}. */ function quote(value: string): string { return `"${escapeQuoted(value)}"`; } /** - * The quoting for a value that has to come back byte for byte as text: a - * label, documentation, a version tag, a form field label, a map key, a - * declared code, an error message. - * - * A body opening with `${` or `#{` gets a backslash before the opener, so it - * lexes as a `STRING` rather than as a raw template. `\$` and `\#` are not - * recognized escapes and the reader hands the character back unchanged, which - * keeps the two exact inverses. Only that opening earns the backslash: an - * escape no terminal asks for is noise in source somebody reads. + * For a value that has to come back byte for byte as text: a label, a form + * field label, a map key, a declared code. A body opening with `${` or `#{` + * gets a backslash before the opener so it lexes as a `STRING` rather than a + * raw template; `\$` and `\#` are not recognized escapes and the reader hands + * the character back unchanged, which keeps the two exact inverses. */ function quoteLiteral(value: string): string { const escaped = escapeQuoted(value); diff --git a/packages/transform/src/ir-to-xml.ts b/packages/transform/src/ir-to-xml.ts index 4acd070..8908aca 100644 --- a/packages/transform/src/ir-to-xml.ts +++ b/packages/transform/src/ir-to-xml.ts @@ -14,14 +14,19 @@ import { existsSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; -import { EXPRESSION_OPEN } from '@bpmn-script/language'; +import { + ENGINE_KEYS, + EXPRESSION_OPEN, + type TimerKind, + USER_TASK_VERBATIM_KEYS, +} from '@bpmn-script/language'; import { BpmnModdle, type BpmnModdleInstance, type ModdleElement, } from 'bpmn-moddle'; -// Pinned to 1.x: 0.3.x pulls `bpmn-moddle@^8`, which collides with the -// `bpmn-moddle@^10` this package locks, and exposes a constructor API instead. +// Pinned to 1.x: 0.3.x pulls `bpmn-moddle@^8`, which collides with the `^10` +// locked here. import { layoutProcess } from 'bpmn-auto-layout'; import { humanize } from './humanize.js'; @@ -32,6 +37,7 @@ import type { CallActivity, CallVariableMapper, CallVariableMapping, + CodeBinding, ErrorMapping, EventDefinition, ExecutionListener, @@ -61,11 +67,9 @@ import { } from './ir/types.js'; /** - * `BpmnParse.parseDefinitionsAttributes` stores this as `this.targetNamespace`, - * `parseProcess` sets it as the process definition's category, `parseMessages` - * and `parseSignals` prefix every message and signal id with it, and - * `resolveName` prefixes an unqualified `messageRef` or `signalRef` the same - * way. None of that dereferences it as a URI; the engine never fetches it. + * The engine stores this as the process definition's category and prefixes + * every message and signal id with it (`BpmnParse.parseProcess`, + * `parseMessages`, `parseSignals`); it never fetches it as a URI. */ const TARGET_NAMESPACE = 'http://bpmnscript.io/processes'; @@ -105,14 +109,11 @@ function resolveOperatonModdlePath(): string { * Both directions of the XML boundary build their moddle here, so the read and * write paths stay symmetric. * - * `camunda:` stays unregistered: the importer rewrites the camunda namespace - * URI to the operaton one before this moddle instance ever reads the text (see - * `xmlToIr`), so no camunda-bound element or attribute reaches `fromXML` under - * a foreign prefix at all. Registering `camunda-bpmn-moddle` here besides would - * collide: it defines the same property names (`class`, `assignee`, `formKey`, - * `historyTimeToLive`) on the same BPMN types as the Operaton fork, and moddle - * would refuse both with `property <...> already defined; ... not allowed - * without redefines`. + * `camunda:` stays unregistered: `xmlToIr` rewrites the camunda namespace URI + * to the operaton one before `fromXML` reads the text, and registering + * `camunda-bpmn-moddle` besides would collide with the Operaton extension on + * `class`, `assignee`, `formKey` and `historyTimeToLive` (moddle refuses a + * property defined twice without `redefines`). */ export function createModdle(): BpmnModdleInstance { return new BpmnModdle({ @@ -178,12 +179,9 @@ export async function irToXml( // with its own formatting. const { xml } = await moddle.toXML(definitions, { format: true }); - // `bpmn-auto-layout` re-parses this string with `moddle.fromXML` internally - // and silently drops anything that draws a warning there (an illegal or - // duplicate id chief among them), serializing whatever survives with no - // indication that the document lost its process. Catching that here, before - // the layouter runs, turns a two-line output with exit 0 into a thrown error - // naming the actual defect. + // `bpmn-auto-layout` re-parses this with `moddle.fromXML` and drops whatever + // draws a warning there (an illegal or duplicate id), serializing the rest + // with no error. Re-reading first turns that into an error naming the defect. const { warnings } = await createModdle().fromXML(xml); if (warnings.length > 0) { throw new Error( @@ -194,15 +192,21 @@ export async function irToXml( try { return await layoutProcess(xml); } catch (cause) { - throw new LayoutError(xml, cause); + // The expansion hint carries no `dc:Bounds` (layout was to add them) and + // `BpmnParse.parseDIBounds` fails the deployment on a bounds-less shape, + // so the fallback drops the diagram. + definitions.diagrams = undefined; + const { xml: xmlWithoutDiagram } = await moddle.toXML(definitions, { + format: true, + }); + throw new LayoutError(xmlWithoutDiagram, cause); } } /** - * BPMN puts these at `bpmn:Definitions` level, deduped by code or name, so one - * identity maps to one element. Map order is emission order. `taken` is every - * id already in the document, so the `bpmn:Definitions` id itself can join the - * same collision check these roots use. + * One `bpmn:Definitions`-level element per code or name, emitted in insertion + * order. `taken` holds every id in the document, so the `bpmn:Definitions` id + * joins the same collision check. */ interface RootElementIndex { errorByCode: Map; @@ -213,12 +217,10 @@ interface RootElementIndex { } /** - * Derive the `bpmn:Error`/`bpmn:Escalation`/`bpmn:Message`/`bpmn:Signal` roots - * from usage: the IR carries codes and names inline and models no roots. A - * declared code not yet seen follows, so a declaration emits its root even when - * nothing raises it, and a code with no declaration still emits one, so a - * hand-built IR produces a document the engine can run. The root's `name` is - * the declared name, which is the code itself wherever a name can spell it. + * The IR carries codes and names inline and models no roots, so they are + * derived from usage plus the declarations: a declaration nothing raises still + * emits its root, and a code with no declaration still emits one, so a + * hand-built IR deploys. */ function synthesizeRootElements( moddle: BpmnModdleInstance, @@ -235,17 +237,14 @@ function synthesizeRootElements( for (const code of errorDecls.keys()) errorCodes.add(code); for (const code of escalationDecls.keys()) escalationCodes.add(code); - // Seeded with every id already in the document so a root id shadows nothing. const taken = new Set(); collectElementIds(process, taken); const errorByCode = new Map(); for (const code of errorCodes) { - const id = resolveCollision(sanitizeRootId('Error_', code), taken); - taken.add(id); const decl = errorDecls.get(code); const attrs: Record = { - id, + id: claimRootId('Error_', code, taken), name: decl?.name ?? code, errorCode: code, }; @@ -257,12 +256,10 @@ function synthesizeRootElements( const escalationByCode = new Map(); for (const code of escalationCodes) { - const id = resolveCollision(sanitizeRootId('Escalation_', code), taken); - taken.add(id); escalationByCode.set( code, moddle.create('bpmn:Escalation', { - id, + id: claimRootId('Escalation_', code, taken), name: escalationDecls.get(code)?.name ?? code, escalationCode: code, }), @@ -298,16 +295,20 @@ function synthesizeNamedRoots( ): Map { const byName = new Map(); for (const name of names) { - const id = resolveCollision(sanitizeRootId(prefix, name), taken); - taken.add(id); + const id = claimRootId(prefix, name, taken); byName.set(name, moddle.create(type, { id, name })); } return byName; } /** Non-id characters become `_`, since the result is an XML ID. */ -function sanitizeRootId(prefix: string, code: string): string { - return prefix + code.replace(/[^A-Za-z0-9_.-]/g, '_'); +function claimRootId(prefix: string, code: string, taken: Set): string { + const id = resolveCollision( + prefix + code.replace(/[^A-Za-z0-9_.-]/g, '_'), + taken, + ); + taken.add(id); + return id; } function collectElementIds(container: FlowContainer, into: Set): void { @@ -319,17 +320,29 @@ function collectElementIds(container: FlowContainer, into: Set): void { for (const flow of container.sequenceFlows) into.add(flow.id); } +/** + * The `operaton:` attributes `parseServiceTaskLike` resolves an implementation + * from, a code binding's kind being its attribute. The import direction reads + * and sweeps by this list. + */ +export const IMPLEMENTATION_ATTRS = [ + 'class', + 'expression', + 'delegateExpression', + 'type', + 'topic', +] as const satisfies readonly (CodeBinding['kind'] | 'type' | 'topic')[]; + /** The attributes Operaton reads an execution binding off, whatever carries it. */ function serviceTaskBindingAttrs( binding: ServiceTaskBinding, ): Record { switch (binding.kind) { case 'class': - return { 'operaton:class': binding.className }; + return { [`operaton:${binding.kind}`]: binding.className }; case 'expression': - return { 'operaton:expression': binding.expression }; case 'delegateExpression': - return { 'operaton:delegateExpression': binding.expression }; + return { [`operaton:${binding.kind}`]: binding.expression }; case 'external': return { 'operaton:type': 'external', @@ -375,13 +388,39 @@ interface DefinitionOwner { exclusive?: false; } -/** No code means no `errorRef`/`escalationRef`: a ref-less definition catches any. */ +/** The tag each definition kind is written with; the import direction derives from it the tags a position may carry. */ +export const EVENT_DEFINITION_TAG = { + error: 'bpmn:ErrorEventDefinition', + escalation: 'bpmn:EscalationEventDefinition', + message: 'bpmn:MessageEventDefinition', + signal: 'bpmn:SignalEventDefinition', + compensation: 'bpmn:CompensateEventDefinition', + timer: 'bpmn:TimerEventDefinition', + conditional: 'bpmn:ConditionalEventDefinition', + terminate: 'bpmn:TerminateEventDefinition', + cancel: 'bpmn:CancelEventDefinition', + link: 'bpmn:LinkEventDefinition', +} as const satisfies Record; + function buildEventDefinition( moddle: BpmnModdleInstance, def: EventDefinition, roots: RootElementIndex, owner: DefinitionOwner = {}, ): ModdleElement { + return moddle.create( + EVENT_DEFINITION_TAG[def.kind], + definitionAttrs(moddle, def, roots, owner), + ); +} + +/** No code means no `errorRef`/`escalationRef`: a ref-less definition catches any. */ +function definitionAttrs( + moddle: BpmnModdleInstance, + def: EventDefinition, + roots: RootElementIndex, + owner: DefinitionOwner, +): Record { switch (def.kind) { case 'error': { const attrs: Record = {}; @@ -394,7 +433,7 @@ function buildEventDefinition( if (def.messageVariable !== undefined) { attrs['operaton:errorMessageVariable'] = def.messageVariable; } - return moddle.create('bpmn:ErrorEventDefinition', attrs); + return attrs; } case 'escalation': { const attrs: Record = {}; @@ -404,44 +443,35 @@ function buildEventDefinition( if (def.codeVariable !== undefined) { attrs['operaton:escalationCodeVariable'] = def.codeVariable; } - return moddle.create('bpmn:EscalationEventDefinition', attrs); + return attrs; } case 'message': - return moddle.create('bpmn:MessageEventDefinition', { + return { messageRef: roots.messageByName.get(def.messageName), ...(owner.binding === undefined ? {} : serviceTaskBindingAttrs(owner.binding)), - }); + }; case 'signal': - return moddle.create('bpmn:SignalEventDefinition', { - signalRef: roots.signalByName.get(def.signalName), - }); - case 'compensation': - return moddle.create('bpmn:CompensateEventDefinition', {}); - case 'timer': { - const expression = moddle.create('bpmn:FormalExpression', { - body: def.expression, - }); - return moddle.create('bpmn:TimerEventDefinition', { - [TIMER_KIND_TO_CHILD[def.timerKind]]: expression, + return { signalRef: roots.signalByName.get(def.signalName) }; + case 'timer': + return { + [TIMER_KIND_TO_CHILD[def.timerKind]]: formalExpression( + moddle, + def.expression, + ), ...(owner.exclusive === false ? { 'operaton:exclusive': false } : {}), - }); - } + }; case 'conditional': - return moddle.create('bpmn:ConditionalEventDefinition', { - condition: moddle.create('bpmn:FormalExpression', { - body: def.condition, - }), - }); - case 'terminate': - return moddle.create('bpmn:TerminateEventDefinition', {}); - case 'cancel': - return moddle.create('bpmn:CancelEventDefinition', {}); + return { condition: formalExpression(moddle, def.condition) }; case 'link': // `source`/`target` are moddle references the engine never reads: it // correlates a throw and a catch by `name` alone, at deploy time. - return moddle.create('bpmn:LinkEventDefinition', { name: def.linkName }); + return { name: def.linkName }; + case 'compensation': + case 'terminate': + case 'cancel': + return {}; default: { const exhaustive: never = def; throw new Error( @@ -462,9 +492,16 @@ function eventDefinitionAttrs( : { eventDefinitions: [buildEventDefinition(moddle, def, roots, owner)] }; } +function formalExpression( + moddle: BpmnModdleInstance, + body: string, +): ModdleElement { + return moddle.create('bpmn:FormalExpression', { body }); +} + /** Read back by the import direction, which inverts it. */ export const TIMER_KIND_TO_CHILD: Record< - Extract['timerKind'], + TimerKind, 'timeDuration' | 'timeDate' | 'timeCycle' > = { duration: 'timeDuration', @@ -473,12 +510,11 @@ export const TIMER_KIND_TO_CHILD: Record< }; /** - * Fed DI-less XML with a `bpmn:SubProcess`, `bpmn-auto-layout` draws a - * collapsed parent and scatters the nested children across the root plane. It - * reads `isExpanded` off a pre-existing `bpmndi:BPMNShape`, recomputing any - * bounds given here, and finds that shape through an id-keyed index, so each - * shape needs an `id` nothing references back. A `bpmn:Transaction` needs the - * same hint. See ADR 0015. + * Without this hint `bpmn-auto-layout` draws a sub-process collapsed and + * scatters its children across the root plane. It reads `isExpanded` off a + * pre-existing `bpmndi:BPMNShape` found through an id-keyed index, so the shape + * needs an `id`; bounds are recomputed. A `bpmn:Transaction` needs the same + * (ADR 0015). */ function buildSubProcessExpansionHint( moddle: BpmnModdleInstance, @@ -511,7 +547,7 @@ function buildSubProcessExpansionHint( function collectSubProcessElements(container: ModdleElement): ModdleElement[] { const result: ModdleElement[] = []; - for (const element of container.flowElements ?? []) { + for (const element of container.flowElements) { if (SUB_PROCESS_LIKE_TAGS.has(element.$type)) { result.push(element); result.push(...collectSubProcessElements(element)); @@ -521,10 +557,9 @@ function collectSubProcessElements(container: ModdleElement): ModdleElement[] { } /** - * Pass 1 creates a moddle element per flow node and sequence flow, keyed by id. - * The three attach passes need moddle objects where the IR carries raw ids, so - * they cannot run until pass 1 has built every node in this container. A nested - * sub-process re-enters here through {@link createFlowNode} with its own maps. + * The attach passes need moddle objects where the IR carries raw ids, so every + * node and flow of the container is created first. A nested sub-process + * re-enters here through {@link createFlowNode} with its own maps. */ function buildContainerChildren( moddle: BpmnModdleInstance, @@ -609,7 +644,6 @@ function createFlowNode( ...baseAttrs, ...eventDefinitionAttrs(moddle, node.eventDefinition, roots, node), }; - // BPMN defaults to interrupting, and the serializer drops a default. if (node.cancelActivity === false) { attrs.cancelActivity = false; } @@ -618,7 +652,7 @@ function createFlowNode( case 'userTask': { const attrs: Record = { ...baseAttrs }; - for (const key of USER_TASK_ATTRIBUTES) { + for (const key of USER_TASK_VERBATIM_KEYS) { const value = node[key]; if (value !== undefined) attrs[`operaton:${key}`] = value; } @@ -670,14 +704,12 @@ function createFlowNode( } case 'exclusiveGateway': - // `default` is wired later, by `attachGatewayDefaults`. return moddle.create('bpmn:ExclusiveGateway', baseAttrs); case 'parallelGateway': return moddle.create('bpmn:ParallelGateway', baseAttrs); case 'inclusiveGateway': - // `default` is wired later, by `attachGatewayDefaults`. return moddle.create('bpmn:InclusiveGateway', baseAttrs); case 'eventBasedGateway': @@ -736,7 +768,7 @@ export const SERVICE_TASK_LIKE_TAG = { } as const; /** The tag each {@link SubProcess.element} serializes to. */ -const SUB_PROCESS_LIKE_TAG = { +export const SUB_PROCESS_LIKE_TAG = { embedded: 'bpmn:SubProcess', transaction: 'bpmn:Transaction', } as const; @@ -773,10 +805,9 @@ function versionBindingAttrs( } /** - * The `bpmn:documentation` child carrying `text` verbatim, or `{}` for - * `undefined`. `textFormat` stays unset, since an absent attribute is what - * `text/plain` means, and moddle serializes `BaseElement`'s properties in - * descriptor order regardless of where this is spread in. + * `textFormat` stays unset, since an absent attribute is what `text/plain` + * means, and moddle serializes `BaseElement`'s properties in descriptor order + * regardless of where this is spread in. */ function documentationChild( moddle: BpmnModdleInstance, @@ -787,11 +818,6 @@ function documentationChild( : { documentation: [moddle.create('bpmn:Documentation', { text })] }; } -/** - * A flow node's documentation, or `{}`. `in` narrows past the kinds whose IR - * type carries no `documentation` field at all. Unlike {@link flowNodeName} - * this derives nothing and humanizes nothing: absent is absent. - */ function flowNodeDocumentation( moddle: BpmnModdleInstance, node: FlowElement, @@ -835,12 +861,10 @@ function flowNodeName(node: FlowElement): string | undefined { } /** - * The `bpmn:multiInstanceLoopCharacteristics` child of a repeated activity, and - * nothing for a node that {@link repeats} answers no for. Operaton reads it - * before the tag dispatch, so the same child serves every activity kind alike. - * The per-run settings ride it as an activity's own do; moddle writes the - * inherited `extensionElements` ahead of `loopCardinality` whatever the - * attribute order here. + * Operaton reads the multi-instance child before the tag dispatch, so one + * child serves every activity kind. moddle writes the inherited + * `extensionElements` ahead of `loopCardinality` whatever the attribute order + * here. */ function loopCharacteristicsAttrs( moddle: BpmnModdleInstance, @@ -862,14 +886,13 @@ function loopCharacteristicsAttrs( } Object.assign(attrs, bareJobSettingAttrs(moddle, loop)); if (loop.cardinality !== undefined) { - attrs.loopCardinality = moddle.create('bpmn:FormalExpression', { - body: loop.cardinality, - }); + attrs.loopCardinality = formalExpression(moddle, loop.cardinality); } if (loop.completionCondition !== undefined) { - attrs.completionCondition = moddle.create('bpmn:FormalExpression', { - body: loop.completionCondition, - }); + attrs.completionCondition = formalExpression( + moddle, + loop.completionCondition, + ); } return { loopCharacteristics: moddle.create( @@ -879,20 +902,18 @@ function loopCharacteristicsAttrs( }; } -/** The four `operaton:` attributes of {@link JobSettings}, in one fixed order for every carrier. */ -export function jobSettingAttrs(node: JobSettings): Record { +/** The `operaton:` attributes of {@link JobSettings}, in the vocabulary's order for every carrier; the retry cycle is an element ({@link retryCycleElement}). */ +function jobSettingAttrs(node: JobSettings): Record { const attrs: Record = {}; - if (node.asyncBefore === true) attrs['operaton:asyncBefore'] = true; - if (node.asyncAfter === true) attrs['operaton:asyncAfter'] = true; - if (node.exclusive === false) attrs['operaton:exclusive'] = false; - if (node.jobPriority !== undefined) { - attrs['operaton:jobPriority'] = node.jobPriority; + for (const key of ENGINE_KEYS) { + if (key === 'retryCycle' || node[key] === undefined) continue; + attrs[`operaton:${key}`] = node[key]; } return attrs; } /** The `operaton:FailedJobRetryTimeCycle` child a retry cycle becomes, wherever it is authored. */ -export function retryCycleElement( +function retryCycleElement( moddle: BpmnModdleInstance, node: JobSettings, ): ModdleElement | undefined { @@ -938,17 +959,6 @@ function engineSettingAttrs( return attrs; } -/** IR fields whose `operaton:` attribute has the same name and takes the value verbatim. */ -const USER_TASK_ATTRIBUTES = [ - 'assignee', - 'formKey', - 'candidateUsers', - 'candidateGroups', - 'dueDate', - 'followUpDate', - 'priority', -] as const; - type EngineNode = Exclude; /** @@ -963,12 +973,10 @@ function buildExtensionElements( roots: RootElementIndex, ): ModdleElement | undefined { const values: ModdleElement[] = []; - // A field configures the implementation the element's own attributes name - // (`operaton:class`/`operaton:delegateExpression`), so it precedes even the - // io-parameter block. Only a serviceTask-like tag has that implementation - // slot; a thrown or emitted message's binding lives on its message - // definition instead, which the language keeps no member block for - // (ADR 0032), so no field is ever written there. + // Fields configure the implementation the element's own attributes name, so + // they precede even the io block. A thrown message's binding sits on its + // `bpmn:MessageEventDefinition` and the language keeps no field, property or + // mapping slot for it (ADR 0032), so only a serviceTask-like tag writes any. if (node.kind === 'serviceTask') { for (const field of codeBindingFields(node.binding)) { values.push(buildField(moddle, field)); @@ -978,12 +986,6 @@ function buildExtensionElements( if (inputOutput !== undefined) { values.push(inputOutput); } - // A thrown message's binding lives on its `bpmn:MessageEventDefinition` - // (`serviceTaskBindingAttrs`, called from `buildEventDefinition`). The - // engine reads a mapping off that definition and a property list off the - // event (`BpmnParse.parseServiceTaskLike` is handed both), but the language - // has no position for either on a throw, so none is written; only a - // serviceTask-like tag reaches here with one. if (node.kind === 'serviceTask' && node.binding.kind === 'external') { if (node.binding.properties !== undefined) { values.push(buildProperties(moddle, node.binding.properties, 'name')); @@ -1068,10 +1070,7 @@ function buildIoParameter( }); } -/** - * Text goes in the body, every other form becomes the single `definitions` - * entry. Exactly one of the two, so a carrier never holds both. - */ +/** The `value` attribute carries a text form's body; every other form is the single `definitions` entry. */ function ioValueAttrs( moddle: BpmnModdleInstance, value: IoValue, @@ -1111,7 +1110,7 @@ function buildIoValueElement( } } -/** The `operaton:field` list a binding carries, or none for a shape the engine hands no field list to ({@link carriesFields}). */ +/** None for a shape the engine hands no field list to ({@link carriesFields}). */ function codeBindingFields( binding: ServiceTaskBinding | ListenerBinding, ): FieldInjection[] { @@ -1119,11 +1118,10 @@ function codeBindingFields( } /** - * One `operaton:field`. `stringValue` is injected into the bean verbatim; - * `expression` is evaluated per instantiation, and a value opening with `${` - * or `#{` lands there. The moddle declares `expression` as a non-attribute - * String, so it serializes as a child rather than an attribute, which is what - * lets the two slots share one DSL spelling. + * `stringValue` is injected into the bean verbatim; `expression` is evaluated + * per instantiation, and a value opening with `${` or `#{` lands there. The + * moddle declares `expression` as a non-attribute String, so it serializes as + * a child. */ function buildField( moddle: BpmnModdleInstance, @@ -1137,7 +1135,6 @@ function buildField( }); } -/** A `timeout` task listener adds the `bpmn:timerEventDefinition` child. */ function buildListener( moddle: BpmnModdleInstance, type: 'operaton:ExecutionListener' | 'operaton:TaskListener', @@ -1165,7 +1162,7 @@ function buildListener( /** * The three reference forms are unprefixed attributes: the listener element is * already `operaton:`-qualified, unlike the same three names on a - * `bpmn:serviceTask`. An inline script becomes an `operaton:script` child. + * `bpmn:serviceTask`. */ function listenerBindingAttrs( moddle: BpmnModdleInstance, @@ -1295,7 +1292,6 @@ function buildConstraint( }); } -/** Canonical order: the business key, then the in-mappings, then the out-mappings. */ function buildCallExtensionValues( moddle: BpmnModdleInstance, node: CallActivity, @@ -1371,9 +1367,10 @@ function createSequenceFlow( // Viewers render a flow's `name`, not its `conditionExpression`, so the // condition is copied there to make the routing readable on the canvas. attrs.name = conditionLabel(flow.conditionExpression); - attrs.conditionExpression = moddle.create('bpmn:FormalExpression', { - body: flow.conditionExpression, - }); + attrs.conditionExpression = formalExpression( + moddle, + flow.conditionExpression, + ); } return moddle.create('bpmn:SequenceFlow', attrs); } @@ -1394,20 +1391,8 @@ function attachIncomingOutgoing( for (const flow of container.sequenceFlows) { const flowModdle = requireById(sequenceFlowById, flow.id); - const source = flowNodeById.get(flow.sourceRef); - const target = flowNodeById.get(flow.targetRef); - if (source === undefined) { - throw new Error( - `SequenceFlow "${flow.id}" references unknown sourceRef "${flow.sourceRef}".`, - ); - } - if (target === undefined) { - throw new Error( - `SequenceFlow "${flow.id}" references unknown targetRef "${flow.targetRef}".`, - ); - } - source.outgoing.push(flowModdle); - target.incoming.push(flowModdle); + requireById(flowNodeById, flow.sourceRef).outgoing.push(flowModdle); + requireById(flowNodeById, flow.targetRef).incoming.push(flowModdle); } } diff --git a/packages/transform/src/ir/types.ts b/packages/transform/src/ir/types.ts index e54e806..608aba6 100644 --- a/packages/transform/src/ir/types.ts +++ b/packages/transform/src/ir/types.ts @@ -9,14 +9,18 @@ import type { BuiltinTaskType, + CallBindingValue, DECISION_RESULT_MAPPINGS, + EMIT_TRIGGERS, END_TRIGGERS, + EngineKey, EXECUTION_LISTENER_EVENTS, FORM_CONSTRAINT_NAMES, FORM_FIELD_TYPES, TASK_LISTENER_EVENTS, THROW_TRIGGERS, TimerJobKey, + TimerKind, } from '@bpmn-script/language'; import { TIMER_JOB_KEYS } from '@bpmn-script/language'; @@ -47,14 +51,12 @@ export interface BpmnProcess extends FlowContainer, Named { /** Comma-separated group ids, listed and stored the same way. */ candidateStarterGroups?: string; /** - * Every error code the process raises, catches, or declares, in canonical - * order: codes something uses in first-use order, then the rest in - * declaration order. Stored rather than derived from usage because two throws - * of one code share a root element, a declared code emits its root even when - * unused, and the message text usage alone cannot recover. `name` is the - * identifier a use site refers to, which is the code itself unless the code - * cannot be spelled as one. See ADR 0016, Derive Event Root Elements From - * Usage. + * Every error code the process raises, catches, or declares: codes in use in + * first-use order, then the rest in declaration order. Stored rather than + * derived from usage because a declared code emits its root even when unused + * and usage alone cannot recover the message text. `name` is the identifier + * a use site refers to, the code itself unless the code cannot be spelled as + * one. See ADR 0016, Derive Event Root Elements From Usage. */ errorDecls?: { name: string; code: string; message?: string }[]; /** As {@link BpmnProcess.errorDecls}; BPMN gives an escalation no message. */ @@ -105,16 +107,13 @@ function collectEventDefinitions(container: FlowContainer): EventDefinition[] { defs.push(el.eventDefinition); break; case 'receiveTask': - // A receive task names its message on the element itself, and shares - // one root with every other use of that name. if (el.messageName !== undefined) { defs.push({ kind: 'message', messageName: el.messageName }); } break; case 'serviceTask': - // An external task's failure mapping raises its code the way a throw - // does, so its root is derived the same way even with no throw or - // declaration of its own (`ExternalTaskEntity.evaluateThrowBpmnError`). + // An external task's failure mapping raises its code like a throw + // (`ExternalTaskEntity.evaluateThrowBpmnError`), so it derives a root. if (el.binding.kind === 'external' && el.binding.errorMappings) { for (const mapping of el.binding.errorMappings) { defs.push({ kind: 'error', errorCode: mapping.errorCode }); @@ -129,7 +128,7 @@ function collectEventDefinitions(container: FlowContainer): EventDefinition[] { } /** The identities a document-level root element is derived from, or checked against. */ -export interface EventIdentities { +interface EventIdentities { errorCodes: Set; escalationCodes: Set; messageNames: Set; @@ -167,8 +166,7 @@ export function eventIdentities(container: FlowContainer): EventIdentities { identities.signalNames.add(def.signalName); break; default: - // Compensation, timer, conditional, and link need no document-level - // element. + // The other kinds need no document-level element. break; } } @@ -221,15 +219,11 @@ export interface FormField { } /** - * The payload of a catch or a throw. On a catch the code selects what the - * handler catches and the bindings name the process variables the caught code - * and text fill; on a throw the code says what is thrown and the engine ignores - * the bindings. The type mirrors what BPMN can represent, not where each field - * is meaningful: the validator and the import contract enforce that bindings - * appear only on a catch and that a throw resolves to a non-empty code. - * - * See ADR 0016, Derive Event Root Elements From Usage, and ADR 0017, Event - * Trigger Payloads. + * The payload of a catch or a throw. The type mirrors what BPMN can represent, + * not where each field is meaningful: the validator and the import contract + * enforce that bindings appear only on a catch and that a throw resolves to a + * non-empty code. See ADR 0016, Derive Event Root Elements From Usage, and + * ADR 0017, Event Trigger Payloads. */ export type EventDefinition = | { @@ -250,29 +244,21 @@ export type EventDefinition = } | { /** - * Payload-less in this IR: no code and no `activityRef`, so a throw - * always compensates its whole scope rather than one activity. - * `BpmnParse.parseThrowCompensateEventDefinition` reads both - * `activityRef` and `waitForCompletion` off the BPMN definition; - * `waitForCompletion` stays unmodeled here because the moddle schema - * defaults it to `true` and the engine warns on any other value. + * No code and no `activityRef`, so a throw compensates its whole scope. + * `waitForCompletion` stays unmodeled: the moddle schema defaults it to + * `true` and the engine warns on any other value. */ kind: 'compensation'; } | { /** - * Payload-free. It ends every running path of its scope at once rather - * than raising something, which is why the surface spells it on an `end` - * statement instead of a `throw`. + * Payload-free: it ends every running path of its scope, which is why + * the surface spells it on an `end` rather than a `throw`. */ kind: 'terminate'; } | { - /** - * Payload-free. It gives up the block it ends rather than raising - * something, which is why the surface spells it on an `end` statement and - * catches it on the block. - */ + /** Payload-free: it gives up the block it ends, spelled on an `end` and caught on the block. */ kind: 'cancel'; } | { @@ -288,7 +274,7 @@ export type EventDefinition = | { kind: 'timer'; /** Maps 1:1 to the `timeDuration`/`timeDate`/`timeCycle` BPMN forms. */ - timerKind: 'duration' | 'date' | 'cycle'; + timerKind: TimerKind; /** ISO-8601 or EL, verbatim. The clock starts with the surrounding scope. */ expression: string; } @@ -300,9 +286,9 @@ export type EventDefinition = | { kind: 'link'; /** - * What a throw and a catch match on: the engine keys one table of these - * per deployed file and rewires every throw of a name to the catch of - * the same name, so the IR carries no reference between the two nodes. + * What a throw and a catch match on: the engine rewires every throw of a + * name to the catch of the same name, so the IR carries no reference + * between the two nodes. */ linkName: string; }; @@ -323,6 +309,12 @@ export type EndEventDefinition = Extract< { kind: (typeof THROW_TRIGGERS | typeof END_TRIGGERS)[number] } >; +/** The definitions an `emit` may raise: the `throw` kinds without the error, plus the link. {@link IntermediateThrowEvent} says why the error is out. */ +export type EmitEventDefinition = Extract< + EventDefinition, + { kind: (typeof EMIT_TRIGGERS)[number] } +>; + /** * The human-facing text a BPMN element carries, mixed into every kind the * surface gives a name, so a kind that carries one carries the other. The @@ -334,30 +326,33 @@ export interface Named { } /** - * The five Operaton job-execution settings, mixed into every event and - * activity kind and, authored on the statement head, into the four gateway - * kinds directly. Each field is stored only in the non-default direction, so - * `asyncBefore="false"` and `exclusive="true"` reproduce by omission. See - * ADR 0022, Carry Operaton Engine Attributes as Named IR Fields, and ADR - * 0040, Engine Settings on Synthesized Gateways. + * The Operaton job-execution settings, on every event and activity kind + * and, authored on the statement head, on the four gateway kinds. Each is + * stored only in the non-default direction, so `asyncBefore="false"` and + * `exclusive="true"` reproduce by omission. See ADR 0022, Carry Operaton + * Engine Attributes as Named IR Fields, and ADR 0040, Engine Settings on + * Synthesized Gateways. */ -export interface JobSettings { - asyncBefore?: true; - asyncAfter?: true; - exclusive?: false; +export type JobSettings = { [K in EngineKey]?: StoredJobSetting[K] }; + +interface StoredJobSetting { + asyncBefore: true; + asyncAfter: true; + exclusive: false; /** An integer or EL, verbatim. */ - jobPriority?: string; + jobPriority: string; /** The `operaton:failedJobRetryTimeCycle` element body, verbatim. */ - retryCycle?: string; + retryCycle: string; } -export function jobSettings(found: { - asyncBefore: boolean | undefined; - asyncAfter: boolean | undefined; - exclusive: boolean | undefined; - jobPriority: string | undefined; - retryCycle: string | undefined; -}): JobSettings { +/** What a reader found per setting; {@link jobSettings} keeps the non-default direction alone. */ +type FoundJobSettings = { + [K in EngineKey]: + | (StoredJobSetting[K] extends boolean ? boolean : StoredJobSetting[K]) + | undefined; +}; + +export function jobSettings(found: FoundJobSettings): JobSettings { return { ...(found.asyncBefore === true ? { asyncBefore: true } : {}), ...(found.asyncAfter === true ? { asyncAfter: true } : {}), @@ -372,14 +367,10 @@ export function jobSettings(found: { export type { TimerJobKey }; /** - * Split the settings of an element that declares a timer between the two jobs - * it can create. `BpmnParse.parseTimer` builds the timer job with the lock off - * the timer definition and the priority off the declaring activity, and - * `DefaultFailedJobParseListener.parseStartEvent`, `parseBoundaryEvent` and - * `parseIntermediateCatchEvent` read its retry cycle off the declaring element. - * The async flags create the continuation job and stay with it. `timer` is - * normalized as {@link jobSettings} is, so a key absent on `settings` stays - * absent. + * Split a timer-declaring element's settings between the two jobs it creates: + * the {@link TIMER_JOB_KEYS} configure the timer job, the async flags create + * the continuation job and stay with it. `timer` is normalized as + * {@link jobSettings} is. */ export function splitTimerJobSettings( settings: T, @@ -428,13 +419,11 @@ export function ioMapped( /** * How many times an activity runs, and what each run sees. At least one of - * `cardinality` and `collection` is present: with both, the count drives the + * `cardinality` and `collection` is present; with both, the count drives the * runs while each run still sees its element. The job settings are the ones - * the engine reads off this element onto each run, `RUN_ENGINE_KEYS` in the - * language package. There is no `jobPriority` field here because the plain - * one on the repeated activity already prices each run's job, not the whole - * loop's; the whole-loop job takes the process definition's own priority, - * since nothing writes one on this element. + * the engine reads off this element onto each run (`RUN_ENGINE_KEYS` in the + * language package); the plain `jobPriority` on the repeated activity already + * prices each run's job, so there is none here. */ export interface LoopCharacteristics extends Omit { /** A literal count, or an expression that yields one. */ @@ -519,11 +508,9 @@ export type ScriptValue = { /** * One `operaton:field`, set on the bean its binding instantiates. `value` - * carries both of the XML value slots as one text: a body opening with `${` is - * the expression form, evaluated per instantiation and written as an - * `operaton:expression` child, and anything else is the literal form, injected - * verbatim and written as a `stringValue` attribute. That is the same reading - * of a leading `${` that `renderIoValue` does on the way out. + * folds both XML slots into one text: a body opening with `${` is written as + * an `operaton:expression` child, anything else as a `stringValue` attribute, + * the same reading of a leading `${` that `renderIoValue` does on the way out. */ export interface FieldInjection { name: string; @@ -606,7 +593,7 @@ export interface EndEvent extends EngineAttributes, Named { export interface IntermediateThrowEvent extends EngineAttributes { kind: 'intermediateThrowEvent'; id: string; - eventDefinition: EventDefinition; + eventDefinition: EmitEventDefinition; /** The implementation {@link EndEvent.binding} describes, on the emitting side. */ binding?: ServiceTaskBinding; } @@ -650,7 +637,7 @@ export interface UserTask extends EngineAttributes, IoMapped, Activity, Named { taskListeners?: TaskListener[]; } -export type DecisionResultMapping = (typeof DECISION_RESULT_MAPPINGS)[number]; +type DecisionResultMapping = (typeof DECISION_RESULT_MAPPINGS)[number]; /** One `operaton:errorEventDefinition` on an external task: the failure condition and the code it raises. */ export interface ErrorMapping { @@ -694,11 +681,7 @@ export type ServiceTaskBinding = fields?: FieldInjection[]; }; -/** - * The bindings Operaton hands a field list to, `FIELD_BINDING_KEYS` in the - * language package: a class and the two built-in behaviours through - * `instantiateDelegate`, a delegate expression's bean on each invocation. - */ +/** The IR side of `FIELD_BINDING_KEYS` in the language package. */ export function carriesFields( binding: ServiceTaskBinding | ListenerBinding, ): binding is Extract< @@ -721,11 +704,10 @@ export interface ServiceTask resultVariable?: string; /** * Which tag this serializes to; absent is a service task. Operaton runs all - * three through `parseServiceTaskLike` when the tag carries a class, - * expression, delegate expression, external topic, or `mail`/`shell` type - * binding, so they share this node. A business rule task naming an - * `operaton:decisionRef` goes to `parseDmnBusinessRuleTask` instead, and - * that binding is legal on that tag alone. + * three through `parseServiceTaskLike`, so they share this node; a business + * rule task naming an `operaton:decisionRef` goes to + * `parseDmnBusinessRuleTask` instead, and that binding is legal on that tag + * alone. */ element?: 'send' | 'businessRule'; } @@ -799,11 +781,8 @@ export interface EventBasedGateway extends JobSettings, Named { /** * Routing rather than work: a fork, a merge or a loop head, never a step. - * - * Membership is the `Gateway` suffix on the kind, the only discriminator every - * BPMN gateway spelling shares. A kind spelled without it would drop out of - * this alias, out of the map below, and out of every site reading either, all - * at once and without a compile error. + * Membership is the `Gateway` suffix on the kind, so a kind spelled without it + * drops out of this alias and of `GATEWAY_KINDS` without a compile error. */ export type Gateway = Extract; @@ -850,19 +829,17 @@ export interface SubProcess triggeredByEvent?: true; /** * Which tag this serializes to; absent is an embedded sub-process. Operaton - * runs a transaction through the very behavior class it gives an ordinary - * sub-process, so nothing about the block is atomic and nothing rolls back; - * what the tag buys is that the engine then accepts a cancel end inside the - * block and a cancel boundary on it. + * runs a transaction through the same behavior class as an ordinary + * sub-process, so nothing is atomic and nothing rolls back; the tag buys + * that the engine accepts a cancel end inside the block and a cancel + * boundary on it. */ element?: 'transaction'; } /** Which deployed version a call activity or a decision task resolves to. */ export type VersionBinding = - | { kind: 'latest' } - | { kind: 'deployment' } - | { kind: 'version'; version: string }; + { kind: CallBindingValue } | { kind: 'version'; version: string }; /** * `target` always names the receiving side: the variable created in the callee @@ -880,12 +857,10 @@ export type CallVariableMapping = }; /** - * Computes a call activity's variable mapping in code, running after the - * declared `in`/`out` mappings on each side, so it adds to them rather than - * replacing them. Not {@link CodeBinding}: the engine reads exactly two - * attributes here, with no expression form, and `CodeBinding`'s `fields?` - * slot exists only for the two behaviours Operaton injects into, which this - * is not. + * Computes a call activity's variable mapping in code, after the declared + * `in`/`out` mappings rather than in place of them. Not {@link CodeBinding}: + * the engine reads exactly two attributes here, with no expression form and + * no field injection. */ export type CallVariableMapper = | { kind: 'class'; /** Fully qualified. */ className: string } diff --git a/packages/transform/src/juel.ts b/packages/transform/src/juel.ts index 3f30916..2024336 100644 --- a/packages/transform/src/juel.ts +++ b/packages/transform/src/juel.ts @@ -1,13 +1,12 @@ /** * Parser, classifier, and DSL serializer for the JUEL subset, on the import - * path. It decides whether a raw `${...}` or `#{...}` body fits the subset and - * can print as clean unquoted DSL, or has to fall back to the quoted raw form, - * `"${...}"` or `"#{...}"` with the opener it came with. + * path: a raw `${...}` or `#{...}` body either fits the subset and prints as + * bare DSL, or falls back to the quoted raw form with the opener it came with. * * The subset boundary is the Langium expression sub-grammar in * `packages/language/src/bpmn-script.langium`, whose precedence the - * recursive-descent parser below reproduces. A test cross-checks this ladder - * against the real grammar, so the two cannot drift: + * recursive-descent parser below reproduces; a test cross-checks this ladder + * against the real grammar: * * ternary c ? t : f * logical || && @@ -23,17 +22,17 @@ * A method or bean call (`x.foo()`), a JUEL function (`fn:size(x)`), or a * malformed body is classified raw. * - * Hand-rolled rather than re-invoking Langium: a synchronous, dependency-free - * parser keeps `xmlToIr` and `irToDsl` off the language package's async parse - * machinery on the hot import path. - * - * The surface form is shared with `renderExpression` in `@bpmn-script/language`: - * double-quoted strings, spaced operators, `.prop`/`[idx]` accessors, author - * parentheses preserved. That makes `parseJuel(renderExpression(x))` idempotent - * on the subset, which is what keeps a round trip from re-wrapping a body it - * already printed bare. + * Hand-rolled rather than re-invoking Langium so that `xmlToIr` and `irToDsl` + * stay synchronous and off the language package's parse machinery. The + * surface form matches `renderExpression` in `@bpmn-script/language` + * (double-quoted strings, spaced operators, `.prop`/`[idx]` accessors, author + * parentheses kept), which makes `parseJuel(renderExpression(x))` idempotent + * on the subset and keeps a round trip from re-wrapping a body it already + * printed bare. */ +import { ID_TERMINAL } from '@bpmn-script/language'; + export type JuelNode = | { kind: 'int'; value: number } | { kind: 'decimal'; value: number } @@ -148,10 +147,9 @@ function stripWrapper(body: string): string | undefined { } /** - * Only fills the `text` of a raw result, never the classification. It sees a - * body with no wrapper or with an unclosed one, so it drops the opening - * delimiter alone: taking a closing brace it never opened would corrupt the - * text {@link renderRawFallback} re-wraps. + * Fills the `text` of a raw result whose wrapper is missing or unclosed, so it + * drops the opening delimiter alone: taking a closing brace it never opened + * would corrupt the text {@link renderRawFallback} re-wraps. */ function stripWrapperLenient(body: string): string { const trimmed = body.trim(); @@ -174,10 +172,7 @@ const MULTI_CHAR_OPS = ['||', '&&', '==', '!=', '<=', '>=']; const SINGLE_CHAR_OPS = ['<', '>', '+', '-', '*', '/', '%', '!', '?', ':']; const PUNCT = ['(', ')', '[', ']', '.']; -const ID_START = /[_a-zA-Z]/; -// The grammar's ID terminal: word chars with internal hyphen groups, where a -// hyphen must be followed by at least one word char. -const ID_REGEX = /^[_a-zA-Z]\w*(?:-\w+)*/; +const ID_REGEX = new RegExp(`^${ID_TERMINAL.source}`, ID_TERMINAL.flags); const DECIMAL_REGEX = /^[0-9]+\.[0-9]+/; const INT_REGEX = /^[0-9]+/; @@ -228,10 +223,9 @@ function tokenize(input: string): Token[] | undefined { continue; } - if (ID_START.test(ch)) { - const idMatch = ID_REGEX.exec(rest); - // ID_REGEX is anchored and ch is an id-start char, so this always matches. - const word = idMatch![0]; + const idMatch = ID_REGEX.exec(rest); + if (idMatch) { + const word = idMatch[0]; if (word === 'true' || word === 'false') { tokens.push({ type: 'bool', value: word }); } else if (word === 'null') { @@ -280,7 +274,6 @@ function readString( while (i < input.length) { const ch = input[i]; if (ch === '\\') { - // Backslash escape: the next char is kept literally. if (i + 1 >= input.length) { return undefined; } @@ -488,10 +481,9 @@ function renderNode(node: JuelNode): string { case 'decimal': return String(node.value); case 'string': - // The grammar's string reader (Langium's `convertEscapeCharacter`) - // resolves `\b \f \n \r \t \v \0` to control characters and, for any - // other character, drops the backslash and keeps the character, so only - // the doubled form reads back as a backslash; it is also the one escape + // Langium's `convertEscapeCharacter` resolves `\b \f \n \r \t \v \0` and + // drops the backslash before any other character, so only the doubled + // form reads back as a backslash; it is also the one escape // `Scanner.nextString` in operaton-juel takes besides `\"`. return `"${escapeQuoted(node.value)}"`; case 'bool': diff --git a/packages/transform/src/synthesize-ids.ts b/packages/transform/src/synthesize-ids.ts index e9fb54a..32fc9ca 100644 --- a/packages/transform/src/synthesize-ids.ts +++ b/packages/transform/src/synthesize-ids.ts @@ -10,7 +10,11 @@ * construction. */ -import { BpmnScriptGrammar, reservedWordsOf } from '@bpmn-script/language'; +import { + BpmnScriptGrammar, + ID_TEXT, + reservedWordsOf, +} from '@bpmn-script/language'; const START_EVENT_PREFIX = 'StartEvent_'; const END_EVENT_PREFIX = 'EndEvent_'; @@ -120,8 +124,8 @@ function claimId(base: string, taken: Set): string { return id; } -/** The grammar's `ID` terminal, the one shape a name in the script has. */ -export const ID_SHAPED = /^[_a-zA-Z]\w*(-\w+)*$/; +/** The one shape a name in the script has. */ +export const ID_SHAPED = ID_TEXT; /** Whether the script can spell `word` as a name: `ID`-shaped and no keyword. */ export function isWritableName(word: string): boolean { diff --git a/packages/transform/src/xml-to-ir.ts b/packages/transform/src/xml-to-ir.ts index 4cdb5ae..98f206c 100644 --- a/packages/transform/src/xml-to-ir.ts +++ b/packages/transform/src/xml-to-ir.ts @@ -3,11 +3,6 @@ * (ADR 0003: the IR holds semantics only). Content the IR cannot express throws * an `UnsupportedConstructError` subclass before any IR exists; content it does * not carry comes back in `warnings`. See ADR 0014, Honest BPMN Import. - * - * The camunda namespace is normalized to the operaton one before parsing - * (`BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` falls back to the camunda URI - * wherever the operaton spelling is absent), so every `camunda:` attribute - * and element is read exactly as its `operaton:` spelling. */ import { @@ -19,6 +14,7 @@ import { EMIT_TRIGGERS, END_TRIGGERS, EXECUTION_LISTENER_EVENTS, + EXPRESSION_ANYWHERE, EXPRESSION_OPEN, FORM_BOUND_TEXT, FORM_CONSTRAINT_TYPES, @@ -28,11 +24,14 @@ import { SHELL_FLAG_FIELDS, SHELL_FLAG_LITERALS, isFormConstraintName, + ON_TRIGGERS, START_TRIGGERS, TASK_LISTENER_EVENTS, + THROW_TRIGGERS, TYPE_BINDING_VALUES, + USER_TASK_VERBATIM_KEYS, } from '@bpmn-script/language'; -import type { BuiltinTaskType } from '@bpmn-script/language'; +import type { BuiltinTaskType, TimerKind } from '@bpmn-script/language'; import { Parser } from 'saxen'; import type { BoundaryEvent, @@ -40,6 +39,7 @@ import type { CallActivity, CallVariableMapper, CallVariableMapping, + EmitEventDefinition, EndEvent, EndEventDefinition, EngineAttributes, @@ -120,9 +120,12 @@ import { parseJuel } from './juel.js'; import { conditionLabel, createModdle, + EVENT_DEFINITION_TAG, FORM_FIELD_TYPE_TO_OPERATON, HISTORY_TIME_TO_LIVE, + IMPLEMENTATION_ATTRS, SERVICE_TASK_LIKE_TAG, + SUB_PROCESS_LIKE_TAG, TIMER_KIND_TO_CHILD, VARIABLE_MAPPING_ATTR_BY_KIND, } from './ir-to-xml.js'; @@ -148,20 +151,11 @@ export interface ImportWarning { message: string; } -/** - * The tags that take the same implementation attributes, so `decisionRef` and - * its modifiers are listed for the business rule task alone below. - */ const SERVICE_TASK_LIKE_OWNERS: readonly string[] = Object.values( SERVICE_TASK_LIKE_TAG, ); -/** - * The `bpmn:Activity` subtypes this tool maps. BPMN declares - * `isForCompensation` and `default` on `bpmn:Activity`, and the IR mixes - * `IoMapped` into exactly these kinds, so one list answers for every sweep that - * asks about an activity. - */ +/** The `bpmn:Activity` subtypes this tool maps; every sweep that asks about an activity reads this one list. */ const ACTIVITY_TAGS: readonly string[] = [ 'bpmn:Task', 'bpmn:ManualTask', @@ -169,15 +163,11 @@ const ACTIVITY_TAGS: readonly string[] = [ ...SERVICE_TASK_LIKE_OWNERS, 'bpmn:ReceiveTask', 'bpmn:ScriptTask', - 'bpmn:SubProcess', - 'bpmn:Transaction', + ...Object.values(SUB_PROCESS_LIKE_TAG), 'bpmn:CallActivity', ]; -/** - * The `$type`s whose IR node carries the job settings and execution listeners - * both; a gateway takes the settings alone, so it is listed one table down. - */ +/** The `$type`s whose IR node carries execution listeners beside the job settings; a gateway takes the settings alone. */ const ENGINE_ATTRIBUTE_OWNERS: readonly string[] = [ 'bpmn:StartEvent', 'bpmn:EndEvent', @@ -197,10 +187,9 @@ const GATEWAY_TAGS: readonly string[] = [ const MULTI_INSTANCE = 'bpmn:MultiInstanceLoopCharacteristics'; /** - * The `$type`s whose IR node carries the job settings (`ENGINE_KEYS` names the - * engine calls). The repetition element carries the four besides the priority - * ({@link readRunSettings}); its priority is read by nothing in the engine and - * is reported by hand in {@link sweepRepetition}. + * The `$type`s whose IR node carries the job settings. The repetition element + * carries all but the priority, which no engine method reads off it + * ({@link sweepRepetition} reports it). */ const JOB_SETTING_OWNERS: readonly string[] = [ ...ENGINE_ATTRIBUTE_OWNERS, @@ -209,28 +198,17 @@ const JOB_SETTING_OWNERS: readonly string[] = [ ]; /** - * A thrown message runs the same implementation, off its definition: * `BpmnParse.parseIntermediateThrowEvent` and `parseEndEvents` hand - * `parseServiceTaskLike` the definition as the element to read, so the - * priority, the result variable, the fields and the error mappings are read - * off it too ({@link readThrownMessageBinding} reports each). The catch side - * carries the same element type and honors none of it, which is what - * {@link warnCatchSideImplementationAttrs} reports. + * `parseServiceTaskLike` the message definition as the element to read, so a + * thrown message carries every implementation attribute a service task does. + * The catch side carries the same element type and honors none of it + * ({@link warnCatchSideImplementationAttrs}). */ const IMPLEMENTATION_OWNERS = [ ...SERVICE_TASK_LIKE_OWNERS, 'bpmn:MessageEventDefinition', ] as const; -/** The extension attributes that name an implementation. */ -const IMPLEMENTATION_ATTRS = [ - 'class', - 'expression', - 'delegateExpression', - 'type', - 'topic', -] as const; - /** What `parseServiceTaskLike` reads off the element beside the implementation. */ const IMPLEMENTATION_EXTRA_ATTRS = [ 'taskPriority', @@ -245,36 +223,39 @@ const IMPLEMENTATION_EXTRA_CHILDREN: readonly string[] = [ ]; /** - * The tags `parseServiceTaskLike` is handed as the property-list element of a - * thrown message (`parseExternalServiceTask` reads `operaton:properties` off - * it), so the block is marked read on both and reported by hand on every - * shape ({@link readThrowEventAttributes}). + * `parseExternalServiceTask` reads `operaton:properties` off the event element + * of a thrown message, so the block is marked read on both tags and reported + * by hand on every other shape ({@link readThrowEventAttributes}). */ const THROW_EVENT_TAGS: readonly string[] = [ 'bpmn:IntermediateThrowEvent', 'bpmn:EndEvent', ]; +type ConsumptionRow = readonly [string, readonly string[]]; + function consumptionTable( - entries: readonly (readonly [string, readonly string[]])[], + entries: readonly ConsumptionRow[], ): ReadonlyMap> { return new Map(entries.map(([name, owners]) => [name, new Set(owners)])); } +function ownedRows( + names: readonly string[], + owners: readonly string[], +): ConsumptionRow[] { + return names.map((name): ConsumptionRow => [name, owners]); +} + /** - * Extension-attribute local names read into the IR, per owning `$type`. Matched - * on the local part, so the `operaton:`/`camunda:` prefix does not matter. - * Keying by owner is what keeps the sweep honest: `assignee` is real data on a - * user task and unread decoration on a service task. - * - * A name listed for an owner that reads it on one side only, or not at all, is - * reported by hand with its own reason: the three `*Variable` names by - * {@link warnThrowSideBindingAttrs}, `jobPriority` on a repetition by - * {@link sweepRepetition}, every engine setting on a link throw by - * {@link warnLinkThrowEngineSettings}, what `parseServiceTaskLike` reads off - * a message definition by {@link readThrownMessageBinding} on a throw and - * {@link warnCatchSideImplementationAttrs} on a catch, and a property list on - * an end or an intermediate throw by {@link readThrowEventAttributes}. + * Extension-attribute local names read into the IR, per owning `$type`: + * `assignee` is data on a user task and unread decoration on a service task. + * A row silences the sweep for every shape of its owner, so a name read on + * one shape only (a throw, the process's own start, a link) is reported by + * hand where it is read: {@link warnThrowSideBindingAttrs}, + * {@link sweepRepetition}, {@link warnLinkThrowEngineSettings}, + * {@link readThrownMessageBinding}, {@link warnCatchSideImplementationAttrs}, + * {@link readThrowEventAttributes}. */ const CONSUMED_EXTENSION_ATTRS = consumptionTable([ ['asyncBefore', JOB_SETTING_OWNERS], @@ -282,21 +263,11 @@ const CONSUMED_EXTENSION_ATTRS = consumptionTable([ ['asyncAfter', JOB_SETTING_OWNERS], ['exclusive', [...JOB_SETTING_OWNERS, 'bpmn:TimerEventDefinition']], ['jobPriority', JOB_SETTING_OWNERS], - ['assignee', ['bpmn:UserTask']], - ['formKey', ['bpmn:UserTask']], - ['formRef', ['bpmn:UserTask']], - ['formRefBinding', ['bpmn:UserTask']], - ['formRefVersion', ['bpmn:UserTask']], - ['candidateGroups', ['bpmn:UserTask']], - ['candidateUsers', ['bpmn:UserTask']], - ['dueDate', ['bpmn:UserTask']], - ['followUpDate', ['bpmn:UserTask']], - ['priority', ['bpmn:UserTask']], - ['class', IMPLEMENTATION_OWNERS], - ['expression', IMPLEMENTATION_OWNERS], - ['delegateExpression', IMPLEMENTATION_OWNERS], - ['type', IMPLEMENTATION_OWNERS], - ['topic', IMPLEMENTATION_OWNERS], + ...ownedRows( + [...USER_TASK_VERBATIM_KEYS, 'formRef', 'formRefBinding', 'formRefVersion'], + ['bpmn:UserTask'], + ), + ...ownedRows(IMPLEMENTATION_ATTRS, IMPLEMENTATION_OWNERS), ['taskPriority', IMPLEMENTATION_OWNERS], ['resultVariable', [...IMPLEMENTATION_OWNERS, 'bpmn:ScriptTask']], ['resultVariableName', [...IMPLEMENTATION_OWNERS, 'bpmn:ScriptTask']], @@ -307,12 +278,9 @@ const CONSUMED_EXTENSION_ATTRS = consumptionTable([ ['mapDecisionResult', ['bpmn:BusinessRuleTask']], ['calledElementBinding', ['bpmn:CallActivity']], ['calledElementVersion', ['bpmn:CallActivity']], - ...Object.values(VARIABLE_MAPPING_ATTR_BY_KIND).map( - (attr): readonly [string, readonly string[]] => [ - attr, - ['bpmn:CallActivity'], - ], - ), + ...ownedRows(Object.values(VARIABLE_MAPPING_ATTR_BY_KIND), [ + 'bpmn:CallActivity', + ]), ['collection', [MULTI_INSTANCE]], ['elementVariable', [MULTI_INSTANCE]], ['versionTag', ['bpmn:Process']], @@ -344,19 +312,14 @@ const CONSUMED_EXTENSION_ELEMENTS = consumptionTable([ /** * Per extension element the IR reads, the attribute local names its reader - * reads off it; body text is not an attribute and is absent. A `$type` missing - * from the table is never swept by {@link warnUnreadChildAttrs}, which is the - * answer for a child no reader reads at all: reporting it whole says more than - * naming each of its attributes would. + * reads off it. A `$type` missing here is never swept by + * {@link warnUnreadChildAttrs}: a child no reader reads is reported whole. * * A key is the path of resolved keys down from `bpmn:ExtensionElements`, * falling back to the bare `$type` at each step, and a qualified row wins over - * the bare one. That is what lets one tag be swept by two rows: an enum - * field's `operaton:value` is read by `id` and `name` where an io list item - * reports both, and a task's `operaton:property` is keyed by `name` + * the bare one; that is how a task's `operaton:property` is keyed by `name` * (`BpmnParseUtil.parseOperatonExtensionProperties`) where a form field's is - * keyed by `id` (`DefaultFormHandler.parseProperties`), under the same - * `operaton:properties` parent. + * keyed by `id` (`DefaultFormHandler.parseProperties`). */ const CONSUMED_CHILD_ATTRS = consumptionTable([ ['operaton:FormData', []], @@ -414,8 +377,7 @@ const CONSUMED_CHILD_ATTRS = consumptionTable([ ['event', 'class', 'expression', 'delegateExpression'], ], // `id` keys a timeout listener's timer job (`BpmnParse.parseTimeoutTaskListener`) - // and the export mints one per timeout listener, so the one read is dropped - // without a word; on any other event `parseTaskListener` never reads it. + // and the export mints one, so the one read drops without a word. [ 'operaton:TaskListener', ['id', 'event', 'class', 'expression', 'delegateExpression'], @@ -423,10 +385,7 @@ const CONSUMED_CHILD_ATTRS = consumptionTable([ ['operaton:Field', ['name', 'stringValue']], ]); -/** - * Tried in this order: the first one a child's reader reads names it in a - * warning. An `operaton:value` has none, so list items stay unqualified. - */ +/** Tried in this order; the first one a child's reader reads names the child in a warning. */ const CHILD_IDENTITY_ATTRS = [ 'name', 'key', @@ -444,10 +403,7 @@ function isConsumedHere( return table.get(name)?.has(ownerType) === true; } -/** - * The reverse of a map the export direction owns, so the pair is spelled once - * and the two directions cannot drift apart. - */ +/** The reverse of a map the export direction owns, so the pair is spelled once. */ function invert( map: Readonly>, ): Readonly> { @@ -463,17 +419,12 @@ const OPERATON_TO_FORM_FIELD_TYPE: Readonly> = /** * What Operaton reads off an owner kind whose IR node has no slot for it, * keyed `/`, each with the - * clause naming the reader. {@link warnUnimportedSetting} appends the clause, - * so a drop the engine would have acted on says so; a name absent here is - * one no engine method reads on that owner. - * - * A row keyed with a `throw:` or `initial:` prefix only matches a drop swept - * under that qualifier: `async` and `operaton:In` on a signal definition are - * read by `parseSignalEventDefinition` on the thrown side alone (a catch's - * definition runs with `isThrowing=false`, which skips both), and - * `parseStartFormHandlers` runs for the process's own start alone, never for - * a nested one. The bare key would otherwise claim the engine reads a catch - * or a nested start's copy, which it does not. + * clause naming the reader; a name absent here is one no engine method reads + * on that owner. A `throw:` or `initial:` prefix matches only a drop swept + * under that qualifier: `parseSignalEventDefinition` reads `async` and + * `operaton:in` with `isThrowing=true` alone, and `parseStartFormHandlers` + * runs for the process's own start alone, so the bare key would claim a read + * the engine never makes on a catch or a nested start. */ const ENGINE_READS_ELSEWHERE: ReadonlyMap = new Map< string, @@ -565,10 +516,10 @@ interface ModdlePropertyDescriptor { } /** - * The text of a reference moddle could not resolve, per element and property. - * moddle types a reference `xsd:IDREF` and drops one naming no element in the - * document, but Operaton reads some of them as a variable name. Keyed by the - * parsed element, so two documents in flight cannot see each other's. + * The text of a reference moddle dropped for naming no element in the + * document, per element and property, since Operaton reads some of them as a + * variable name. Keyed by the parsed element, so two documents in flight + * cannot see each other's. */ const UNRESOLVED_REFS = new WeakMap>(); @@ -590,17 +541,17 @@ function unresolvedRef(el: ModdleElement, name: string): string | undefined { return UNRESOLVED_REFS.get(el)?.get(`bpmn:${name}`); } -/** - * An `xmlns` declaration binding a prefix to the camunda namespace every - * Camunda Modeler export declares. Only a declaration is rewritten: the same - * URI inside a `bpmn:documentation` body or a script is text the engine never - * reads as a namespace. - */ +/** Only an `xmlns` declaration is rewritten: the same URI inside a documentation body or a script is text. */ const CAMUNDA_XMLNS = /(xmlns(?::[\w.-]+)?\s*=\s*)(["'])http:\/\/camunda\.org\/schema\/1\.0\/bpmn\2/g; -/** The namespace whose lookup falls back to the camunda URI, `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS`. */ const OPERATON_NS = 'http://operaton.org/schema/1.0/bpmn'; +export const CAMUNDA_ALIAS_MESSAGE = + 'The file declares the camunda namespace; it was read as the ' + + 'operaton namespace, since `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` ' + + 'falls back to the camunda URI wherever the operaton spelling is ' + + 'absent, and the document written back carries `operaton:` alone.'; + /** * Parse a BPMN 2.0 XML document into the IR. Throws when the XML is malformed * or when the document as a whole is one Operaton refuses or this tool cannot @@ -612,18 +563,17 @@ export async function xmlToIr( const moddle = createModdle(); refuseDocumentShapes(xml); - // `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` is one `Namespace` whose lookup - // falls back to the camunda URI when the operaton spelling is absent, so - // the engine runs a `camunda:` element exactly as its `operaton:` spelling. - // moddle-xml binds a prefix to a package by URI, not by spelling, so - // rewriting the declared URI alone (never the prefix, which can recur - // inside attribute values) makes every `camunda:` element and attribute - // arrive typed and readable as `operaton:`, with no separate alias code. + // `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` falls back to the camunda URI + // wherever the operaton spelling is absent, so the engine runs a `camunda:` + // element exactly as its `operaton:` spelling. moddle-xml binds a prefix to + // a package by URI, so rewriting the declared URI alone (never the prefix, + // which can recur inside attribute values) makes every `camunda:` element + // and attribute arrive typed as `operaton:`. const normalizedXml = xml.replace(CAMUNDA_XMLNS, `$1$2${OPERATON_NS}$2`); const camundaRead = normalizedXml !== xml; // moddle records "unparsable content" only for elements in the registered - // `operaton:` namespace whose type the extension does not declare. Declared + // `operaton:` namespace whose type the extension does not declare; declared // operaton and foreign-namespace elements materialize as values instead. const { rootElement, warnings: moddleWarnings } = await moddle.fromXML(normalizedXml); @@ -638,7 +588,7 @@ export async function xmlToIr( refuseDroppedIds(moddleWarnings); refuseImports(root); - const rootElements = (root.get('rootElements') as ModdleElement[]) ?? []; + const rootElements = root.get('rootElements') as ModdleElement[]; const warnings: ImportWarning[] = []; const processEl = selectProcess(rootElements, warnings); @@ -651,11 +601,7 @@ export async function xmlToIr( warnings.push({ elementId: mappedProcess.id, category: 'unmappedConstruct', - message: - 'The file declares the camunda namespace; it was read as the ' + - 'operaton namespace, since `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` ' + - 'falls back to the camunda URI wherever the operaton spelling is ' + - 'absent, and the document written back carries `operaton:` alone.', + message: CAMUNDA_ALIAS_MESSAGE, }); } @@ -710,17 +656,12 @@ export async function xmlToIr( const UNDEFINED_ENTITY = /&(?!(?:amp|lt|gt|quot|apos);)[A-Za-z][\w.-]*;/; /** - * Refuse, on the raw text, what the engine's SAX parse refuses before - * `BpmnParse` sees an element, and the one attribute shape moddle reads - * differently from the engine. Comments and CDATA sections are cut first: a - * script body may legitimately hold `&name;` or `|/g, ''); @@ -754,11 +695,9 @@ function refuseDocumentShapes(xml: string): void { /** * moddle-xml drops an element whose id repeats or falls outside its ASCII - * pattern, leaving a warning and a dangling reference; the engine validates - * every file against `BPMN20.xsd` (`BpmnParse` sets the schema, `Parse.execute` - * runs a validating parse, `ParseHandler.error` files each violation) and - * refuses a duplicate `xs:ID`, while it deploys a non-ASCII NCName this tool - * cannot read. + * pattern, leaving a warning and a dangling reference. The engine's validating + * parse against `BPMN20.xsd` (`Parse.execute`) refuses a duplicate `xs:ID`, + * while it deploys a non-ASCII NCName this tool cannot read. */ function refuseDroppedIds(moddleWarnings: unknown): void { for (const warning of (moddleWarnings as ModdleWarning[] | undefined) ?? []) { @@ -784,8 +723,7 @@ function refuseDroppedIds(moddleWarnings: unknown): void { * `CxfWSDLImporter` on the classpath. */ function refuseImports(root: ModdleElement): void { - const [theImport] = - (root.get('imports') as ModdleElement[] | undefined) ?? []; + const [theImport] = root.get('imports') as ModdleElement[]; if (theImport === undefined) return; throw new UnsupportedDocumentError( `it declares a bpmn:import of type '${readString(theImport, 'importType') ?? ''}'; ` + @@ -795,13 +733,11 @@ function refuseImports(root: ModdleElement): void { } /** - * The one process to import: the one marked `isExecutable="true"`, which is - * the one `BpmnParse.parseProcessDefinitions` deploys (an absent attribute - * deploys nothing in a new deployment, `!deployment.isNew()`). Every other - * process is a warned drop. Two executable processes deploy side by side and - * the IR holds one, so they refuse; several processes of which none is - * executable deploy nothing, so they refuse too, while a lone unmarked process - * imports with {@link mapProcess}'s warning. + * The one process `BpmnParse.parseProcessDefinitions` deploys: the one marked + * `isExecutable="true"` (an absent attribute defaults to `!deployment.isNew()`, + * so a new deployment skips it). Two executable processes deploy side by side + * and the IR holds one, so they refuse; a lone unmarked process imports with + * {@link mapProcess}'s warning. */ function selectProcess( rootElements: ModdleElement[], @@ -845,12 +781,9 @@ function selectProcess( } /** - * One warning per participant and per message flow. `BpmnParse.parseCollaboration` - * records a participant's `processRef` for `parseBPMNShape` alone, and no - * method in `BpmnParse` reads a message flow, so a collaboration is diagram - * data: the imported process runs identically, and the document written back - * has no pool. Anything else hung on the collaboration (an artifact) is swept - * like any unread BPMN child. + * `BpmnParse.parseCollaboration` records a participant's `processRef` for + * `parseBPMNShape` alone, and no method in `BpmnParse` reads a message flow, + * so a collaboration is diagram data: the imported process runs identically. */ function warnCollaborationDrops( rootElements: ModdleElement[], @@ -860,7 +793,7 @@ function warnCollaborationDrops( for (const collab of rootElements) { if (collab.$type !== 'bpmn:Collaboration') continue; const collabId = collab.id ?? processEl.id ?? ''; - for (const p of (collab.get('participants') as ModdleElement[]) ?? []) { + for (const p of collab.get('participants') as ModdleElement[]) { const id = p.id ?? collabId; const ref = getEl(p, 'processRef'); const names = @@ -878,7 +811,7 @@ function warnCollaborationDrops( 'and the document written back has no pool.', }); } - for (const flow of (collab.get('messageFlows') as ModdleElement[]) ?? []) { + for (const flow of collab.get('messageFlows') as ModdleElement[]) { const id = flow.id ?? collabId; const end = (name: string): string => getEl(flow, name)?.id ?? unresolvedRef(flow, name) ?? '?'; @@ -923,13 +856,10 @@ function refuseDuplicateSignalNames(signalRoots: ModdleElement[]): void { /** * The declarations `bpmn:Error` and `bpmn:Escalation` roots carry, in document - * order, deduped by code. Referenced or not: the surface holds a declaration - * either way, so a root reaches the IR whenever it can be keyed by a code. - * - * Only an error root carries `operaton:errorMessage`, and that text is the one - * root datum usage cannot recover, so two roots agreeing on a code and - * disagreeing on the message are refused rather than merged. A root with no - * code is warned about and dropped: nothing keys it. + * order, deduped by code; a root with no code is dropped with a warning. Two + * roots agreeing on a code and disagreeing on `operaton:errorMessage` are + * refused rather than merged, since that text is the one root datum usage + * cannot recover. */ function readCodeDecls( roots: ModdleElement[], @@ -994,11 +924,7 @@ function readCodeDecls( return decls; } -/** - * Warn once per message or signal root nothing in the IR uses. An error or an - * escalation root is not asked: {@link readCodeDecls} imports it as a - * declaration whether or not anything raises its code. - */ +/** Warn once per message or signal root nothing in the IR uses; an error or escalation root imports as a declaration regardless. */ function warnUnreferencedRoots( messageRoots: ModdleElement[], signalRoots: ModdleElement[], @@ -1027,11 +953,6 @@ function warnUnreferencedRoots( } } -/** - * Check one message/signal root against the names the IR uses. moddle resolves - * `itemRef`/`structureRef` as element references, so presence goes through - * `.get()`. - */ function warnUnreferencedNamedRoot( root: ModdleElement, referencedNames: ReadonlySet, @@ -1067,33 +988,21 @@ function warnUnreferencedNamedRoot( }); } -/** A `bpmn-moddle` parse warning; only its `message` is read. */ +/** A `bpmn-moddle` parse warning. */ interface ModdleWarning { readonly message?: string; - /** - * An `unresolved reference` warning carries the element that held it, the - * property it was written on (`bpmn:loopDataInputRef`), and its text. - */ + /** An `unresolved reference` warning carries the element, the property (`bpmn:loopDataInputRef`) and the text. */ readonly element?: ModdleElement; readonly property?: string; readonly value?: string; } -/** - * A source position as `line:column`, both counted from zero. An "unparsable - * content" moddle warning carries one and no element at all, so it is the only - * handle on the element the warning was raised for. - */ +/** A source position as `line:column`: the only handle an "unparsable content" moddle warning gives on its element. */ function positionKey(line: number, column: number): string { return `${line}:${column}`; } -/** - * The positions of every element written under one of `rootIds`, which are ids - * of elements directly under ``. Re-reading the source is - * what ties a residual back to its owner; a root whose id the source does not - * carry simply matches nothing, so its children stay reported. - */ +/** The positions of every element written under a root whose id is in `rootIds`, read off the source text. */ function positionsUnderRoots( xml: string, rootIds: ReadonlySet, @@ -1117,9 +1026,8 @@ function positionsUnderRoots( }, ); // saxen raises `closeTag` for `` as well as for ``, so the - // decrement has to be gated on the same flag the increment above is: - // ungated, every self-closing tag sinks `depth` one below true nesting and - // the direct-child test stops firing for the rest of the document. + // decrement is gated on the same flag as the increment; ungated, every + // self-closing tag sinks `depth` one below true nesting for good. parser.on('closeTag', (_elementName, _decodeEntities, selfClosing) => { if (selfClosing) return; depth -= 1; @@ -1147,12 +1055,11 @@ const DROPPED_CONDITIONS = new WeakMap(); * moddle resolves an unprefixed `xsi:type` against the document's default * namespace, where `BpmnParse.parseConditionExpression` resolves it against * `BPMN20_NS` whatever the document declares, so `` in a prefixed document reaches no reader - * (moddle drops it as unparsable content) while the engine deploys and - * evaluates it. The two agree on a prefixed type and in a default-namespace - * document. Such an element is read back off the source text and keyed by - * its flow, so {@link mapSequenceFlow} reads it as the engine does; the - * positions of the ones taken are returned for the residual sweep to skip. + * xsi:type="tFormalExpression">` in a prefixed document is dropped by moddle + * as unparsable content while the engine evaluates it. Such an element is + * read back off the source text and keyed by its flow for + * {@link mapSequenceFlow}; the positions taken are returned for the residual + * sweep to skip. */ function recordDroppedConditions( root: ModdleElement, @@ -1242,10 +1149,8 @@ function localNameOf(qualifiedName: string): string { /** * An attribute of a raw-text tag, found by local name whatever prefix binds - * it: the namespace swap in {@link xmlToIr} only rewrites `xmlns:*` values, - * so a document's own choice of `camunda:` or `operaton:` survives on the - * attribute itself. Empty and absent both read as `undefined`, matching - * {@link readString} and {@link readNamespacedAttr} on the moddle-parsed path. + * it: the namespace swap in {@link xmlToIr} rewrites `xmlns:*` values alone, + * so the document's own `camunda:` or `operaton:` prefix survives here. */ function nonEmptyAttr( attrs: Record, @@ -1260,13 +1165,9 @@ function nonEmptyAttr( /** * One {@link ImportWarning} per residual "unparsable content" moddle warning, - * attributed to the process because moddle cannot tie the dropped element to a - * step. Declared operaton and foreign-namespace elements never reach here. - * - * A residual written inside a root {@link collectRootDrops} already reported - * whole is skipped, so the root does not draw a second warning blamed on the - * process for its own child; so is a condition {@link recordDroppedConditions} - * read back, which its flow carries. + * attributed to the process because moddle cannot tie the dropped element to + * a step. A residual inside a root {@link collectRootDrops} reported whole, + * or a condition {@link recordDroppedConditions} read back, is skipped. */ function collectUnparsableResidualDrops( moddleWarnings: unknown, @@ -1306,10 +1207,7 @@ function collectUnparsableResidualDrops( } } -/** - * Map a `bpmn:Process` into the IR. All `bpmndi:`/`dc:`/`di:` content sits - * outside the process subtree, so iterating `flowElements` drops DI for free. - */ +/** All `bpmndi:`/`dc:`/`di:` content sits outside the process subtree, so iterating `flowElements` drops DI for free. */ function mapProcess( processEl: ModdleElement, warnings: ImportWarning[], @@ -1321,9 +1219,8 @@ function mapProcess( const named = readNamed(processEl, id, warnings); // The one place import changes what the document says rather than leaving - // something out, so it gets its own wording. An absent attribute is the - // same change: `BpmnParse.parseProcessDefinitions` defaults it to - // `!deployment.isNew()`, false for every deployment of a new resource. + // something out. An absent attribute is the same change: + // `BpmnParse.parseProcessDefinitions` defaults it to `!deployment.isNew()`. const isExecutable = processEl.get('isExecutable'); if (isExecutable === false) { warnings.push({ @@ -1358,9 +1255,8 @@ function mapProcess( ); const versionTag = readNamespacedAttr(processEl, 'versionTag'); // The exporter stamps HISTORY_TIME_TO_LIVE on every process that authored - // none, so reading that exact value back would invent a setting the source - // never had, and leave a re-imported IR unequal to the one it was exported - // from. Every other value is the author's and is carried. + // none, so reading that exact value back would invent a setting and leave a + // re-imported IR unequal to the one exported. const authoredTimeToLive = readNamespacedAttr(processEl, 'historyTimeToLive'); const historyTimeToLive = authoredTimeToLive === HISTORY_TIME_TO_LIVE @@ -1391,8 +1287,7 @@ function mapProcess( /** * `BpmnParse.parseStartAuthorization` reads every `operaton:potentialStarter` * into the candidate-starter lists before the two attributes append theirs, - * splitting each formal expression as `parsePotentialOwnerResourceAssignment` - * splits a task's: the same respelling a `bpmn:potentialOwner` gets. + * splitting each formal expression as a `bpmn:potentialOwner`'s is split. */ function readPotentialStarters( processEl: ModdleElement, @@ -1435,9 +1330,9 @@ function readPotentialStarters( } /** - * Which container hosts the element being mapped, threaded down because moddle - * offers no `$parent`: an undo handler sits directly inside the block whose - * work it undoes, and a cancel end directly inside a block that can be given up. + * Which container hosts the element being mapped: an undo handler sits + * directly inside the block whose work it undoes, and a cancel end directly + * inside a block that can be given up. */ type ContainerHostKind = 'process' | 'subProcess' | 'transaction' | 'eventSubProcess'; @@ -1457,15 +1352,12 @@ function mapContainer( } /** - * A process takes several start events, one per `start` statement of its - * body, but `BpmnParse.selectInitial` fails the deployment on a second plain - * or timer start. A subprocess or a transaction takes exactly one: - * `BpmnParse.parseScopeStartEvent` errors on the second. With none, - * `BpmnParse.parseStartEvents` errors on a `process` or `subProcess` tag - * alone, so a transaction with no start deploys and - * `SubProcessActivityBehavior.execute` throws on entering it, where the - * script adds a start and runs. An event handler is checked in - * {@link mapEventSubProcess}. + * A process takes one start per `start` statement, but `BpmnParse.selectInitial` + * fails the deployment on a second plain or timer start; a subprocess or a + * transaction takes exactly one (`BpmnParse.parseScopeStartEvent`). With none, + * `BpmnParse.parseStartEvents` errors on a `process` or `subProcess` tag alone, + * so a transaction with no start deploys and fails on entry + * (`SubProcessActivityBehavior.execute`), where the script adds a start. */ function checkStartEventCount( el: ModdleElement, @@ -1473,7 +1365,7 @@ function checkStartEventCount( warnings: ImportWarning[], ): void { const id = requireId(el); - const children = (el.get('flowElements') as ModdleElement[]) ?? []; + const children = el.get('flowElements') as ModdleElement[]; const starts = children.filter((c) => c.$type === 'bpmn:StartEvent'); if (starts.length === 0 && hostKind !== 'transaction') { throw new UnsupportedEventFeatureError( @@ -1526,13 +1418,13 @@ function checkStartEventCount( const ACTIVITY_TYPES: ReadonlySet = new Set(ACTIVITY_TAGS); /** The general wording for an unpaired half of the pattern: nothing to name a rewrite from. */ -const IS_FOR_COMPENSATION_DETAIL = +export const IS_FOR_COMPENSATION_DETAIL = 'isForCompensation="true" marks this activity as excluded from normal ' + 'flow: the boundary-event compensation-handler pattern, which this ' + 'tool cannot import; wrap the steps in their own subprocess and ' + 'target it with "on compensation" instead'; -const COMPENSATION_BOUNDARY_DETAIL = +export const COMPENSATION_BOUNDARY_DETAIL = 'a compensation boundary event is not imported: BPMN attaches ' + 'compensation through isForCompensation and a bpmn:association on ' + 'the activity being compensated, not a boundary event; wrap the ' + @@ -1551,11 +1443,7 @@ function refuseIfForCompensation( ); } -/** - * The paired wording for the handler side of the pattern: `undefined` when no - * association targets `handlerId`, or when the rewrite cannot be shown, - * either of which leaves the general wording as the only honest refusal. - */ +/** The paired wording for the handler side, or `undefined` when no association targets `handlerId` or the rewrite cannot be shown. */ function describePairedHandler( containerEl: ModdleElement, handlerId: string, @@ -1579,18 +1467,13 @@ function describePairedHandler( ); } -/** - * The activity a `bpmn:association` in `containerEl`'s `artifacts` names on - * the opposite end from `id`: `artifacts` sits outside `READ_BPMN_CHILDREN`, - * so nothing else in the dispatch ever reads it, and only an association - * ties a compensation boundary to the handler it targets. - */ +/** The element a `bpmn:association` in `containerEl`'s `artifacts` names on the opposite end from `id`. */ function findAssociationEnd( containerEl: ModdleElement, knownEnd: 'sourceRef' | 'targetRef', id: string, ): ModdleElement | undefined { - const artifacts = (containerEl.get('artifacts') as ModdleElement[]) ?? []; + const artifacts = containerEl.get('artifacts') as ModdleElement[]; const otherEnd = knownEnd === 'sourceRef' ? 'targetRef' : 'sourceRef'; const association = artifacts.find( (a) => a.$type === 'bpmn:Association' && getEl(a, knownEnd)?.id === id, @@ -1598,12 +1481,7 @@ function findAssociationEnd( return association === undefined ? undefined : getEl(association, otherEnd); } -/** - * `boundaryEl` as a genuine compensation boundary, with the activity it - * attaches to: `undefined` when the association's other end is not one, so - * the caller falls back to the general wording rather than a rewrite it - * cannot honestly show. - */ +/** `boundaryEl` as a compensation boundary with its host, or `undefined` when it is not one. */ function compensationBoundaryOf( boundaryEl: ModdleElement, ): { id: string; hostEl: ModdleElement } | undefined { @@ -1618,21 +1496,27 @@ function compensationBoundaryOf( : { id: requireId(boundaryEl), hostEl }; } +const SERVICE_TASK_ELEMENT_BY_TAG: Readonly< + Record +> = invert(SERVICE_TASK_LIKE_TAG); + /** - * Per-tag dispatch for every activity kind but `bpmn:SubProcess`, shared by - * the container dispatch (`mapContainerChildren`) and the standalone mapping - * below: `bpmn:SubProcess` is excluded because the two disagree on it, - * container by `triggeredByEvent` (an event sub-process nests correctly only - * there) and standalone always as a plain sub-process, so each keeps its own - * case for that one tag rather than the two drifting silently inside a shared - * one. `undefined` for a tag not in this list, which cannot happen for either - * caller today but keeps this total rather than throwing into a message that - * is already reporting a different problem. + * Per-tag dispatch for every activity kind but `bpmn:SubProcess`, which the + * container dispatch maps by `triggeredByEvent` and the standalone mapping + * always as a plain sub-process, so each keeps its own case for that tag. */ function mapActivityByTag( el: ModdleElement, warnings: ImportWarning[], -): FlowElement | undefined { +): FlowElement { + const serviceLike = SERVICE_TASK_ELEMENT_BY_TAG[el.$type]; + if (serviceLike !== undefined) { + return mapServiceTask( + el, + warnings, + serviceLike === 'service' ? undefined : serviceLike, + ); + } switch (el.$type) { case 'bpmn:Task': return mapTask(el, warnings); @@ -1640,53 +1524,38 @@ function mapActivityByTag( return mapManualTask(el, warnings); case 'bpmn:UserTask': return mapUserTask(el, warnings); - case 'bpmn:ServiceTask': - return mapServiceTask(el, warnings); - case 'bpmn:SendTask': - return mapServiceTask(el, warnings, 'send'); - case 'bpmn:BusinessRuleTask': - return mapServiceTask(el, warnings, 'businessRule'); case 'bpmn:ReceiveTask': return mapReceiveTask(el, warnings); case 'bpmn:ScriptTask': return mapScriptTask(el, warnings); - case 'bpmn:Transaction': + case SUB_PROCESS_LIKE_TAG.transaction: return mapSubProcess(el, warnings, 'transaction'); case 'bpmn:CallActivity': return mapCallActivity(el, warnings); default: - return undefined; + throw new UnsupportedElementError(el.$type, el.id); } } /** - * Map one activity element the way {@link describeCompensationRewrite} needs - * it, standalone: both the compensated activity and its handler sit outside - * the container the refusal is walking, so neither goes through - * `mapContainerChildren`. Repetition is attached here too, the same read - * `mapContainerChildren` runs after its own dispatch, so a - * `bpmn:multiInstanceLoopCharacteristics` on either side survives into the - * preview instead of silently vanishing from it. + * One activity mapped outside `mapContainerChildren`, for + * {@link describeCompensationRewrite}: the repetition is attached here as + * that loop attaches it, so a repeated activity previews with its loop. */ function mapActivityStandalone( el: ModdleElement, warnings: ImportWarning[], -): FlowElement | undefined { - const mapped = +): FlowElement { + const out = [ el.$type === 'bpmn:SubProcess' ? mapSubProcess(el, warnings) - : mapActivityByTag(el, warnings); - if (mapped === undefined) return undefined; - const out = [mapped]; + : mapActivityByTag(el, warnings), + ]; attachRepetition(out, 0, el, warnings); return out[0]; } -/** - * The paired wording for the boundary side of the pattern: `undefined` when - * no association leaves `boundaryId`, or the rewrite cannot be shown, either - * of which leaves the general wording as the only honest refusal. - */ +/** The paired wording for the boundary side, or `undefined` when no association leaves `boundaryId` or the rewrite cannot be shown. */ function describePairedBoundary( containerEl: ModdleElement, boundaryId: string, @@ -1694,10 +1563,9 @@ function describePairedBoundary( ): string | undefined { const handlerEl = findAssociationEnd(containerEl, 'sourceRef', boundaryId); if (handlerEl === undefined) return undefined; - // Mirrors the boundary check `describePairedHandler` performs the other - // direction: an association to an element that never declared itself a - // compensation handler is not this pattern, genuine or otherwise, and - // Operaton's own `parseAssociationOfCompensationBoundaryEvent` rejects it. + // An association to an element that never declared itself a compensation + // handler is not this pattern; `parseAssociationOfCompensationBoundaryEvent` + // rejects it too. if (handlerEl.get('isForCompensation') !== true) return undefined; const rewrite = describeCompensationRewrite(hostEl, boundaryId, handlerEl); if (rewrite === undefined) return undefined; @@ -1711,22 +1579,13 @@ function describePairedBoundary( /** * The `subprocess`/`on compensation` rewrite an author would write by hand - * for one compensation triple, in the current surface spelling. Built by - * mapping the compensated activity and the handler through their own real - * mappers, repetition included, so every attribute and loop already prints in - * its known shape, wrapping the result in a throwaway process, and printing - * that process through the real `irToDsl` printer: reusing the printer's own - * CFG pass means an activity that is itself a sub-process nests correctly - * with no separate recursion here. `undefined` when either activity carries - * content this tool cannot import either, so the caller falls back to the - * general wording instead of a rewrite it cannot show. - * - * The synthesized start never needs to dodge the reserved-name check: a - * plain, unlabeled start under the id minted for its handler is elided from - * the printed body entirely (`isElidedOnPrint`), the same way one is on any - * ordinary import. Only the wrapper's own id has to clear it, since it is the - * one statement that prints its id; `Compensated_` does, because every - * reserved pattern names a different prefix. + * for one compensation triple, built by mapping both activities through their + * real mappers, wrapping them in a throwaway process and printing it through + * the real `irToDsl`, whose own CFG pass nests a sub-process host correctly. + * `undefined` when either activity refuses on its own, since the preview is a + * courtesy on top of the compensation refusal already in flight. The wrapper + * id `Compensated_` is the one printed id, and it clears every reserved + * prefix; the synthesized start is elided (`isElidedOnPrint`). */ function describeCompensationRewrite( hostEl: ModdleElement, @@ -1734,27 +1593,21 @@ function describeCompensationRewrite( handlerEl: ModdleElement, ): string | undefined { const hostId = requireId(hostEl); - let compensated: FlowElement | undefined; - let handler: FlowElement | undefined; + let compensated: FlowElement; + let handler: FlowElement; try { compensated = mapActivityStandalone(hostEl, []); handler = mapActivityStandalone(handlerEl, []); } catch (e) { - // A per-tag mapper refuses content of its own (e.g. an - // `operaton:resource` script) by throwing rather than returning - // `undefined`; this preview is a courtesy on top of the compensation - // refusal already in flight, not the place to raise a second one. if (e instanceof UnsupportedConstructError) return undefined; throw e; } - if (compensated === undefined || handler === undefined) return undefined; const wrapperId = `Compensated_${hostId}`; const handlerId = makeEventSubProcessId(wrapperId); const compensationStart: FlowElement = { kind: 'startEvent', - // Minted for the handler it sits in, as the compiler mints it, so the - // printer elides it the way it elides the compiler's own. + // Minted as the compiler mints it, so the printer elides it the same way. id: makeStartEventId(handlerId, new Set()), eventDefinition: { kind: 'compensation' }, }; @@ -1778,19 +1631,17 @@ function describeCompensationRewrite( sequenceFlows: [], }; - // Strip the throwaway `process Preview { ... }` shell (header, closing - // brace, and the newline `irToDsl` always trails with) and drop the one - // indent level every kept line carries as that process's direct child, so - // the block reads as something to paste at the reader's own nesting depth. + // Strip the `process Preview { ... }` shell (header, closing brace and the + // trailing newline) and the one indent level its direct child carries. const lines = irToDsl(preview).source.split('\n').slice(1, -2); return lines.map((line) => line.slice(INDENT.length)).join('\n'); } /** * `BpmnParse` reads neither quantity attribute. A BPMN-declared attribute is - * invisible to both generic sweeps: it never reaches `$attrs`, and the - * declared-attribute sweep looks at `operaton:` attributes only. Every such - * drop is therefore reported by hand. + * invisible to both generic sweeps (it never reaches `$attrs`, and the + * declared-attribute sweep looks at `operaton:` attributes only), so every + * such drop is reported by hand. */ function warnIgnoredQuantityAttrs( el: ModdleElement, @@ -1799,9 +1650,7 @@ function warnIgnoredQuantityAttrs( ): void { if (!ACTIVITY_TYPES.has(el.$type)) return; for (const name of ['startQuantity', 'completionQuantity']) { - // moddle's descriptor default (1) applies before .get() ever returns - // undefined, so an absent attribute reads back as 1, which is what the - // engine runs either way. + // An absent attribute reads back as moddle's descriptor default, 1. const value: unknown = el.get(name); if (value === 1) continue; warnings.push({ @@ -1816,10 +1665,9 @@ function warnIgnoredQuantityAttrs( } /** - * Per-child dispatch for a container's `flowElements`. `attachedToRef` is - * validated afterwards by {@link checkBoundaryEventHosts}, not inline: moddle - * may present a boundary event before its host, so the container's full - * activity-id set exists only once the loop has finished. + * Per-child dispatch for a container's `flowElements`. Every rule about flows + * or about a boundary's host runs after the loop, since a boundary event may + * be written before its host. */ function mapContainerChildren( el: ModdleElement, @@ -1829,11 +1677,10 @@ function mapContainerChildren( ): { flowElements: FlowElement[]; sequenceFlows: SequenceFlow[] } { const flowElements: FlowElement[] = []; const sequenceFlows: SequenceFlow[] = []; - // What the printer's elision reads: the container's id, which seeds the - // minted start and end, and its elements, asked once the list is complete. + // What the printer's elision reads, asked once the list is complete. const container = { id: requireId(el), flowElements }; - const children = (el.get('flowElements') as ModdleElement[]) ?? []; + const children = el.get('flowElements') as ModdleElement[]; for (const child of children) { refuseIfForCompensation(child, el); const mappedAt = flowElements.length; @@ -1876,10 +1723,8 @@ function mapContainerChildren( : mapSubProcess(child, warnings), ); break; - // Every other activity tag, including bpmn:Transaction: Operaton reads - // triggeredByEvent on that one not at all, so it never opens an event - // handler (warnIgnoredTransactionAttrs reports the drop), and it maps - // the same way standalone as it does here. + // Operaton reads `triggeredByEvent` on a bpmn:Transaction not at all + // (warnIgnoredTransactionAttrs reports it), so it maps like a task. case 'bpmn:Task': case 'bpmn:ManualTask': case 'bpmn:UserTask': @@ -1889,21 +1734,13 @@ function mapContainerChildren( case 'bpmn:ReceiveTask': case 'bpmn:ScriptTask': case 'bpmn:Transaction': - case 'bpmn:CallActivity': { - const mapped = mapActivityByTag(child, warnings); - if (mapped === undefined) { - throw new UnsupportedElementError(child.$type, child.id); - } - flowElements.push(mapped); + case 'bpmn:CallActivity': + flowElements.push(mapActivityByTag(child, warnings)); break; - } case 'bpmn:SequenceFlow': sequenceFlows.push(mapSequenceFlow(child, warnings)); break; - // None of the three is a flow node: no bpmn:sequenceFlow can point at - // one, so dropping it leaves no hole in the graph, and Operaton keeps - // process variables in its own store regardless. `continue` skips the - // trailing per-child sweeps below, which would otherwise draw a second + // `continue` skips the per-child sweeps below, which would draw a second // warning for a `bpmn:dataObject`'s own `bpmn:dataState` child. case 'bpmn:DataObject': case 'bpmn:DataObjectReference': @@ -1916,9 +1753,7 @@ function mapContainerChildren( attachRepetition(flowElements, mappedAt, child, warnings); if (child.id !== undefined) { warnIgnoredQuantityAttrs(child, child.id, warnings); - // 'initial': parseStartFormHandlers runs for the process's own start - // alone, so only a start at the process's top level reports a form - // setting as read elsewhere; a nested start's copy reads as unread anywhere. + // `parseStartFormHandlers` runs for the process's own start alone. collectExtensionDrops( child, child.id, @@ -1931,11 +1766,9 @@ function mapContainerChildren( } } - // Whether a start or an end prints at all is a question about the whole - // container: the first plain start under the minted id goes, and an end - // named for a boundary escape goes only if that boundary is in the list, - // which this tool's own output writes after the end. So the list is - // complete before any element is asked. + // Whether a start or an end prints is a question about the whole container + // (an end named for a boundary escape goes only if that boundary is in the + // list, which the export writes after the end), so the list is complete first. for (const mapped of flowElements) { if (mapped.kind === 'startEvent') { warnElidedNamedDrop( @@ -1958,13 +1791,11 @@ function mapContainerChildren( } /** - * Carry the `default` a step names once every flow of the container is - * mapped: `BpmnActivityBehavior.handleNoTransitions` takes it when no other - * route out of the step held, so it is routing, the same as on a gateway, - * which {@link mapDefaultingGateway} reads for itself. One naming a flow that - * does not leave the step is what `handleNoTransitions` throws - * `missingDefaultFlowException` on, and a `default` naming no element at all - * is read back from moddle's dropped reference for the same report. + * The `default` a step names: `BpmnActivityBehavior.handleNoTransitions` + * takes it when no other route out of the step held, and throws + * `missingDefaultFlowException` on one that does not leave the step, which + * is reported; a `default` naming no element is read back from moddle's + * dropped reference for the same report. */ function attachDefaultFlows( children: ModdleElement[], @@ -2004,12 +1835,10 @@ function attachDefaultFlows( /** * The shapes `BpmnParse.validateExclusiveGateway` fails the deployment on, * asked of the document rather than the IR: the engine counts a condition - * element with an empty body as a condition, which the IR carries as none, - * and reads `default` as the raw attribute, so one naming no element in the - * document still counts as a default. Each refusal quotes the engine's own - * sentence for the shape. One conditioned route beside one plain route with - * no default is left alone: the engine takes the plain one as the default and - * warns. + * element with an empty body as a condition and a `default` naming no + * element as a default, and the IR carries neither. One conditioned route + * beside one plain route with no default is left alone: the engine takes the + * plain one as the default and warns. */ function checkExclusiveGateways(children: ModdleElement[]): void { const flows = children.filter((c) => c.$type === 'bpmn:SequenceFlow'); @@ -2035,8 +1864,7 @@ function checkExclusiveGateways(children: ModdleElement[]): void { } continue; } - // `default=""` is moddle's dropped reference to nothing; `BpmnParse. - // validateExclusiveGateway` reads an empty default as none. + // `validateExclusiveGateway` reads `default=""` as none. const defaultFlowId = getEl(gateway, 'default')?.id ?? (unresolvedRef(gateway, 'default') || undefined); @@ -2064,9 +1892,8 @@ function checkExclusiveGateways(children: ModdleElement[]): void { /** * `BpmnParse.parseSequenceFlow` fails the deployment on a flow into or out of - * an event sub-process, off the flow's own endpoints; the element's - * `incoming`/`outgoing` children are bookkeeping a hand-written file omits, - * so the check reads the flows. + * an event sub-process, off the flow's own endpoints, so the check reads the + * flows rather than the `incoming`/`outgoing` children a hand-written file omits. */ function checkHandlerFlows( flowElements: FlowElement[], @@ -2099,13 +1926,11 @@ function checkHandlerFlows( } /** - * Runs before {@link checkWaitBranches} so a wait branch leading to a link - * catch draws this message rather than passing the wait's own check. The - * engine refuses a flow out of a link throw at deploy - * (`BpmnParse.parseSequenceFlow`, an invalid source) but accepts one into a - * link catch and runs the catch as a pass-through; that side is this surface's - * own refusal, since `await link` takes no incoming flow, so the flow could - * neither print nor be dropped without changing what runs. + * Runs before {@link checkWaitBranches} so a wait branch into a link catch + * draws this message. The engine refuses a flow out of a link throw at deploy + * (`BpmnParse.parseSequenceFlow`) but runs one into a link catch as a + * pass-through; that side is this surface's own refusal, since `await link` + * takes no incoming flow and dropping one would change what runs. */ function checkLinkFlows( flowElements: FlowElement[], @@ -2172,19 +1997,14 @@ function isActivity(node: FlowElement): node is ActivityElement { return (ACTIVITY_KINDS as readonly string[]).includes(node.kind); } -/** - * Read the repetition off the child the dispatch just mapped, at `index` in - * `flowElements`. Operaton reads it before its own tag dispatch and wraps - * whatever that produces, so one reader here serves every repeatable tag. - */ +/** Operaton reads the repetition before its own tag dispatch and wraps whatever that produces, so one reader serves every repeatable tag. */ function attachRepetition( flowElements: FlowElement[], index: number, child: ModdleElement, warnings: ImportWarning[], ): void { - // A sequence flow pushed nothing, and a kind that cannot repeat is left - // alone. + // A sequence flow pushed nothing at `index`. const mapped = flowElements[index]; if (mapped === undefined || !isActivity(mapped)) return; const loop = readLoopCharacteristics(child, mapped.id, warnings); @@ -2192,11 +2012,7 @@ function attachRepetition( flowElements[index] = { ...mapped, loop }; } -/** - * The kinds a boundary event may attach to, each with the noun the refusals - * spell it with. The keys are the only list of those kinds, so a kind cannot - * reach the check without a noun to be named by. - */ +/** The kinds a boundary event may attach to, each with the noun the refusals spell it with. */ const BOUNDARY_HOST_NOUNS = { task: 'plain task', userTask: 'user task', @@ -2234,11 +2050,7 @@ function boundaryHostNoun(host: BoundaryHost): string { return BOUNDARY_HOST_NOUNS[host.kind]; } -/** - * Every remaining kind, named as the surface writes it, so a diagnostic about - * an arbitrary element has a noun for it. Exhaustive over the two maps - * together: a new kind stops the build here rather than printing `undefined`. - */ +/** Every remaining kind, named as the surface writes it; the `satisfies` makes a new kind stop the build here. */ const OTHER_FLOW_ELEMENT_NOUNS = { startEvent: 'start', endEvent: 'end', @@ -2257,19 +2069,11 @@ const OTHER_FLOW_ELEMENT_NOUNS = { const flowElementNoun = (el: FlowElement): string => isBoundaryHost(el) ? boundaryHostNoun(el) : OTHER_FLOW_ELEMENT_NOUNS[el.kind]; -/** - * `a` or `an` in front of a noun from the maps above. Spelling decides, minus - * `u`, which those maps open with only in `user task`. A noun that sounds a - * vowel it does not spell, such as `hour`, would need its own exception; the - * maps hold none. - */ +/** `a` or `an` by spelling, minus `u`, which the maps above open with only in `user task`. */ const withArticle = (noun: string): string => `${/^[aeio]/i.test(noun) ? 'an' : 'a'} ${noun}`; -/** - * Every noun a host can be named by, for the refusal that enumerates them. Both - * maps feed it, so adding a kind to either widens the sentence with it. - */ +/** Every noun a host can be named by, for the refusal that enumerates them. */ const BOUNDARY_HOST_NOUN_LIST = Object.entries(BOUNDARY_HOST_NOUNS).flatMap( ([kind, noun]) => { if (kind === 'serviceTask') { @@ -2282,10 +2086,7 @@ const BOUNDARY_HOST_NOUN_LIST = Object.entries(BOUNDARY_HOST_NOUNS).flatMap( }, ); -/** - * The subset an escalation boundary may attach to, per Operaton's own - * `BpmnParse.parseBoundaryEvents`: a service or script task is excluded. - */ +/** The hosts `BpmnParse.parseBoundaryEvents` allows an escalation boundary on. */ const ESCALATION_BOUNDARY_HOST_KINDS: ReadonlySet = new Set(['subProcess', 'callActivity', 'userTask']); @@ -2294,15 +2095,10 @@ const givesUpItsWork = (el: FlowElement): el is SubProcess => el.kind === 'subProcess' && el.element === 'transaction'; /** - * Validate every boundary event against the other elements of its own - * container, after {@link mapContainerChildren}'s child loop: a host may be - * written before or after the boundary event, so the activity-id set is - * complete only then. - * - * The same pass catches an inbound flow written only as - * `sequenceFlow/@targetRef`. {@link mapBoundaryEvent} sees only the `incoming` - * list, which moddle fills from optional `` children, while - * Operaton reads `targetRef` regardless. + * Validate every boundary event against its own container. The same pass + * catches an inbound flow written only as `sequenceFlow/@targetRef`: + * {@link mapBoundaryEvent} sees only the `incoming` list, which moddle fills + * from optional `` children, while Operaton reads `targetRef`. */ function checkBoundaryEventHosts( flowElements: FlowElement[], @@ -2311,9 +2107,8 @@ function checkBoundaryEventHosts( ): void { const activityById = new Map(); for (const el of flowElements) { - // An event subprocess is written as a bare `on { ... }` with no - // authored id, so a boundary event on one has nothing to print against. - // Left out of the map, it hits the refusal below. + // An event subprocess prints as a bare `on { ... }` with no id, + // so a boundary event on one has nothing to print against. if ( isBoundaryHost(el) && !(el.kind === 'subProcess' && el.triggeredByEvent === true) @@ -2378,11 +2173,10 @@ function checkBoundaryEventHosts( } /** - * The cancel end inside a block and the cancel handler on it are one construct: - * parsing the handler is what wires the two together, and nothing but that end - * ever reaches the handler. Operaton takes one handler per block and refuses a - * file with two; either half alone deploys and then goes wrong at run time, so - * a lone half warns rather than refusing. + * The cancel end inside a block and the cancel handler on it are one + * construct. Operaton takes one handler per block and refuses a file with + * two; either half alone deploys and goes wrong at run time, so a lone half + * warns rather than refusing. */ function checkCancelPairing( flowElements: FlowElement[], @@ -2437,16 +2231,11 @@ function checkCancelPairing( } /** - * Validate the branches of every wait that has several, run after - * {@link mapContainerChildren}'s child loop for the reason - * {@link checkBoundaryEventHosts} is: both rules are about flows. - * - * Operaton's `BpmnParse.parseEventBasedGateway` refuses a target that is not an - * `intermediateCatchEvent` beside the gateway, which is the first refusal - * below. `BpmnParse.parseSequenceFlow` refuses any other flow into a catch such - * a gateway opens, which is the second; the engine misses the case where every - * path in comes from an event-based gateway, and the refusal below counts the - * paths instead. Neither shape is writable on the surface. + * `BpmnParse.parseEventBasedGateway` refuses a branch target that is not an + * `intermediateCatchEvent`, and `BpmnParse.parseSequenceFlow` refuses any + * other flow into a catch such a gateway opens; the engine misses the case + * where every path in comes from an event-based gateway, so the second + * refusal counts the paths instead. Neither shape is writable on the surface. */ function checkWaitBranches( flowElements: FlowElement[], @@ -2501,11 +2290,7 @@ function checkWaitBranches( } } -/** - * Map a `bpmn:SubProcess` or a `bpmn:Transaction`, which differ only in the tag - * they serialize back to. `bpmn:AdHocSubProcess` carries its own `$type` and - * hits the default refusal arm instead. - */ +/** A `bpmn:SubProcess` or a `bpmn:Transaction`, which differ only in the tag they serialize back to. */ function mapSubProcess( el: ModdleElement, warnings: ImportWarning[], @@ -2534,10 +2319,9 @@ function mapSubProcess( } /** - * The BPMN attributes a `` declares that Operaton reads - * nothing of: `parseTransaction` reads no attribute of its own and forces the - * triggered-by-event property to false, so all three drop without changing what - * runs. Reported by hand, for the reason {@link warnIgnoredQuantityAttrs} gives. + * The BPMN attributes of a `` that `parseTransaction` reads + * nothing of, reported by hand for the reason {@link warnIgnoredQuantityAttrs} + * gives. */ const IGNORED_TRANSACTION_ATTRS: ReadonlyMap = new Map([ ['method', 'Operaton reads it on a not at all'], @@ -2555,8 +2339,7 @@ function warnIgnoredTransactionAttrs( warnings: ImportWarning[], ): void { for (const [name, reason] of IGNORED_TRANSACTION_ATTRS) { - // An absent attribute reads back as undefined, or as the moddle default - // false, and neither is content the document wrote. + // An absent attribute reads back as undefined or as the moddle default false. const value: unknown = el.get(name); if (value === undefined || value === false) continue; warnings.push({ @@ -2581,7 +2364,7 @@ function mapEventSubProcess( collectLaneDrops(el, id, warnings); warnNamedDrop(el, id, 'an event handler', warnings); - const children = (el.get('flowElements') as ModdleElement[]) ?? []; + const children = el.get('flowElements') as ModdleElement[]; const startEvents = children.filter((c) => c.$type === 'bpmn:StartEvent'); if (startEvents.length !== 1) { throw new UnsupportedEventFeatureError( @@ -2615,15 +2398,12 @@ function mapEventSubProcess( } /** - * A timer-started event sub-process prints as a host-less `on timer(...)`, - * whose head spells the three timer job keys once, for the timer job its - * start event creates ({@link splitTimerJobSettings}). The sub-process's own - * copy reaches only its async continuation job (`BpmnParse.createActivityOnScope` - * takes the priority, `parseAsynchronousContinuation` the lock and - * `DefaultFailedJobParseListener.parseSubProcess` the retry cycle off the - * sub-process element), and that copy has no spelling on the surface. - * Keeping it on the node would print it into the head beside the start's, - * where the compiler puts it back on the start or refuses the duplicate key. + * A timer-started event sub-process prints as a host-less `on timer(...)` + * whose head spells the timer job keys once, for the start's timer job. The + * sub-process's own copy reaches only its async continuation job + * (`BpmnParse.createActivityOnScope`, `parseAsynchronousContinuation`, + * `DefaultFailedJobParseListener.parseSubProcess`) and has no spelling on the + * surface; kept on the node, it would print into the head beside the start's. */ function dropContinuationCopyOfTimerJobSettings( settings: EngineAttributes, @@ -2669,9 +2449,8 @@ function mapEventSubProcessStart( 'start', ); - // Operaton's compensation-handler lookup asks only whether the handler is a - // subprocess scope triggered by an event, never what tag the block it - // compensates carries, so both container heads may host one. + // Operaton's compensation-handler lookup never asks what tag the block + // carries, so a transaction hosts one as a subprocess does. if ( eventDefinition.kind === 'compensation' && hostKind !== 'subProcess' && @@ -2704,9 +2483,8 @@ function mapEventSubProcessStart( ); } - // Unlike a boundary event or a throw, the start statement under an `on` - // header has a label slot, so the pair is carried rather than dropped; - // warnElidedNamedDrop reports it if the statement turns out not to print. + // Unlike a boundary event, the start under an `on` header has a label slot; + // warnElidedNamedDrop reports the pair if the statement does not print. const named = readNamed(startEl, id, warnings); return { kind: 'startEvent', @@ -2719,11 +2497,7 @@ function mapEventSubProcessStart( }; } -/** - * The trigger kinds a `cancelActivity="false"` boundary refuses on, each with - * why. Every other kind a boundary takes has a non-interrupting form, which - * this surface writes as `alongside`. - */ +/** The trigger kinds a `cancelActivity="false"` boundary refuses on; every other kind writes as `alongside`. */ const NON_INTERRUPTING_REFUSALS: Partial< Record > = { @@ -2738,11 +2512,7 @@ const NON_INTERRUPTING_REFUSALS: Partial< 'which this surface cannot write back', }; -/** - * Map a `bpmn:BoundaryEvent`. `attachedToRef` resolves to the host element - * (BPMN declares it `isReference: true`) and only its `id` is kept, so the IR - * stays plain strings; {@link checkBoundaryEventHosts} validates it afterwards. - */ +/** `attachedToRef` resolves to the host element; only its `id` is kept, and {@link checkBoundaryEventHosts} validates it afterwards. */ function mapBoundaryEvent( el: ModdleElement, warnings: ImportWarning[], @@ -2759,7 +2529,7 @@ function mapBoundaryEvent( ); } - const incoming = (el.get('incoming') as ModdleElement[] | undefined) ?? []; + const incoming = el.get('incoming') as ModdleElement[]; if (incoming.length > 0) { throw new UnsupportedEventFeatureError( id, @@ -2814,8 +2584,7 @@ function mapBoundaryEvent( /** * `BpmnParse.ensureNoIoMappingDefined` (a start or a boundary event) and * `BpmnParse.checkActivityInputOutputSupported` (a gateway or an event - * sub-process) fail the deployment on an `operaton:inputOutput` here; - * `method` says which, and the quote is the one it adds. + * sub-process) fail the deployment on an `operaton:inputOutput` here. */ function refuseIoMapping( el: ModdleElement, @@ -2834,18 +2603,16 @@ function refuseIoMapping( id, `an operaton:inputOutput mapping on a <${tag}>, which BpmnParse.${method} ` + 'fails the deployment on ("operaton:inputOutput mapping unsupported ' + - `for element type '${tag.slice(tag.indexOf(':') + 1)}'${triggered}")`, + `for element type '${localNameOf(tag)}'${triggered}")`, ); } /** - * Resolve one event definition on the CATCH side. An error or escalation - * definition with no ref, or whose root carries no code, is catch-all: the - * missing code is what makes the handler match anything. A reference naming - * no root is not: the engine catches its text as the code + * One event definition on the catch side. An error or escalation definition + * with no ref, or whose root carries no code, is catch-all; a reference + * naming no root is not: the engine catches its text as the code * ({@link readDanglingErrorCode}) or refuses the file - * (`createEscalationEventDefinitionForEscalationHandler`). A cancel and a - * link are read at one position each and refused everywhere else. + * (`createEscalationEventDefinitionForEscalationHandler`). */ function readCatchEventDefinition( defEl: ModdleElement, @@ -2908,15 +2675,13 @@ function readCatchEventDefinition( }; } - // The boundary is the only catch position for it: BPMN has no cancel start - // event, and Operaton refuses a cancel intermediate catch outright. + // BPMN has no cancel start, and Operaton refuses a cancel intermediate catch. if (defEl.$type === 'bpmn:CancelEventDefinition' && position === 'boundary') { return { kind: 'cancel' }; } - // Operaton reads a link definition in `BpmnParse.parseIntermediateCatchEvent` - // and nowhere else on the catch side: a start ignores it and runs as a none - // start, and `parseBoundaryEvents` refuses it at deploy. + // On the catch side only `BpmnParse.parseIntermediateCatchEvent` reads a + // link: a start ignores it, and `parseBoundaryEvents` refuses it. if ( defEl.$type === 'bpmn:LinkEventDefinition' && position === 'intermediate catch' @@ -2924,18 +2689,19 @@ function readCatchEventDefinition( return readLinkDefinition(defEl, ownerId); } - throw new UnsupportedEventDefinitionError(ownerId, position, defEl.$type); + // The await site admits no tag the arms above leave unread, so only a start + // or a boundary reaches this refusal. + throw new UnsupportedEventDefinitionError( + ownerId, + position as 'start' | 'boundary', + defEl.$type, + ); } /** - * The text of an `errorRef` naming no root, which is the code the engine - * catches, throws or maps: `parseBoundaryErrorEventDefinition`, - * `parseErrorStartEventDefinition`, `parseEndEvents` and - * `parseOperatonErrorEventDefinitions` each read - * `error == null ? errorRef : error.getErrorCode()`. `undefined` when no - * reference was written at all, which is the catch-all on the catch side. - * The export declares a root for every code in use, so the document written - * back names one. + * The text of an `errorRef` naming no root, which the engine's four error + * readers each take as the code (`error == null ? errorRef : + * error.getErrorCode()`); `undefined` when no reference was written at all. */ function readDanglingErrorCode( defEl: ModdleElement, @@ -2959,7 +2725,7 @@ function readDanglingErrorCode( return written; } -/** Unlike an error, an escalation reference the engine cannot resolve fails the deployment; `method` is the reader that does. */ +/** Unlike an error, an escalation reference the engine cannot resolve fails the deployment. */ function danglingEscalationDetail(written: string, method: string): string { return ( `its escalationRef '${written}' names no bpmn:escalation root, which ` + @@ -2969,10 +2735,8 @@ function danglingEscalationDetail(written: string, method: string): string { } /** - * A nameless link definition has nothing to match: - * `BpmnParse.parseIntermediateLinkEventCatchBehavior` throws a - * NullPointerException on a catch, and `BpmnParse.parseSequenceFlow` reports - * every flow into such a throw as a deploy error. + * `BpmnParse.parseIntermediateLinkEventCatchBehavior` throws on a nameless + * catch, and `BpmnParse.parseSequenceFlow` fails every flow into a nameless throw. */ function readLinkDefinition( defEl: ModdleElement, @@ -2991,11 +2755,7 @@ function readLinkDefinition( return { kind: 'link', linkName }; } -/** - * The three definitions whose identity is their whole payload, so nothing about - * them turns on which side of the wire they sit. `undefined` leaves the kind to - * the caller's own arms. - */ +/** The three definitions read the same on the catch and the throw side; `undefined` leaves the kind to the caller. */ function readSharedEventDefinition( defEl: ModdleElement, ownerId: string, @@ -3019,11 +2779,7 @@ function readSharedEventDefinition( return undefined; } -/** - * The moddle schema defaults `waitForCompletion` to `true` and reads an absent - * attribute back as `true`, so a bare definition and an explicit `"true"` - * import identically; only an explicit `false` is refused. - */ +/** moddle reads an absent `waitForCompletion` back as its default `true`, so only an explicit `false` is refused. */ function refuseUnsupportedCompensateFeatures( defEl: ModdleElement, ownerId: string, @@ -3033,7 +2789,7 @@ function refuseUnsupportedCompensateFeatures( throw new UnsupportedEventFeatureError( ownerId, 'a compensation definition targets one activity by reference ' + - `(activityRef="${activityRef.id ?? '(unknown)'}"); this tool always ` + + `(activityRef="${requireId(activityRef)}"); this tool always ` + 'addresses the enclosing scope and cannot target a single activity', ); } @@ -3086,7 +2842,7 @@ const TIMER_CHILD_TO_KIND = invert(TIMER_KIND_TO_CHILD); function readTimerDefinition( defEl: ModdleElement, ownerId: string, -): { timerKind: 'duration' | 'date' | 'cycle'; expression: string } { +): { timerKind: TimerKind; expression: string } { const childNames = Object.keys( TIMER_CHILD_TO_KIND, ) as (keyof typeof TIMER_CHILD_TO_KIND)[]; @@ -3114,11 +2870,7 @@ function readTimerDefinition( return { timerKind: TIMER_CHILD_TO_KIND[childName], expression }; } -/** - * The conditional-narrowing attribute names. Neither is declared by the moddle - * extension, so both surface only in `$attrs`, under `operaton:` alone (a - * document's camunda: spelling reads as `operaton:` by the time this runs). - */ +/** Neither is declared by the moddle extension, so both surface in `$attrs` alone. */ const CONDITIONAL_NARROWING_ATTRS: readonly string[] = [ 'variableName', 'variableEvents', @@ -3129,9 +2881,8 @@ function readConditionalDefinition( ownerId: string, warnings: ImportWarning[], ): string { - const attrs = defEl.$attrs ?? {}; for (const localName of CONDITIONAL_NARROWING_ATTRS) { - if (attrs[`operaton:${localName}`] !== undefined) { + if (defEl.$attrs[`operaton:${localName}`] !== undefined) { throw new UnsupportedEventFeatureError( ownerId, `a conditional definition's operaton:${localName} narrows when the ` + @@ -3156,21 +2907,17 @@ function readConditionalDefinition( } /** - * Resolve one event definition on the THROW side. The definition type is the - * same on both sides, so {@link CONSUMED_EXTENSION_ATTRS} marks the catch - * parameters read and {@link warnThrowSideBindingAttrs} reports them here. - * Only an intermediate throw reaches this with a link: {@link mapEndEvent} - * refuses one by tag first, since Operaton runs a link end as a none end. + * One event definition on the throw side. Only an intermediate throw reaches + * this with a link: {@link mapEndEvent} refuses one by tag first, since + * Operaton runs a link end as a none end. */ function readThrowEventDefinition( defEl: ModdleElement, ownerId: string, warnings: ImportWarning[], ): EventDefinition { - // 'throw': every position reaching here (an emit, a message/signal/ - // escalation end) fires with parseSignalEventDefinition's isThrowing=true, - // which is what makes it read `async` and `operaton:in`; a catch's own - // sweep (readCatchEventDefinition) stays unqualified since isThrowing=false there. + // Every position reaching here runs `parseSignalEventDefinition` with + // `isThrowing=true`, which is what reads `async` and `operaton:in`. collectExtensionDrops(defEl, ownerId, warnings, 'throw'); collectUnmappedBpmnDrops(defEl, ownerId, warnings); warnDocumentationDrop(defEl, ownerId, 'an event definition', warnings); @@ -3238,10 +2985,7 @@ function readThrowEventDefinition( return { kind: 'escalation', escalationCode }; } -/** - * The generic sweep cannot report these: throw and catch carry the same element - * `$type`, and the catch side reads these names. - */ +/** Throw and catch carry the same definition `$type`, and the catch side reads these names, so the sweep cannot report them. */ function warnThrowSideBindingAttrs( defEl: ModdleElement, ownerId: string, @@ -3268,11 +3012,8 @@ function warnThrowSideBindingAttrs( /** * `BpmnParse.parseIntermediateThrowEvent` returns before creating an activity - * for a link throw, so the engine never read these and the re-exported - * document runs the same without them. The generic sweep cannot report them: - * {@link CONSUMED_EXTENSION_ATTRS} and {@link CONSUMED_EXTENSION_ELEMENTS} - * mark them read on every intermediate throw, since the other emit kinds do - * carry them. + * for a link throw, so the engine never reads these; the tables mark them + * read on every intermediate throw for the other emit kinds' sake. */ function warnLinkThrowEngineSettings( el: ModdleElement, @@ -3309,12 +3050,7 @@ function warnLinkThrowEngineSettings( } } -/** - * The mirror of {@link warnThrowSideBindingAttrs}: an implementation on a - * message definition, and what `parseServiceTaskLike` reads beside it, is - * what sends the message, which only a throw does, so the catch side reads - * the same names and imports none of them. - */ +/** The mirror of {@link warnThrowSideBindingAttrs}: only a throw sends the message, so a catch reads no implementation. */ function warnCatchSideImplementationAttrs( defEl: ModdleElement, ownerId: string, @@ -3343,19 +3079,15 @@ function warnCatchSideImplementationAttrs( } } -/** `operaton:field 'to'`: {@link describeChild} without its article, for a sentence that leads with the child. */ +/** {@link describeChild} without its article, for a sentence that leads with the child. */ function childSubject(child: ModdleElement): string { return describeChild(child, child.$type).replace(/^an /, ''); } /** - * Report everything a start or end the printer may leave unprinted takes with - * it: its label, its documentation, and a start's initiator. + * Report what a start or end the printer may leave unprinted takes with it. * {@link isElidedOnPrint} decides a start's fate alone; an end's is settled * by print position, unknown here, so an end's message covers both outcomes. - * This is the only report standing behind an `initiator`, which - * {@link warnUnreadDeclaredAttrs} counts as read the moment any start carries - * it. */ function warnElidedNamedDrop( el: StartEvent | EndEvent, @@ -3391,12 +3123,7 @@ function warnElidedNamedDrop( } } -/** - * Report the label and the documentation of an element whose position has no - * IR node to hold either. `surface` names that position, and each fact takes - * its own category, so an element draws one warning per fact and never two for - * one. - */ +/** The label and the documentation of an element whose position has no IR node to hold either; `surface` names that position. */ function warnNamedDrop( el: ModdleElement, id: string, @@ -3417,11 +3144,7 @@ function warnNamedDrop( warnDocumentationDrop(el, id, surface, warnings); } -/** - * Report a `bpmn:documentation` at a position with no IR node to hold it, - * naming that position the way {@link warnNamedDrop} names it. A position that - * does hold one reads it through {@link readNamed} instead. - */ +/** A `bpmn:documentation` at a position with no IR node to hold it; a position that holds one reads it through {@link readNamed}. */ function warnDocumentationDrop( el: ModdleElement, id: string, @@ -3507,15 +3230,9 @@ function mapCallActivity( } /** - * The variable-mapping delegate a call activity names, class first then - * delegate expression: Operaton's own if/else-if in - * `BpmnParse.parseCallActivity` resolves them in that order, taking the class - * and silently dropping the delegate expression when both are set. Importing - * that way loses nothing the engine was going to run, so this warns instead - * of refusing, through {@link buildShadowedImplementationWarning} rather than - * `warnShadowedImplementation` itself, since `IMPLEMENTATION_ATTRS`/ - * `IMPLEMENTATION_OWNERS` is keyed to the tags that resolve one - * implementation and deliberately excludes `bpmn:CallActivity`. + * `BpmnParse.parseCallActivity` resolves the variable-mapping delegate class + * first and drops the delegate expression beside it, which loses nothing the + * engine runs, so the loser warns rather than refuses. */ function readCallVariableMapper( el: ModdleElement, @@ -3543,11 +3260,7 @@ function readCallVariableMapper( return undefined; } -/** - * The one refusal `BpmnParse.parseTenantId` draws, on the call activity and - * the DMN task alike: `what` is the callable the tenant scopes (`called - * process`, `decision`). - */ +/** The one refusal `BpmnParse.parseTenantId` draws; `what` is the callable the tenant scopes. */ function tenantPinDetail(attr: string, value: string, what: string): string { return ( `it names operaton:${attr}="${value}", which pins the tenant ` + @@ -3557,17 +3270,11 @@ function tenantPinDetail(attr: string, value: string, what: string): string { } /** - * Resolve the `Binding`/`Version` pair a call activity, a - * decision reference, and a form reference all pin their version with, the - * inverse of `ir-to-xml.ts`'s `versionBindingAttrs`. The generic sweep cannot - * tell a meaningful version from a dangling one (set while the binding is - * absent or not `"version"`, where Operaton ignores it), so it is reported - * here. - * - * A word outside the four `BpmnParse.parseBinding` matches leaves the - * callable's binding null, which `BaseCallableElement.isLatestBinding` reads - * as latest, so it imports as such; `parseFormDefinition` refuses the same - * word on a form reference, so there it refuses too. + * The `Binding`/`Version` pair, the inverse of `ir-to-xml.ts`'s + * `versionBindingAttrs`. A word outside `BpmnParse.parseBinding`'s four leaves + * the binding null, which `BaseCallableElement.isLatestBinding` reads as + * latest, so it imports as such; `parseFormDefinition` refuses the same word + * on a form reference. */ function readVersionBinding( el: ModdleElement, @@ -3636,7 +3343,6 @@ function readVersionBinding( return binding; } -/** The word `formRefBinding` outside the three `parseFormDefinition` resolves is a deployment error. */ function formRefBindingRefusal(word: string): string { return ( `formRefBinding="${word}" is outside the bindings ` + @@ -3651,10 +3357,6 @@ interface CallMappings { outMappings?: CallVariableMapping[]; } -/** - * Report an attribute on an `operaton:in`/`operaton:out` that the engine - * passes over because `winner` was read first; `why` names the reader. - */ function warnMappingAttrIgnored( warnings: ImportWarning[], ownerId: string, @@ -3674,9 +3376,8 @@ function warnMappingAttrIgnored( /** * `BpmnParse.parseInputParameter` takes a non-empty `businessKey` and reads - * nothing else off that `operaton:in`, and a later one overwrites the - * earlier through `setBusinessKeyValueProvider`; every other element goes - * to {@link readCallVariableMapping}. + * nothing else off that `operaton:in`, and a later one overwrites the earlier + * through `setBusinessKeyValueProvider`. */ function readCallMappings( el: ModdleElement, @@ -3757,7 +3458,7 @@ function readCallVariableMapping( const sourceExpression = readString(value, 'sourceExpression'); const variables = readString(value, 'variables'); const target = readString(value, 'target'); - const local = value.get('local') === true ? true : undefined; + const local = value.get('local') === true ? { local: true as const } : {}; const ignored = (attr: string, winner: string, why: string): void => warnMappingAttrIgnored(warnings, ownerId, tag, attr, winner, why); @@ -3778,7 +3479,7 @@ function readCallVariableMapping( 'reads nothing else', ); } - return { kind: 'all', ...(local === true ? { local } : {}) }; + return { kind: 'all', ...local }; } if (value.get('source') === '') { @@ -3804,12 +3505,7 @@ function readCallVariableMapping( `an ${tag} carries source without a target`, ); } - return { - kind: 'variable', - source, - target, - ...(local === true ? { local } : {}), - }; + return { kind: 'variable', source, target, ...local }; } if (sourceExpression !== undefined) { @@ -3819,12 +3515,7 @@ function readCallVariableMapping( `an ${tag} carries sourceExpression without a target`, ); } - return { - kind: 'expression', - sourceExpression, - target, - ...(local === true ? { local } : {}), - }; + return { kind: 'expression', sourceExpression, target, ...local }; } throw new UnsupportedCallActivityError( @@ -3840,23 +3531,17 @@ function collectLaneDrops( processId: string, warnings: ImportWarning[], ): void { - const laneSets = (processEl.get('laneSets') as ModdleElement[]) ?? []; - for (const laneSet of laneSets) { + for (const laneSet of processEl.get('laneSets') as ModdleElement[]) { warnLaneSetDrops(laneSet, processId, warnings); } } -/** - * Report every lane in one `bpmn:LaneSet`, descending into a lane's - * `bpmn:childLaneSet`: a nested lane is a lane. - */ function warnLaneSetDrops( laneSet: ModdleElement, fallbackId: string, warnings: ImportWarning[], ): void { - const lanes = (laneSet.get('lanes') as ModdleElement[]) ?? []; - for (const lane of lanes) { + for (const lane of laneSet.get('lanes') as ModdleElement[]) { const laneId = lane.id ?? laneSet.id ?? fallbackId; const laneName = readString(lane, 'name'); warnings.push({ @@ -3874,16 +3559,9 @@ function warnLaneSetDrops( } /** - * The element-valued moddle properties some reader on this transform reads; - * every other BPMN child is content nothing reads, reported by - * {@link collectUnmappedBpmnDrops}. Several are read without being mapped - * one-to-one: `documentation` by {@link readNamed} or - * {@link warnDocumentationDrop}, `extensionElements` by - * {@link collectExtensionDrops}, `laneSets` by {@link collectLaneDrops}, - * `loopCharacteristics` and its four children here by - * {@link readLoopCharacteristics}, `rootElements` by {@link xmlToIr}, - * `participants` and `messageFlows` by {@link warnCollaborationDrops}, and - * `diagrams` is the DI data. + * The element-valued moddle properties some reader in this file reads (a + * warning sweep counts as a reader); every other BPMN child is reported by + * {@link collectUnmappedBpmnDrops}. */ const READ_BPMN_CHILDREN: ReadonlySet = new Set([ 'completionCondition', @@ -3903,24 +3581,21 @@ const READ_BPMN_CHILDREN: ReadonlySet = new Set([ 'participants', 'rootElements', 'script', - 'timeCycle', - 'timeDate', - 'timeDuration', + ...Object.values(TIMER_KIND_TO_CHILD), ]); /** * One {@link ImportWarning} per piece of BPMN content on `el` that no reader * reads: a child outside {@link READ_BPMN_CHILDREN}, and an unnamespaced - * attribute BPMN does not declare. A back-reference moddle fills in from the - * other end is not content and is skipped, and an attribute in a foreign - * namespace is left alone: that is where an editor parks its bookkeeping. + * attribute BPMN does not declare. An attribute in a foreign namespace is + * left alone: that is where an editor parks its bookkeeping. */ function collectUnmappedBpmnDrops( el: ModdleElement, ownerId: string, warnings: ImportWarning[], ): void { - for (const key of Object.keys(el.$attrs ?? {})) { + for (const key of Object.keys(el.$attrs)) { if (key.includes(':') || key === 'xmlns') continue; warnings.push({ elementId: ownerId, @@ -3935,12 +3610,11 @@ function collectUnmappedBpmnDrops( if (prop.isAttr === true || prop.isBody === true) continue; if (prop.isReference === true) continue; if (READ_BPMN_CHILDREN.has(prop.name)) continue; - // A user task's resource roles are read by `readAssignment`; on every - // other activity the engine reads none of them, so the drop stands. + // `readAssignment` reads a user task's roles; no other activity's are read. if (prop.name === 'resources' && el.$type === 'bpmn:UserTask') continue; const value = el.get(prop.name); - // A property holding one element is spelled as that property - // (``), one holding a list as each item's own type. + // A single element is spelled as its property (``), + // a list item as its own type. const items = Array.isArray(value) ? value : [value]; const tag = Array.isArray(value) ? undefined : prop.ns?.name; for (const item of items) { @@ -3969,12 +3643,8 @@ const HANDLED_ROOT_KINDS: ReadonlySet = new Set([ /** * Report the root elements the IR does not model, and the extension content - * parked on the ones it does. The process is swept as it is mapped, so only the - * error, escalation, message, and signal roots are swept here. - * - * Returns the ids of the roots reported whole, so - * {@link collectUnparsableResidualDrops} can recognize one of their own - * children rather than blaming it on the process a second time. + * on the ones it does (the process is swept as it is mapped). Returns the ids + * of the roots reported whole, for {@link collectUnparsableResidualDrops}. */ function collectRootDrops( rootElements: ModdleElement[], @@ -4003,27 +3673,27 @@ function collectRootDrops( `A ${describeUnmapped(root)} root element was not imported ` + IMPORTED_FLOW_NOTE, }); - if (root.id !== undefined) reportedRootIds.add(String(root.id)); + if (root.id !== undefined) reportedRootIds.add(root.id); } return reportedRootIds; } -/** - * Name one unmapped construct: its XML tag plus its id, or its `name` when it - * has no id. `tag` overrides the tag derived from the type. - */ +/** The XML tag plus the id, or the `name` when there is no id; `tag` overrides the tag derived from the type. */ function describeUnmapped(el: ModdleElement, tag?: string): string { const identity = el.id ?? readString(el, 'name'); const name = tag ?? xmlTagOf(el.$type); return identity === undefined ? name : `${name} '${identity}'`; } -/** - * A `bpmn:dataObject`, `bpmn:dataObjectReference`, or `bpmn:dataStoreReference`. - * Reported whole, at the id it carries: unlike a flow node, none of the three - * ever appears as a `sourceRef`/`targetRef`, so no downstream check depends on - * it having been mapped. - */ +export function dataConstructDropMessage(subject: string): string { + return ( + `A ${subject} was not imported: Operaton keeps process variables in ` + + 'its own store and never dispatches on it, so the imported process ' + + 'runs identically.' + ); +} + +/** A data object or reference, reported whole: no flow ends at one, so no downstream check needs it mapped. */ function warnDataConstructDrop( child: ModdleElement, hostId: string | undefined, @@ -4032,19 +3702,11 @@ function warnDataConstructDrop( warnings.push({ elementId: child.id ?? hostId ?? '(unknown)', category: 'unmappedConstruct', - message: - `A ${describeUnmapped(child)} was not imported: Operaton keeps ` + - 'process variables in its own store and never dispatches on it, so ' + - 'the imported process runs identically.', + message: dataConstructDropMessage(describeUnmapped(child)), }); } -/** - * `qualifier` narrows an {@link ENGINE_READS_ELSEWHERE} lookup to the one - * position that reader actually runs at (`'throw'`, `'initial'`); omitted, - * a drop is reported as read nowhere else, which is right for every other - * position the same tag or attribute can appear at. - */ +/** `qualifier` narrows the {@link ENGINE_READS_ELSEWHERE} lookup to the position its reader runs at (`'throw'`, `'initial'`). */ function collectExtensionDrops( el: ModdleElement, ownerId: string, @@ -4060,8 +3722,7 @@ function collectExtensionDrops( * Report `operaton:` attributes {@link CONSUMED_EXTENSION_ATTRS} does not list * for this owner kind. Any other namespace is left alone: that is where an * editor stamps its bookkeeping, and reporting it would bury the drops that - * matter. A document's camunda: spelling reads as `operaton:` by the time this - * runs (the namespace swap in {@link xmlToIr}), so one prefix covers both. + * matter. */ function warnUnreadPrefixedAttrs( el: ModdleElement, @@ -4069,7 +3730,7 @@ function warnUnreadPrefixedAttrs( warnings: ImportWarning[], qualifier?: string, ): void { - for (const key of Object.keys(el.$attrs ?? {})) { + for (const key of Object.keys(el.$attrs)) { const colon = key.indexOf(':'); if (colon === -1) continue; const prefix = key.slice(0, colon); @@ -4101,20 +3762,16 @@ function warnUnreadPrefixedAttrs( } } -/** - * The reason a field drops from a position that holds none: a step's binding - * and a listener's are the only two this tool reads one onto. - */ +/** The reason a field drops from a position that holds none. */ const FIELD_HAS_NO_HOME = 'this tool carries an injected field on the step or the listener whose ' + 'class or delegate binding receives it, on the step whose built-in mail ' + 'or shell behaviour does, and on no other position'; /** - * Report the materialized `` children that - * {@link CONSUMED_EXTENSION_ELEMENTS} does not list for this owner kind. An - * undeclared `operaton:` element leaves no value behind and is reported against - * the document by {@link collectUnparsableResidualDrops} instead. + * Report the `` children {@link CONSUMED_EXTENSION_ELEMENTS} + * does not list for this owner kind. An undeclared `operaton:` element leaves + * no value behind; {@link collectUnparsableResidualDrops} reports it instead. */ function warnUnreadExtensionElements( el: ModdleElement, @@ -4156,10 +3813,10 @@ function warnUnreadExtensionElements( } /** - * Report the attributes the operaton moddle extension declares that the IR does - * not read off this owner kind. A declared attribute parses into a typed - * property, never into `$attrs`, so {@link warnUnreadPrefixedAttrs} cannot see - * it. + * Report the attributes the operaton moddle extension declares that the IR + * does not read off this owner kind. A declared attribute parses into a typed + * property, never into `$attrs`, so {@link warnUnreadPrefixedAttrs} cannot + * see it. */ function warnUnreadDeclaredAttrs( el: ModdleElement, @@ -4173,8 +3830,7 @@ function warnUnreadDeclaredAttrs( if (isConsumedHere(CONSUMED_EXTENSION_ATTRS, el.$type, prop.ns.localName)) { continue; } - // Only what the document wrote: moddle stores a parsed value as an own property. - if (!Object.prototype.hasOwnProperty.call(el, prop.name)) continue; + if (!isAuthored(el, prop.name)) continue; warnUnimportedSetting( warnings, ownerId, @@ -4186,12 +3842,22 @@ function warnUnreadDeclaredAttrs( } } -/** - * Report one setting re-export will not write back: `subject` names it in - * the sentence, and `ownerType` with `name` look up the engine's reader for - * it ({@link ENGINE_READS_ELSEWHERE}). `qualifier`, when given, narrows that - * lookup to the one position the row's reader actually runs at. - */ +export function unimportedSettingMessage( + subject: string, + ownerId: string, + tag: string, + reads?: string, +): string { + return ( + `The ${subject} on '${ownerId}' was not imported: this tool reads no ` + + `such setting on ${tag}, ` + + (reads === undefined + ? 'and the document written back carries none.' + : `though Operaton ${reads}, so the document written back runs without it.`) + ); +} + +/** One setting re-export will not write back; `ownerType` and `name` look up its engine reader in {@link ENGINE_READS_ELSEWHERE}. */ function warnUnimportedSetting( warnings: ImportWarning[], ownerId: string, @@ -4206,12 +3872,12 @@ function warnUnimportedSetting( warnings.push({ elementId: ownerId, category: 'extensionAttribute', - message: - `The ${subject} on '${ownerId}' was not imported: this tool reads no ` + - `such setting on ${describeTag(ownerType)}, ` + - (reads === undefined - ? 'and the document written back carries none.' - : `though Operaton ${reads}, so the document written back runs without it.`), + message: unimportedSettingMessage( + subject, + ownerId, + describeTag(ownerType), + reads, + ), }); } @@ -4222,15 +3888,11 @@ function describeTag(type: string): string { /** * Report every attribute on a consumed extension child that no reader reads, - * descending through the children the readers do read. An undeclared - * `operaton:` attribute on a listener is the motivating case: it would - * otherwise vanish with the listener that imports and leave no trace. - * - * Both spellings are swept, because moddle stores them apart: a declared - * attribute parses into a typed property, an undeclared one lands in `$attrs`. - * - * `key` is the table key `el` resolved to under its parent, so a qualified - * row chains: a child of `a/b` is looked up as `a/b/c` before `c`. + * descending through the children the readers do read. Declared attributes + * (typed properties) and undeclared ones (`$attrs`) are swept apart, since + * moddle stores them apart. `key` is the table key `el` resolved to under its + * parent, so a qualified row chains: a child of `a/b` is looked up as `a/b/c` + * before `c`. */ function warnUnreadChildAttrs( el: ModdleElement, @@ -4255,11 +3917,10 @@ function warnUnreadChildAttrs( if (prop.isAttr !== true) continue; const localName = prop.ns?.localName ?? prop.name; if (consumed.has(localName)) continue; - // Only what the document wrote: moddle stores a parsed value as an own property. - if (!Object.prototype.hasOwnProperty.call(el, prop.name)) continue; + if (!isAuthored(el, prop.name)) continue; report(localName); } - for (const key of Object.keys(el.$attrs ?? {})) { + for (const key of Object.keys(el.$attrs)) { if (key === 'xmlns' || key.startsWith('xmlns:')) continue; report(key); } @@ -4275,13 +3936,13 @@ function warnUnreadChildAttrs( } } -/** The {@link CONSUMED_CHILD_ATTRS} key for `type` under `parentKey`: the position-qualified row when one exists, else the bare `$type`. */ +/** The {@link CONSUMED_CHILD_ATTRS} key for `type` under `parentKey`: the qualified row when one exists, else the bare `$type`. */ function consumedKeyOf(type: string, parentKey: string): string { const qualified = `${parentKey}/${type}`; return CONSUMED_CHILD_ATTRS.has(qualified) ? qualified : type; } -/** Name one extension child: its XML tag, plus the word its row at `key` reads that tells it from its siblings. */ +/** The child's XML tag plus the identity its row at `key` reads. */ function describeChild(el: ModdleElement, key: string): string { const consumed = CONSUMED_CHILD_ATTRS.get(key); const identity = CHILD_IDENTITY_ATTRS.filter( @@ -4303,11 +3964,7 @@ function xmlTagOf(type: string): string { ); } -/** - * The elements moddle materialized under `el`. A property declared as an - * element but typed as a string (an `operaton:field`'s body) holds none, and - * a reference holds an element that lives elsewhere. - */ +/** The elements moddle materialized under `el`; a string-typed element property (an `operaton:field`'s body) holds none. */ function childElements(el: ModdleElement): ModdleElement[] { const children: ModdleElement[] = []; for (const prop of el.$descriptor?.properties ?? []) { @@ -4323,14 +3980,9 @@ function childElements(el: ModdleElement): ModdleElement[] { return children; } -/** `${...}` and `#{...}` both name an EL expression; Operaton accepts either syntax. */ -const EXPRESSION_BODY = /\$\{|#\{/; - /** - * A `#{...}` body the printer spells as bare DSL loses its opener, since the - * compiler writes a bare expression inside `${...}`; one it keeps quoted, an - * out-of-subset or composite body, prints and lowers as written and needs no - * report. Only a leading `#{` is an opener at all. + * A `#{...}` body the printer spells as bare DSL comes back inside `${...}`; + * one it keeps quoted (out of subset or composite) lowers as written. */ function noteRewrappedExpression( body: string | undefined, @@ -4378,13 +4030,9 @@ function mapStartEvent( /** * The initiator and the start form, read off the process's own start alone: * `BpmnParse.parseStartEvents` sends every other start to - * `parseScopeStartEvent`, which reads no `operaton:` attribute and no form - * (`parseProcessDefinitionStartEvent` and `parseStartFormHandlers` run for - * the process's alone), and the validator refuses both on a nested start. - * Both start mappers come through here because `initiator` and - * `operaton:formData` are declared read on `bpmn:StartEvent`, which silences - * the sweep for a nested start as much as for the process's own, so the drop - * has to be reported by hand. + * `parseScopeStartEvent`, which reads neither. Both are marked read on + * `bpmn:StartEvent`, which silences the sweep for a nested start too, so the + * drop is reported by hand. */ function readStartAttributes( el: ModdleElement, @@ -4426,11 +4074,7 @@ function readStartAttributes( return {}; } -/** - * The subjects Operaton parses off a start event but never acts on, each with - * the move that catches it instead. The remedies match what the validator says - * at the same position, so authoring and importing read alike. - */ +/** The triggers Operaton parses off a process start but never acts on, each with the validator's remedy for the same position. */ const IGNORED_START_SUBJECTS: ReadonlyMap< string, { subject: string; remedy: string } @@ -4460,23 +4104,22 @@ const IGNORED_START_SUBJECTS: ReadonlyMap< ], ]); -/** - * The tag each trigger a process start may carry is written with. The - * `satisfies` clause demands a row per word, so a word added to the vocabulary - * opens the import path with it rather than leaving the two to drift. - */ -const START_CARRIED_TAGS = { - message: 'bpmn:MessageEventDefinition', - signal: 'bpmn:SignalEventDefinition', - timer: 'bpmn:TimerEventDefinition', - condition: 'bpmn:ConditionalEventDefinition', -} satisfies Record<(typeof START_TRIGGERS)[number], string>; +/** The tags the triggers of one position are written with; `condition` is the one word the IR spells differently. */ +function definitionTagsOf( + triggers: readonly ((typeof ON_TRIGGERS)[number] | 'link')[], +): readonly string[] { + return triggers.map( + (trigger) => + EVENT_DEFINITION_TAG[trigger === 'condition' ? 'conditional' : trigger], + ); +} -/** - * The trigger on a container's own start event. An event handler's start is - * entered through {@link mapEventSubProcessStart}, which requires one - * definition and takes a wider set of kinds. - */ +const START_CARRIED_TAGS = definitionTagsOf(START_TRIGGERS); +const THROW_CARRIED_TAGS = definitionTagsOf(THROW_TRIGGERS); +const EMIT_CARRIED_TAGS = definitionTagsOf(EMIT_TRIGGERS); +const AWAIT_CARRIED_TAGS = definitionTagsOf(CATCH_TRIGGERS); + +/** The trigger on a container's own start; an event handler's start goes through {@link mapEventSubProcessStart}. */ function readStartTrigger( el: ModdleElement, id: string, @@ -4514,14 +4157,14 @@ function readStartTrigger( ignored.remedy, ); } - if (!Object.values(START_CARRIED_TAGS).includes(defEl.$type)) { + if (!START_CARRIED_TAGS.includes(defEl.$type)) { throw new UnsupportedEventDefinitionError(id, 'start', defEl.$type); } const definition = readCatchEventDefinition(defEl, id, warnings, 'start'); if ( definition.kind === 'message' && - EXPRESSION_BODY.test(definition.messageName) + EXPRESSION_ANYWHERE.test(definition.messageName) ) { throw new UnsupportedEventFeatureError( id, @@ -4535,21 +4178,11 @@ function readStartTrigger( return definition; } -/** - * The tag each end-carried definition is written with. Both keep the end's - * label, because both print on the statement itself instead of as a throw, - * which has no label slot. - */ -const END_CARRIED_TAGS = { - terminate: 'bpmn:TerminateEventDefinition', - cancel: 'bpmn:CancelEventDefinition', -} satisfies Record<(typeof END_TRIGGERS)[number], string>; - -/** The kind a definition tag imports as, or `undefined` for a raised one. */ +/** The kind a definition tag imports as, or `undefined` for a raised one; a terminate and a cancel print on the end statement itself, which keeps its label. */ function endCarriedKind( tag: string, ): (typeof END_TRIGGERS)[number] | undefined { - return END_TRIGGERS.find((kind) => END_CARRIED_TAGS[kind] === tag); + return END_TRIGGERS.find((kind) => EVENT_DEFINITION_TAG[kind] === tag); } function mapEndEvent( @@ -4603,13 +4236,7 @@ function mapEndEvent( ...readThrowEventAttributes(el, id, undefined, warnings), }; } - if ( - defEl.$type !== 'bpmn:ErrorEventDefinition' && - defEl.$type !== 'bpmn:EscalationEventDefinition' && - defEl.$type !== 'bpmn:MessageEventDefinition' && - defEl.$type !== 'bpmn:SignalEventDefinition' && - defEl.$type !== 'bpmn:CompensateEventDefinition' - ) { + if (!THROW_CARRIED_TAGS.includes(defEl.$type)) { throw new UnsupportedEventDefinitionError(id, 'end', defEl.$type); } @@ -4639,17 +4266,11 @@ function mapEndEvent( /** * `BpmnParse.parseEndEvents` runs the mapping through - * `parseActivityInputOutput`, whose output check fails the deployment on - * this tag alone; the input half is read there and reported as a drop by - * the sweep, since the IR's end carries no mapping. + * `parseActivityInputOutput`, whose output check fails the deployment on this + * tag alone; the input half is read there and reported as a drop by the sweep. */ function refuseEndOutputParameters(el: ModdleElement, id: string): void { - const io = onlyExtensionElement( - el, - 'operaton:InputOutput', - id, - 'BpmnParseUtil.parseInputOutput', - ); + const io = onlyIoMapping(el, id); const outputs = (io?.get('outputParameters') as ModdleElement[] | undefined) ?? []; if (outputs.length === 0) return; @@ -4662,24 +4283,12 @@ function refuseEndOutputParameters(el: ModdleElement, id: string): void { ); } -/** As {@link START_CARRIED_TAGS}, for the triggers an emit may carry. */ -const EMIT_CARRIED_TAGS = { - escalation: 'bpmn:EscalationEventDefinition', - message: 'bpmn:MessageEventDefinition', - signal: 'bpmn:SignalEventDefinition', - compensation: 'bpmn:CompensateEventDefinition', - link: 'bpmn:LinkEventDefinition', -} satisfies Record<(typeof EMIT_TRIGGERS)[number], string>; - /** * A definition-less throw (the Modeler's milestone marker) is a step: * `BpmnParse.parseIntermediateThrowEvent` gives it * `IntermediateThrowNoneEventActivityBehavior`, whose `execute` is the bare - * `leave` a `TaskActivityBehavior` performs, and reads the same async and - * listener settings off it (`parseAsynchronousContinuationForActivity`, - * `parseExecutionListenersOnScope`). `createActivityOnScope` keeps the tag - * as the activity type, so history and Cockpit report `task` for it once - * written back; the warning names that rewrite, as the manual task's does. + * `leave` a `TaskActivityBehavior` performs, with the same async and listener + * settings; only the activity type history reports changes. */ function mapIntermediateThrowEvent( el: ModdleElement, @@ -4725,7 +4334,7 @@ function mapIntermediateThrowEvent( 'throw; write "throw error" to end the path instead', ); } - if (!Object.values(EMIT_CARRIED_TAGS).includes(defEl.$type)) { + if (!EMIT_CARRIED_TAGS.includes(defEl.$type)) { throw new UnsupportedEventDefinitionError( id, 'intermediate throw', @@ -4733,24 +4342,22 @@ function mapIntermediateThrowEvent( ); } - const eventDefinition = readThrowEventDefinition(defEl, id, warnings); + // The two checks above admit only the emittable kinds, so the reader's + // error arm is unreachable here and the cast holds. + const eventDefinition = readThrowEventDefinition( + defEl, + id, + warnings, + ) as EmitEventDefinition; if (eventDefinition.kind === 'link') { warnNamedDrop(el, id, 'an emit link', warnings, eventDefinition.linkName); warnLinkThrowEngineSettings(el, id, warnings); return { kind: 'intermediateThrowEvent', id, eventDefinition }; } warnNamedDrop(el, id, 'an emit', warnings); - // `parseActivity` runs the mapping on this throw as on the none throw - // above, which carries it as a step; the block is marked read on the tag - // for that arm's sake, so this node, which has no slot, reports it by hand. - // A second block still fails the deployment (Element.elementNS), so it is - // refused, not warned twice. - const io = onlyExtensionElement( - el, - 'operaton:InputOutput', - id, - 'BpmnParseUtil.parseInputOutput', - ); + // The block is marked read on the tag for the none throw's sake, which + // carries it as a step; this node has no slot, so it reports it by hand. + const io = onlyIoMapping(el, id); if (io !== undefined) { warnUnimportedSetting(warnings, id, childSubject(io), el.$type, io.$type); } @@ -4771,12 +4378,9 @@ function mapIntermediateThrowEvent( } /** - * The engine attributes of an end or an intermediate throw, after the one - * extension block the tag itself carries for `parseServiceTaskLike`: - * `operaton:properties`, which `parseExternalServiceTask` reads off the event - * element of a thrown message bound with `operaton:type="external"` and - * nothing reads off any other shape of these two tags. The block is marked - * read on both ({@link THROW_EVENT_TAGS}), so every shape reports it here. + * The engine attributes of an end or an intermediate throw, after the + * `operaton:properties` block {@link THROW_EVENT_TAGS} marks read: only an + * external-bound thrown message reaches its reader, so every shape reports it. */ function readThrowEventAttributes( el: ModdleElement, @@ -4797,11 +4401,9 @@ function readThrowEventAttributes( } /** - * One drop of what `BpmnParse.parseExternalServiceTask` reads: with an - * external binding it reads `what` off `readOff` (the message definition or - * the event element, the two elements `parseServiceTaskLike` is handed) and - * this surface's throw has no position for it; under any other binding, or - * none, nothing reaches that reader. + * One drop of what `BpmnParse.parseExternalServiceTask` reads: under an + * external binding it reads `what` off `readOff` and the throw has no + * position for it; under any other binding nothing reaches that reader. */ function warnThrownMessageExtraDrop( id: string, @@ -4826,7 +4428,6 @@ function warnThrownMessageExtraDrop( }); } -/** True when any extension child of `el` is a connector (a camunda: spelling types the same). */ function hasConnector(el: ModdleElement): boolean { return extensionValues(el).some( (value) => value.$type === 'operaton:Connector', @@ -4834,21 +4435,14 @@ function hasConnector(el: ModdleElement): boolean { } /** - * What makes Operaton really send a thrown message: the same implementation a - * service task runs, written on the definition rather than on the event. A - * message thrown without one records and continues, so a definition naming none - * is imported as it stands, though a lone `topic` warns: the engine reaches an - * external worker only with `type="external"` beside it. A connector is that - * implementation in element form, which this surface cannot keep, so it refuses - * rather than turning the send into a no-op. - * - * `parseServiceTaskLike` reads the rest of a service task's settings off the - * definition too, and the throw statement has a slot for none of them: the - * result variable is refused where the engine refuses it beside a class or a - * delegate (`elementName` is the tag as that refusal names it) and where it - * stores the expression's value under it, and dropped where the `type` - * branch never reads it; the fields, the priority and the error mappings are - * dropped naming what reads each. + * What makes Operaton send a thrown message: a service task's + * implementation, written on the definition. A message thrown without one + * records and continues, so a definition naming none imports as it stands; a + * connector is that implementation in element form, which this surface cannot + * keep, so it refuses rather than turning the send into a no-op. The throw + * statement has a slot for nothing else `parseServiceTaskLike` reads off the + * definition, so the result variable is refused where the engine refuses or + * stores it and the rest is dropped naming its reader. */ function readThrownMessageBinding( defEl: ModdleElement, @@ -4963,29 +4557,13 @@ function readThrownMessageBinding( /** The triggers an await may head, as the refusals below name them. */ const AWAITABLE_TRIGGERS = formatPlainWordList(CATCH_TRIGGERS); -/** As {@link START_CARRIED_TAGS}, for the triggers an await may carry. */ -const AWAIT_CARRIED_TAGS = { - message: 'bpmn:MessageEventDefinition', - timer: 'bpmn:TimerEventDefinition', - signal: 'bpmn:SignalEventDefinition', - condition: 'bpmn:ConditionalEventDefinition', - link: 'bpmn:LinkEventDefinition', -} satisfies Record<(typeof CATCH_TRIGGERS)[number], string>; - function mapIntermediateCatchEvent( el: ModdleElement, warnings: ImportWarning[], ): IntermediateCatchEvent { const id = requireId(el); - // A second block still fails the deployment (Element.elementNS); the - // single-block drop is reported by the owner sweep, which has no - // multiplicity of its own to check. - onlyExtensionElement( - el, - 'operaton:InputOutput', - id, - 'BpmnParseUtil.parseInputOutput', - ); + // The single-block drop is reported by the owner sweep, which checks no multiplicity. + onlyIoMapping(el, id); if (el.get('parallelMultiple') === true) { throw new UnsupportedEventFeatureError( @@ -5012,7 +4590,7 @@ function mapIntermediateCatchEvent( ); const [defEl] = defs; - if (!Object.values(AWAIT_CARRIED_TAGS).includes(defEl.$type)) { + if (!AWAIT_CARRIED_TAGS.includes(defEl.$type)) { throw new UnsupportedEventFeatureError( id, `an await cannot carry a ${defEl.$type}: only ${AWAITABLE_TRIGGERS} ` + @@ -5023,8 +4601,7 @@ function mapIntermediateCatchEvent( ); } - // Every kind reaching here is one readCatchEventDefinition maps, so its final - // refusal is unreachable and the cast holds. + // The tag check above admits only kinds the reader maps, so the cast holds. const eventDefinition = readCatchEventDefinition( defEl, id, @@ -5045,13 +4622,7 @@ function mapIntermediateCatchEvent( }; } -/** - * An event handler is entered by its trigger, so a multi-instance repetition - * around one says nothing Operaton can honor. A standard loop is not refused - * here: `readLoopCharacteristics` drops it with a warning on a handler the - * same way it does on every other host, since `parseMultiInstanceLoopCharacteristics` - * treats it as absent regardless of what carries it. - */ +/** A standard loop is not refused here: {@link readLoopCharacteristics} drops it with a warning on every host alike. */ function refuseLoopCharacteristics(el: ModdleElement, id: string): void { const loop = getEl(el, 'loopCharacteristics'); if (loop !== undefined && loop.$type === MULTI_INSTANCE) { @@ -5063,23 +4634,21 @@ function refuseLoopCharacteristics(el: ModdleElement, id: string): void { } } +export function loopDroppedMessage(tag: string, id: string): string { + return ( + `The ${tag} on '${id}' was not imported: Operaton does not run one ` + + 'at all, it deploys the step and runs it once, so the imported step ' + + 'runs once too.' + ); +} + /** * How often a step runs, or `undefined` for one that runs once. - * - * Each pair of spellings imports into one field here, with a warning naming - * what was shadowed and the rule that decided it ({@link readCollection}, - * {@link readElementVariable}). What the engine refuses to deploy is refused - * here as well, rather than imported into a process that cannot start. - * `bpmn:standardLoopCharacteristics` is neither: * `parseMultiInstanceLoopCharacteristics` looks only for the multi-instance - * child and returns null otherwise, so the engine builds the activity through - * its normal arm and runs it once, and a standard loop drops with a warning - * rather than refusing. - * - * A `completionCondition` reaches `createExpression` as text alone: a body - * with no `${`/`#{` opener is a literal there, which - * `MultiInstanceActivityBehavior.completionConditionSatisfied` throws on, and - * a `language` attribute is read by nothing. + * child, so a `bpmn:standardLoopCharacteristics` runs once and drops with a + * warning. A `completionCondition` reaches `createExpression` as text alone, + * so a body with no `${`/`#{` opener is a literal there and its `language` + * is read by nothing. */ function readLoopCharacteristics( el: ModdleElement, @@ -5092,10 +4661,7 @@ function readLoopCharacteristics( warnings.push({ elementId: id, category: 'unmappedConstruct', - message: - `The ${xmlTagOf(loopEl.$type)} on '${id}' was not imported: ` + - 'Operaton does not run one at all, it deploys the step and runs ' + - 'it once, so the imported step runs once too.', + message: loopDroppedMessage(xmlTagOf(loopEl.$type), id), }); return undefined; } @@ -5127,7 +4693,7 @@ function readLoopCharacteristics( conditionEl === undefined ? undefined : readString(conditionEl, 'body'); if ( completionCondition !== undefined && - !EXPRESSION_BODY.test(completionCondition) + !EXPRESSION_ANYWHERE.test(completionCondition) ) { warnings.push({ elementId: id, @@ -5190,12 +4756,11 @@ function readCardinality( 'its bpmn:loopCardinality is empty, so Operaton has no number of runs to read', ); } - // A literal the printer has no bare form for would be re-wrapped as an - // expression, which is a different document. BARE_CARDINALITY is the - // printer's own test, so the two directions cannot drift apart. + // A literal the printer has no bare form for would come back re-wrapped as + // an expression, a different document; BARE_CARDINALITY is the printer's test. if ( !BARE_CARDINALITY.test(cardinality) && - !EXPRESSION_BODY.test(cardinality) + !EXPRESSION_ANYWHERE.test(cardinality) ) { throw new UnsupportedLoopCharacteristicsError( id, @@ -5210,19 +4775,14 @@ function readCardinality( } /** - * Operaton reads the text of `bpmn:loopDataInputRef` as the name of the - * collection variable, so a document naming a process variable rather than an - * element in the document is the engine's own canonical spelling. moddle - * resolves the slot as an id reference, answering with the element when the - * text names one and dropping it otherwise, so the dropped text is read back - * from what the parse reported. - * + * Operaton reads the text of `bpmn:loopDataInputRef` as the collection + * variable's name, while moddle resolves it as an id reference and drops one + * naming no element, so the dropped text is read back from the parse warning. * `BpmnParse.parseMultiInstanceLoopCharacteristics` stores each spelling in * one of two fields by whether its text contains `{`, the reference second, * and `MultiInstanceActivityBehavior.resolveNrOfInstances` reads the - * expression field before the variable field. So two values of one shape - * overwrite each other and the reference wins; of two shapes, the expression - * wins whichever came second. + * expression field first: of one shape the reference wins, of two shapes the + * expression wins. */ function readCollection( loopEl: ModdleElement, @@ -5279,9 +4839,8 @@ function readElementVariable( }); } const elementVariable = itemName ?? setting; - // A name outside the printer's one form comes back out as something the - // language cannot parse. BARE_ELEMENT_VARIABLE is that form, so the two - // directions cannot drift apart. + // A name outside the printer's one form (BARE_ELEMENT_VARIABLE) comes back + // as something the language cannot parse. if ( elementVariable !== undefined && !BARE_ELEMENT_VARIABLE.test(elementVariable) @@ -5297,7 +4856,6 @@ function readElementVariable( return elementVariable; } -/** `first` and `second` are the two spellings in the sentence's order; `rule` says which the engine keeps. */ function warnShadowedRepetitionField( warnings: ImportWarning[], id: string, @@ -5320,10 +4878,7 @@ function warnShadowedRepetitionField( }); } -/** - * `BpmnParse.checkActivityOutputParameterSupported` fails the deployment of - * an output mapping on a step that repeats. - */ +/** `BpmnParse.checkActivityOutputParameterSupported` fails the deployment of an output mapping on a step that repeats. */ function refuseOutputParameters(el: ModdleElement, id: string): void { const io = extensionValues(el).find( (value) => value.$type === 'operaton:InputOutput', @@ -5350,14 +4905,10 @@ const IGNORED_REPETITION_REFS = [ ] as const; /** - * Report what the repetition carries and nothing reads. The generic sweeps see - * its plain children and its undeclared attributes; the three references, the - * `behavior` attribute and `operaton:jobPriority` are named by hand, because - * one sweep skips a reference by design, the other looks at `operaton:` - * attributes only, and the generic sentence would read as this tool's - * limitation rather than as a setting the engine never reads. A reference is - * reported whether it resolved or not: moddle drops one naming no element, and - * that drop is exactly what has to be reported. + * Report what the repetition carries and nothing reads. The three references, + * `behavior` and `operaton:jobPriority` are named by hand: the sweeps skip a + * reference and a BPMN attribute, and the generic sentence would read as this + * tool's limitation rather than a setting the engine never reads. */ function sweepRepetition( loopEl: ModdleElement, @@ -5379,9 +4930,7 @@ function sweepRepetition( if (behavior !== undefined && behavior !== 'All') { warnRepetitionContentIgnored(warnings, id, `behavior="${behavior}"`); } - // Operaton reads a job priority in `createActivityOnScope`, off the step, and - // the repetition's own scope is never built there, so the setting written - // here reaches no job. + // `createActivityOnScope` reads a job priority off the step alone. if (readNamespacedAttr(loopEl, 'jobPriority') !== undefined) { warnRepetitionContentIgnored(warnings, id, 'operaton:jobPriority'); } @@ -5439,12 +4988,7 @@ const ROLE_READERS: ReadonlyMap = new Map([ const USER_PREFIX = 'user('; const GROUP_PREFIX = 'group('; -/** - * `BpmnParse.parseCommaSeparatedList`, as the engine writes it: a `$` or a - * `{` opens an expression and a `}` closes one, and only a comma outside - * splits, so `${groupOf(a, b)}` stays one entry. Entries are trimmed, and an - * empty tail is dropped. - */ +/** `BpmnParse.parseCommaSeparatedList`: only a comma outside a `${...}` splits, so `${groupOf(a, b)}` stays one entry. */ function splitAsEngine(text: string): string[] { const entries: string[] = []; let current = ''; @@ -5465,16 +5009,12 @@ function splitAsEngine(text: string): string[] { return entries; } -/** `BpmnParse.getAssignmentId`: the text between the prefix and the last character, trimmed. */ +/** `BpmnParse.getAssignmentId`. */ function assignmentId(entry: string, prefix: string): string { return entry.slice(prefix.length, -1).trim(); } -/** - * A role's entries as `parsePotentialOwnerResourceAssignment` and - * `parsePotentialStarterResourceAssignment` sort them: `user(...)` to the - * users, `group(...)` and a bare entry to the groups. - */ +/** As `parsePotentialOwnerResourceAssignment` sorts them: `user(...)` to the users, `group(...)` and a bare entry to the groups. */ function splitCandidates(text: string): { users: string[]; groups: string[] } { const users: string[] = []; const groups: string[] = []; @@ -5490,7 +5030,7 @@ function splitCandidates(text: string): { users: string[]; groups: string[] } { return { users, groups }; } -/** The role entries first and the attribute's text after, as one list the engine reads the same. */ +/** The role entries first and the attribute's text after, as the engine builds the list. */ function mergeCandidates( fromRoles: string[], fromAttr: string | undefined, @@ -5500,11 +5040,10 @@ function mergeCandidates( } /** - * The text of a role's ``. Every role reader fetches - * this child by tag (`Element.elementsNS`), so only that spelling reaches the - * engine; moddle gives the same `$type` to ``, and the `xsi:type` attribute is what - * tells the two apart. + * The text of a role's ``, the one spelling the role + * readers fetch by tag (`Element.elementsNS`); moddle gives the same `$type` + * to ``, so the raw + * `xsi:type` tells the two apart. */ function formalExpressionTextOf(role: ModdleElement): string | undefined { const rae = getEl(role, 'resourceAssignmentExpression'); @@ -5516,17 +5055,17 @@ function formalExpressionTextOf(role: ModdleElement): string | undefined { } /** - * Merged as the engine builds its lists: `BpmnParse.parseTaskDefinition` - * reads the roles first and `parseUserTaskCustomExtensions` appends the - * attributes' entries after. Roles match on the exact `$type`: bpmn-moddle - * derives `PotentialOwner` from `HumanPerformer`, and the engine reads by tag. + * `BpmnParse.parseTaskDefinition` reads the roles first and + * `parseUserTaskCustomExtensions` appends the attributes' entries after. + * Roles match on the exact `$type`: bpmn-moddle derives `PotentialOwner` from + * `HumanPerformer`, and the engine reads by tag. */ function readAssignment( el: ModdleElement, id: string, warnings: ImportWarning[], ): Pick { - const roles = (el.get('resources') as ModdleElement[] | undefined) ?? []; + const roles = el.get('resources') as ModdleElement[]; const attrAssignee = readText(el, 'operaton:assignee'); const performers = roles.filter((r) => r.$type === 'bpmn:HumanPerformer'); if (performers.length > 1) { @@ -5605,8 +5144,7 @@ function readAssignment( unresolvedRef(role, 'resourceRef') !== undefined ? ['resourceRef'] : []), - ...((role.get('resourceParameterBindings') as ModdleElement[] | undefined) - ?.length + ...((role.get('resourceParameterBindings') as ModdleElement[]).length > 0 ? ['resourceParameterBindings'] : []), ]; @@ -5638,11 +5176,9 @@ function readAssignment( } /** - * The deployed form a user task renders, and the binding resolving which - * version of it. Operaton's `parseFormDefinition` refuses to deploy a task - * naming a form key beside a form reference, and refuses a form reference - * whose binding is absent or outside the three it resolves, so both shapes - * refuse here rather than importing a task that would never deploy. + * `parseFormDefinition` refuses to deploy a form key beside a form reference, + * and a form reference whose binding is absent or outside the three it + * resolves, so both shapes refuse here. */ function readFormRef( el: ModdleElement, @@ -5692,26 +5228,26 @@ function mapTask(el: ModdleElement, warnings: ImportWarning[]): Task { }; } +export function manualTaskMessage(id: string): string { + return ( + `The bpmn:manualTask '${id}' imports as a plain step: token flow, ` + + 'waiting, listeners, async and job configuration are all unchanged, ' + + "but history and Cockpit will report its activity type as 'task' " + + "rather than 'manualTask'." + ); +} + /** - * `ManualTaskActivityBehavior` is an empty subclass of `TaskActivityBehavior`, - * and `BpmnParse.parseManualTask` mirrors `parseTask` line for line but for the - * behaviour class, so token flow, waiting, listeners, async and job - * configuration are all unchanged from a plain task. `createActivityOnScope` - * still stores the raw tag as the activity type, and - * `HistoricActivityInstance.getActivityType()` reports it, so history and - * Cockpit show `task` where the source wrote `manualTask`; the warning names - * that rewrite. + * `ManualTaskActivityBehavior` is an empty subclass of `TaskActivityBehavior` + * and `BpmnParse.parseManualTask` mirrors `parseTask`, so only the activity + * type `createActivityOnScope` stores for history differs from a plain task. */ function mapManualTask(el: ModdleElement, warnings: ImportWarning[]): Task { const task = mapTask(el, warnings); warnings.push({ elementId: task.id, category: 'unmappedConstruct', - message: - `The bpmn:manualTask '${task.id}' imports as a plain step: token ` + - 'flow, waiting, listeners, async and job configuration are all ' + - 'unchanged, but history and Cockpit will report its activity type as ' + - "'task' rather than 'manualTask'.", + message: manualTaskMessage(task.id), }); return task; } @@ -5722,8 +5258,7 @@ function mapReceiveTask( ): ReceiveTask { const id = requireId(el); const named = readNamed(el, id, warnings); - // A missing messageRef is a legitimate wait state, so it is imported rather - // than refused. + // A missing messageRef is a legitimate wait state. const messageName = getEl(el, 'messageRef') === undefined ? undefined @@ -5749,8 +5284,7 @@ function mapServiceTask( const binding = readServiceTaskBinding(el, id, element, warnings); const resultVariable = readResultVariable(el, id, warnings); // `parseServiceTaskLike` hands the variable to the expression behaviour - // alone; its `type` branches never read it, so the value deploys and does - // nothing, and the compile side warns at the same setting. + // alone, so under a `type` binding it deploys and does nothing. if ( resultVariable !== undefined && (binding.kind === 'external' || binding.kind === 'builtin') @@ -5777,12 +5311,7 @@ function mapServiceTask( }; } -/** - * `BpmnParse.parseResultVariable` reads `resultVariable` and falls back to - * the older `resultVariableName`, on a service-like task, a script task and a - * DMN task alike; the fallback is respelled on import and the shadowed one - * reported. - */ +/** `BpmnParse.parseResultVariable` reads `resultVariable` and falls back to `resultVariableName`. */ function readResultVariable( el: ModdleElement, id: string, @@ -5821,14 +5350,11 @@ const SERVICE_TASK_LIKE_SUBJECT = { } as const; /** - * `ConnectorParseListener.parseConnectorElement` overwrites whatever - * behaviour `parseServiceTaskLike` set from `operaton:class`, `expression`, - * `delegateExpression`, or `type`, on a Connect-enabled engine; without the - * plugin the engine runs that attribute instead. The same file has two - * possible executions, which no import warning can honestly summarise, so - * this refuses rather than choosing one. + * `ConnectorParseListener.parseConnectorElement` overwrites whatever behaviour + * `parseServiceTaskLike` set, on a Connect-enabled engine alone, so the same + * file has two possible executions and no warning can summarise it. */ -const CONNECTOR_CONSTRUCT = +export const CONNECTOR_CONSTRUCT = 'an element, which the Connect plugin runs in place ' + 'of whatever operaton:class, expression, delegateExpression, or type ' + 'names beside it, and which an engine without the plugin runs instead ' + @@ -5844,13 +5370,12 @@ function readServiceTaskBinding( const refusal = (construct: string): Error => new UnsupportedServiceTaskFormError(id, construct, subject); - // Above both the decision and the code reads: a connector wins at runtime - // over any of them, so refusing here also replaces "no execution - // discriminator" with the true cause for a connector-only task. + // Before both reads: a connector wins at runtime over any of them, and a + // connector-only task would otherwise read as "no execution discriminator". if (hasConnector(el)) throw refusal(CONNECTOR_CONSTRUCT); - // The decision reference is the engine's discriminator, so it is read before - // the code forms a business rule task may otherwise fall back to. + // The decision reference is the engine's discriminator, read before the + // code forms a business rule task may fall back to. const binding = (element === 'businessRule' ? readDecisionBinding(el, id, refusal, warnings) @@ -5873,13 +5398,9 @@ function readServiceTaskBinding( } /** - * `BpmnParse.parseServiceTaskLike` builds only the `expression` behaviour - * with the result variable and fails the deployment when a `class` or - * `delegateExpression` binding carries one; its `parseResultVariable` reads - * the older `resultVariableName` spelling too. The `type` branches never - * read it, and a `decisionRef` reads it on its own path. - * - * @param elementName The tag as the engine's refusal names it (`serviceTask`). + * `BpmnParse.parseServiceTaskLike` fails the deployment when a `class` or + * `delegateExpression` binding carries a result variable in either spelling; + * `elementName` is the tag as that refusal names it (`serviceTask`). */ function refuseResultVariableBeside( binding: ServiceTaskBinding, @@ -5898,7 +5419,7 @@ function refuseResultVariableBeside( ); } -/** The result-variable spelling the element writes, in the order `parseResultVariable` reads them. */ +/** The first result-variable spelling the element writes, in `parseResultVariable`'s order. */ function writtenResultVariableAttr( el: ModdleElement, ): 'resultVariable' | 'resultVariableName' | undefined { @@ -5908,10 +5429,10 @@ function writtenResultVariableAttr( } /** - * Refuse the field lists the engine's parse refuses, in its order: the shell - * value shapes and flags come before the missing-field checks, and the - * undeclared name last. The flag check is case-insensitive as the engine's - * is, so `"True"` imports as written with a warning (see `SHELL_FLAG_FIELDS`). + * Refuse the field lists the engine's parse refuses, in its order: shell + * value shapes and flags, then missing fields, then an undeclared name. The + * flag check is case-insensitive as the engine's is, so `"True"` imports as + * written with a warning. */ function refuseBuiltinShapes( binding: Extract, @@ -6056,12 +5577,10 @@ function withExternalExtras( } /** - * Checked in the order `parseOperatonErrorEventDefinitions` branches: a - * definition with no `errorRef` is skipped whole, one with a reference and no - * `expression` fails the deployment, and a reference naming no root is read - * as the code ({@link readDanglingErrorCode}). moddle deletes a reference it - * cannot resolve, so the skipped shape and the dangling one are told apart - * by the recorded text alone. `undefined` for the skipped shape. + * In the order `parseOperatonErrorEventDefinitions` branches: a definition + * with no `errorRef` is skipped whole (`undefined`), one with a reference and + * no `expression` fails the deployment, and a reference naming no root is + * read as the code ({@link readDanglingErrorCode}). */ function readErrorMapping( defEl: ModdleElement, @@ -6108,20 +5627,15 @@ function readErrorMapping( return { errorCode, condition }; } -/** - * Where an implementation is read from: a service-like tag carries every form, - * a thrown message's definition every form but the built-in ones, since the - * fields they require have no place on a throw. - */ +/** A thrown message's definition carries every form but the built-in ones, whose fields have no place on a throw. */ type BindingHost = 'task' | 'thrownMessage'; /** * The implementation Operaton resolves, in `parseServiceTaskLike`'s order: * `operaton:type` outranks every code attribute, then `class`, then - * `delegateExpression`, then `expression`. A `type` this position cannot carry - * returns `undefined` rather than falling back to a code attribute the engine - * would never reach, and so does an element naming no implementation at all; - * the caller reads either as a refusal or as a legal absence. + * `delegateExpression`, then `expression`. A `type` this position cannot + * carry returns `undefined` rather than falling back to a code attribute the + * engine would never reach. */ function readCodeOrExternalBinding( el: ModdleElement, @@ -6228,11 +5742,7 @@ function readDecisionBinding( }; } -/** - * The three settings only a named decision gives meaning to, which Operaton - * ignores without one. The consumption table marks them read on every business - * rule task, so a code-bound one reports them here or not at all. - */ +/** The settings only a named decision gives meaning to; the consumption table marks them read on every business rule task. */ const DECISION_MODIFIER_ATTRS = [ 'decisionRefBinding', 'decisionRefVersion', @@ -6240,14 +5750,10 @@ const DECISION_MODIFIER_ATTRS = [ 'mapDecisionResult', ] as const; -/** The two settings only a named form gives meaning to; see {@link readFormRef}. */ +/** The two settings only a named form gives meaning to. */ const FORM_REF_MODIFIER_ATTRS = ['formRefBinding', 'formRefVersion'] as const; -/** - * Report the settings that pin or shape a reference the element never makes. - * The consumption table marks them read on the owner kind, so they leave with - * a warning here or with none. `ref` names the missing reference. - */ +/** The settings that shape a reference the element never makes; `ref` names the missing reference. */ function warnDanglingModifiers( el: ModdleElement, id: string, @@ -6267,13 +5773,7 @@ function warnDanglingModifiers( } } -/** - * The warning that `attr` on `id` is a no-op because `winner` already - * resolved the implementation. Shared by {@link warnShadowedImplementation}, - * which loops it over every unread implementation attribute, and by - * {@link readCallVariableMapper}, which has exactly one attribute - * (`operaton:delegateExpression`) that `bpmn:CallActivity` can shadow. - */ +/** `attr` is a no-op because `winner` already resolved the implementation. */ function buildShadowedImplementationWarning( id: string, attr: string, @@ -6290,12 +5790,10 @@ function buildShadowedImplementationWarning( } /** - * Every implementation attribute `winner` leaves unread, named against it. - * Operaton passes over the same ones: `parseServiceTaskLike` stops at the first - * it resolves, and a named decision goes to `parseDmnBusinessRuleTask`, which - * runs no implementation at all. The consumption table marks all five read on - * these tags, so they leave with a warning here or with none. `consumed` names - * what `winner` itself read. + * Every implementation attribute `winner` leaves unread, as the engine leaves + * it: `parseServiceTaskLike` stops at the first it resolves, and a named + * decision goes to `parseDmnBusinessRuleTask`, which runs no implementation. + * `consumed` names what `winner` itself read. */ function warnShadowedImplementation( el: ModdleElement, @@ -6311,22 +5809,17 @@ function warnShadowedImplementation( } } -/** Operaton compares the type with `equalsIgnoreCase`, so the spelling is free. */ +/** `parseServiceTaskLike` compares the type with `equalsIgnoreCase`. */ function isExternalType(type: string): boolean { return type.toLowerCase() === 'external'; } -/** The built-in behaviour `parseServiceTaskLike` routes `type` to, compared as it compares. */ function builtinTypeOf(type: string): BuiltinTaskType | undefined { const lower = type.toLowerCase(); return TYPE_BINDING_VALUES.find((value) => value === lower); } -/** - * The implementation attributes that carry code, in the order - * {@link readCodeOrExternalBinding} resolves them. `type` and `topic` name the - * external worker rather than code, so they are not among them. - */ +/** The implementation attributes that carry code, in the order {@link readCodeOrExternalBinding} resolves them. */ const CODE_ATTRS = [ 'class', 'delegateExpression', @@ -6334,10 +5827,9 @@ const CODE_ATTRS = [ ] as const satisfies readonly (typeof IMPLEMENTATION_ATTRS)[number][]; /** - * What is wrong with the element, for the refusal. A code attribute alongside - * an `operaton:type` is named too: it is a supported form the engine was never - * going to reach, so a refusal listing the supported forms without it reads as - * if the document had none. + * What is wrong with the element, for the refusal. A code attribute beside an + * `operaton:type` is named too, since a refusal that lists the supported + * forms without it would read as if the document had none. */ function detectUnsupportedServiceTaskForm( el: ModdleElement, @@ -6376,19 +5868,16 @@ function mapScriptTask( const id = requireId(el); const resource = readNamespacedAttr(el, 'resource'); if (resource !== undefined) { - // `ScriptUtil.getScript` prefers a resource over an inline body, so the - // deployed script runs whether or not the document also wrote a body; - // importing the body here would keep a script the engine never runs. throw new UnsupportedExtensionFormError( id, externalResourceDetail(`the script on '${id}'`, resource), ); } const named = readNamed(el, id, warnings); - // moddle reports a missing attribute as `undefined` and a present-but-empty - // one as `''`; `BpmnParse.parseScriptTaskElement` defaults only the former - // to `ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE` ("juel"), while - // `ScriptUtil.getScript` refuses the latter outright. + // moddle reads an absent attribute as `undefined` and an empty one as `''`; + // `BpmnParse.parseScriptTaskElement` defaults only the former to + // `ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE`, while `ScriptUtil.getScript` + // refuses the latter. const rawFormat = el.get('scriptFormat') as string | undefined; if (rawFormat === '') { throw new UnsupportedExtensionFormError( @@ -6447,11 +5936,7 @@ function mapScriptTask( }; } -/** - * The two kinds that carry a flow taken when no condition matched; they read - * the same three things, so one mapper serves both. `default` parses into a - * moddle reference, and only its `id` is kept so the IR stays strings. - */ +/** The two kinds that carry a flow taken when no condition matched. */ function mapDefaultingGateway( el: ModdleElement, kind: 'exclusiveGateway' | 'inclusiveGateway', @@ -6486,11 +5971,7 @@ function mapParallelGateway( }; } -/** - * The branches this opens are validated by {@link checkWaitBranches}, which - * sees the whole container: the rules are about flows, and none is visible - * from the element itself. - */ +/** The branches this opens are validated by {@link checkWaitBranches}, which sees the whole container. */ function mapEventBasedGateway( el: ModdleElement, warnings: ImportWarning[], @@ -6517,15 +5998,11 @@ function mapEventBasedGateway( } /** - * The BPMN attributes a wait with several branches declares that Operaton reads - * nothing of: `BpmnParse.parseEventBasedGateway` reads neither `instantiate` - * nor `eventGatewayType`, which appear in the engine's schema and nowhere in - * its parser. Reported by hand, for the reason - * {@link warnIgnoredQuantityAttrs} gives. - * - * `gatewayDirection` is left out on purpose. It restates the flows already - * imported, carries no execution meaning, and is stamped on most exported - * files, so reporting it would bury every diagnostic that does mean something. + * `BpmnParse.parseEventBasedGateway` reads neither `instantiate` nor + * `eventGatewayType`; both are reported by hand for the reason + * {@link warnIgnoredQuantityAttrs} gives. `gatewayDirection` is left out on + * purpose: it restates the flows and is stamped on most exported files, so + * reporting it would bury the diagnostics that mean something. */ function warnIgnoredWaitAttrs( el: ModdleElement, @@ -6552,12 +6029,10 @@ function warnIgnoredWaitAttrs( } /** - * A condition body the engine evaluates as text rather than as an expression - * always fails the flow: `JuelExpressionManager.createExpression` builds a - * literal from a body with no `${`/`#{` opener, and - * `UelExpressionCondition.evaluate` throws on the string it yields. The - * script has no spelling for that body but inside `${...}`, so the rewrap is - * reported as the change in what runs that it is. + * `JuelExpressionManager.createExpression` builds a literal from a condition + * body with no `${`/`#{` opener, and `UelExpressionCondition.evaluate` throws + * on the string it yields; the script can only spell that body inside + * `${...}`, so the rewrap is reported as the change in what runs that it is. */ function mapSequenceFlow( el: ModdleElement, @@ -6579,9 +6054,8 @@ function mapSequenceFlow( if (dropped.xsiType !== 'tFormalExpression') { throw conditionTypeRefusal(dropped.xsiType, id); } - // The dropped element never reaches `checkConditionExpressionForm`, but - // `BpmnParse.parseConditionExpression` still reads its `language` and - // `resource` the same way, so it refuses or warns on the same grounds. + // `BpmnParse.parseConditionExpression` reads `language` and `resource` + // off the dropped element the same way. refuseOrWarnScriptedCondition( dropped.language, dropped.resource, @@ -6608,7 +6082,7 @@ function mapSequenceFlow( }); } else if ( conditionExpression !== undefined && - !EXPRESSION_BODY.test(conditionExpression) + !EXPRESSION_ANYWHERE.test(conditionExpression) ) { warnings.push({ elementId: id, @@ -6657,7 +6131,6 @@ function mapSequenceFlow( }; } -/** The one sentence `parseConditionExpression` fails the deployment with on a type it does not accept. */ function conditionTypeRefusal( xsiType: string, id: string, @@ -6671,21 +6144,13 @@ function conditionTypeRefusal( } /** - * Operaton's `parseConditionExpression` fails the deployment on an `xsi:type` - * that does not resolve to `tFormalExpression` and accepts an absent one. - * moddle resolves a prefixed type the same way, so the element's `$type` is - * `bpmn:FormalExpression` exactly when the engine accepts a written prefixed - * type, and the raw attribute under `$attrs` says whether one was written at - * all; an unprefixed type it resolves against the document's default - * namespace, so in a prefixed document that element never reaches here and - * is read back by {@link recordDroppedConditions} instead. - * It reads `resource` only inside the `language != null` branch: with a - * `language` it builds a `ScriptCondition` that runs the deployed script (or - * the inline body, absent a resource) in that language, and this surface - * writes a UEL expression, never a script, so it refuses rather than importing - * a body the engine does not evaluate. Without a `language` the resource - * reaches nobody: the engine still builds a UEL condition from the body, so - * the attribute merely goes unread and warns. + * `parseConditionExpression` fails the deployment on an `xsi:type` that does + * not resolve to `tFormalExpression` and accepts an absent one; moddle + * resolves a prefixed type the same way, so the element's `$type` is + * `bpmn:FormalExpression` exactly when the engine accepts it, and the raw + * `$attrs` entry says whether one was written at all. With a `language` the + * engine builds a `ScriptCondition` rather than a UEL one, which this surface + * cannot write; `resource` is read inside that branch alone. */ function checkConditionExpressionForm( expressionEl: ModdleElement, @@ -6704,12 +6169,7 @@ function checkConditionExpressionForm( ); } -/** - * The refusal or warning `BpmnParse.parseConditionExpression` earns from a - * condition's `language`/`resource`, shared by the moddle-parsed element and - * a {@link DroppedCondition} read back off the source text: the engine reads - * both attributes the same way regardless of which path found the element. - */ +/** Shared by the moddle-parsed condition and a {@link DroppedCondition}, which the engine reads alike. */ function refuseOrWarnScriptedCondition( language: string | undefined, resource: string | undefined, @@ -6769,11 +6229,11 @@ function refuseMultipleEventDefinitions( } } +/** Called on tasks and gateways too, which declare no `eventDefinitions`, so the fallback stays. */ function eventDefinitionsOf(el: ModdleElement): ModdleElement[] { return (el.get('eventDefinitions') as ModdleElement[] | undefined) ?? []; } -/** The element's `id`; every flow element in a well-formed BPMN file has one. */ function requireId(el: ModdleElement): string { if (el.id === undefined || el.id === '') { throw new Error(`<${el.$type}> is missing its required 'id' attribute.`); @@ -6794,11 +6254,9 @@ function readString(el: ModdleElement, name: string): string | undefined { /** * A string-valued moddle property kept as written, `""` included, for the - * five texts the engine stores verbatim and the export writes back as such: a - * flow node's label, an assignee, a form field's label and its default, and a - * listener's binding attribute (`class`, `expression`, `delegateExpression`). - * Every other reader folds an empty attribute into absent through - * {@link readString}. + * texts the engine stores verbatim (a label, an assignee, a form field's + * label and default, a listener's binding attribute); every other reader + * folds an empty attribute into absent through {@link readString}. */ function readText(el: ModdleElement, name: string): string | undefined { const value = el.get(name); @@ -6806,11 +6264,9 @@ function readText(el: ModdleElement, name: string): string | undefined { } /** - * Read a `name`, dropping it when it equals `derived`: that is the label the - * export direction derives, so neither the IR nor any DSL printed from it - * carries it back, which is what makes DSL -> XML -> DSL idempotent. The export - * derives `humanize(id)` for every kind but a link event, whose label it - * stamps from the link name. + * A `name`, dropped when it equals the label the export derives (`humanize(id)` + * for every kind but a link event, whose label is its link name), which is + * what makes DSL -> XML -> DSL idempotent. */ function readDerivableName( el: ModdleElement, @@ -6822,15 +6278,10 @@ function readDerivableName( } /** - * The label and the documentation an element carries, read as one pair: every - * kind whose IR node holds a `name` holds a `documentation` beside it. A mapper - * for a new kind reads both here, or reports both through - * {@link warnNamedDrop} when its position has no node to hold either; those are - * the only two answers, and neither of them is silence. - * - * A gateway is the one caller that passes `name` itself, because its id is a - * structural coordinate rather than a label the export direction derives, so a - * name equal to the derived one is still the author's. + * The label and the documentation, read as one pair; a position with no node + * to hold them reports both through {@link warnNamedDrop} instead. A gateway + * passes `name` itself: its id is a structural coordinate the export derives + * no label from, so a name equal to the derived one is still the author's. */ function readNamed( el: ModdleElement, @@ -6846,12 +6297,9 @@ function readNamed( } /** - * The text of a single plaintext `bpmn:documentation` child, verbatim: the - * whitespace a modeler pretty-printed into the body comes back with it, and an - * empty body carries as an empty string. More than one child, or a `textFormat` - * naming anything but plain text, is one string this surface cannot hold and is - * reported instead. moddle answers BPMN's `text/plain` default for an absent - * `textFormat`, so an unwritten format needs no case of its own. + * The text of a single plaintext `bpmn:documentation` child, verbatim; more + * than one child, or a `textFormat` other than plain text, is reported + * instead. moddle answers the `text/plain` default for an absent `textFormat`. */ function readDocumentation( el: ModdleElement, @@ -6886,16 +6334,11 @@ function readDocumentation( return typeof text === 'string' ? text : ''; } -/** The `bpmn:documentation` children moddle parsed off an element. */ function documentationChildren(el: ModdleElement): ModdleElement[] { - return (el.get('documentation') as ModdleElement[] | undefined) ?? []; + return el.get('documentation') as ModdleElement[]; } -/** - * Read an extension attribute. `get` falls back to the raw `$attrs` map for an - * undeclared property, which is how a document's camunda: spelling reads here - * too: the namespace swap in {@link xmlToIr} makes it arrive under this prefix. - */ +/** `get` falls back to the raw `$attrs` map for an undeclared property. */ function readNamespacedAttr( el: ModdleElement, localName: string, @@ -6908,7 +6351,20 @@ function extensionValues(el: ModdleElement): ModdleElement[] { const extensionElements = el.get('extensionElements') as ModdleElement | undefined; if (extensionElements === undefined) return []; - return (extensionElements.get('values') as ModdleElement[] | undefined) ?? []; + return extensionElements.get('values') as ModdleElement[]; +} + +/** The one `operaton:inputOutput` block; `BpmnParseUtil.parseInputOutput` fails the deployment on a second. */ +function onlyIoMapping( + el: ModdleElement, + ownerId: string, +): ModdleElement | undefined { + return onlyExtensionElement( + el, + 'operaton:InputOutput', + ownerId, + 'BpmnParseUtil.parseInputOutput', + ); } /** The settings a repetition element carries as a step does ({@link LoopCharacteristics}), as {@link jobSettings} takes them. */ @@ -6917,9 +6373,9 @@ function readRunSettings( ownerId: string, warnings: ImportWarning[], ): Omit[0], 'jobPriority'> { - // The reader runs on an async step, a timer-driven event and a typed - // throw alone (`DefaultFailedJobParseListener.parseActivity` and its event - // arms); the refusal names that rather than deriving it per element kind. + // `DefaultFailedJobParseListener` reads the cycle on an async step, a + // timer-driven event and a typed throw alone; the refusal names that rather + // than deriving it per element kind. const retryCycleEl = onlyExtensionElement( el, 'operaton:FailedJobRetryTimeCycle', @@ -6936,11 +6392,19 @@ function readRunSettings( }; } +export function asyncRespellingMessage(subject: string): string { + return ( + `The operaton:async="true" on ${subject} imports as asyncBefore: true: ` + + 'BpmnParse.isAsyncBefore reads the two spellings as one, and this ' + + 'tool writes it back as operaton:asyncBefore, which the engine reads ' + + 'the same.' + ); +} + /** * `BpmnParse.isAsyncBefore` reads `operaton:async="true"` as `asyncBefore` - * on every element it parses a continuation for, the repetition element - * included. The descriptor declares the current spelling alone, so the older - * one is read raw and its respelling named. + * on every element it parses a continuation for, the repetition included; the + * descriptor declares `asyncBefore` alone, so `async` is read raw. */ function readAsyncBefore( el: ModdleElement, @@ -6956,20 +6420,19 @@ function readAsyncBefore( warnings.push({ elementId: ownerId, category: 'unmappedConstruct', - message: - `The operaton:async="true" on ${subject} imports as asyncBefore: true: ` + - 'BpmnParse.isAsyncBefore reads the two spellings as one, and this ' + - 'tool writes it back as operaton:asyncBefore, which the engine reads ' + - 'the same.', + message: asyncRespellingMessage(subject), }); return true; } -/** - * `BpmnParse.parsePriority` parses a constant with `Integer.parseInt` and - * fails the deployment on any other, so the value refuses here rather than - * reaching a script the validator refuses at the same setting. - */ +export function priorityRefusal(attr: string, value: string): string { + return ( + `operaton:${attr}="${value}", which BpmnParse.parsePriority refuses to ` + + `deploy ("Value '${value}' for attribute '${attr}' is not a valid number")` + ); +} + +/** `BpmnParse.parsePriority` parses a constant with `Integer.parseInt` and fails the deployment on any other. */ function requireIntegerOrExpression( value: string | undefined, ownerId: string, @@ -6984,8 +6447,7 @@ function requireIntegerOrExpression( } throw new UnsupportedExtensionFormError( ownerId, - `operaton:${attr}="${value}", which BpmnParse.parsePriority refuses to ` + - `deploy ("Value '${value}' for attribute '${attr}' is not a valid number")`, + priorityRefusal(attr, value), ); } @@ -7016,9 +6478,8 @@ function readJobSettings( /** * `BpmnParse.parseTimer` locks the timer job by `operaton:exclusive` on the * definition; the same attribute on the event tag reaches only the async - * continuation job (`parseAsynchronousContinuation`). The surface spells one - * value for both, so the definition's wins where the two are written apart, - * and the tag's stands in where the definition carries none. + * continuation job. The surface spells one value for both, so the + * definition's wins and the tag's stands in where the definition carries none. */ function timerJobExclusive( defEl: ModdleElement, @@ -7057,15 +6518,19 @@ function readEngineAttributes( } /** - * Read a boolean extension attribute. The `operaton:` spelling carries a - * schema default, so `get` answers with that default for an attribute the - * document never wrote; only an own property is an authored value. + * Whether the document wrote the declared property `name`: moddle answers a + * schema default through `get` for one it never wrote, and stores a parsed + * value as an own property alone. */ +function isAuthored(el: ModdleElement, name: string): boolean { + return Object.hasOwn(el, name); +} + function readNamespacedFlag( el: ModdleElement, localName: string, ): boolean | undefined { - if (!Object.prototype.hasOwnProperty.call(el, localName)) return undefined; + if (!isAuthored(el, localName)) return undefined; const value = el.get(`operaton:${localName}`); return typeof value === 'boolean' ? value : undefined; } @@ -7084,27 +6549,26 @@ function readFormFields( if (formData === undefined) { return undefined; } - const fields = (formData.get('fields') as ModdleElement[] | undefined) ?? []; + const fields = formData.get('fields') as ModdleElement[]; if (fields.length === 0) { return undefined; } return fields.map((field) => readFormField(field, ownerId, warnings)); } -/** A warning sink bound to one owner, so a reader names only what it dropped. */ +/** A warning sink bound to one owner. */ type Report = (message: string) => void; -/** One form field as its readers see it: how to name it and where to warn. */ interface FieldContext { fieldId: string; ownerId: string; type: FormFieldType; - /** `form field 'x' of 'T'`, the noun phrase every warning on the field uses. */ + /** `form field 'x' of 'T'`. */ subject: string; report: Report; } -/** The one shape for content the engine deploys but the compiler will refuse. */ +/** The one shape for content the engine deploys but the compiler refuses. */ function warnCarriedAsWritten( { subject, report }: FieldContext, what: string, @@ -7118,9 +6582,8 @@ function warnCarriedAsWritten( /** * A form field id or an io parameter name is the variable the engine sets - * (`FormFieldHandler.handleSubmit`, `InputParameter.execute`, - * `OutputParameter.execute`), and the script writes it as a bare name, so one - * it cannot spell is refused: a minted name would change the variable. + * (`FormFieldHandler.handleSubmit`, `InputParameter.execute`), and the script + * writes it as a bare name, so a minted name would change the variable. */ function refuseUnspellableVariable( name: string, @@ -7177,10 +6640,9 @@ function readFormField( ? [] : readPropertyEntries(propertiesEl, 'id', ctx.subject, ctx.report); - // FormFieldHandler.createFormField converts the evaluated default through - // the type on every render; a literal is decided here, an expression at - // run time. - if (defaultValue !== undefined && !EXPRESSION_BODY.test(defaultValue)) { + // `FormFieldHandler.createFormField` converts the default through the type + // on every render; a literal is decided here, an expression at run time. + if (defaultValue !== undefined && !EXPRESSION_ANYWHERE.test(defaultValue)) { const reason = literalDefaultReason( defaultValue, type, @@ -7204,18 +6666,9 @@ function readFormField( }; } -/** The ISO shape a date default takes when it was written for a pattern the field does not name. */ const ISO_DATE_ONLY_TEXT = /^\d{4}-\d{2}-\d{2}$/; -/** - * Why a literal default fails the conversion its type runs on every render, - * or `undefined` when it converts. `LongFormType.convertValue` parses with - * `Long.valueOf`; `BooleanFormType.convertValue` with `Boolean.valueOf`, - * case-insensitively; `DateFormType` parses under the - * field's pattern, or under "dd/MM/yyyy" from - * `ProcessEngineConfigurationImpl.initFormTypes` when none is written; and - * `EnumFormType.validateValue` refuses an id outside the map. - */ +/** Why a literal default fails the conversion its type runs on every render, or `undefined` when it converts. */ function literalDefaultReason( value: string, type: FormFieldType, @@ -7259,13 +6712,13 @@ function literalDefaultReason( /** * `FormTypes.parseFormPropertyType` reads the `operaton:value` children on an * enum field alone, into a `LinkedHashMap`: a repeated id keeps its first - * position and takes its last `name`, so the import does the same and says so. + * position and takes its last `name`. */ function readEnumValues( field: ModdleElement, { type, subject, report }: FieldContext, ): FormFieldValue[] { - const valueEls = (field.get('values') as ModdleElement[] | undefined) ?? []; + const valueEls = field.get('values') as ModdleElement[]; if (valueEls.length === 0) return []; if (type !== 'enum') { const n = valueEls.length; @@ -7298,10 +6751,7 @@ function readEnumValues( return [...byId.values()]; } -/** - * The four bounds, each by the validator that parses `config` as an integer - * ({@link FORM_BOUND_TEXT}) on every submission. - */ +/** The four bounds, each by the validator that parses `config` as an integer on every submission. */ const BOUND_VALIDATORS: Readonly> = { min: 'MinValidator.validate', max: 'MaxValidator.validate', @@ -7393,30 +6843,25 @@ function readConstraints( /** * What a repeated key comes to, by the attribute the engine reader keys on: * `DefaultFormHandler.parseProperties` reads a form field's by `id` into a - * `LinkedHashMap`, which keeps the first position as well as the last value; - * `BpmnParseUtil.parseOperatonExtensionProperties` reads an external task's - * by `name` into a `HashMap`, which keeps the last value and no position, so - * the first position there is this import's choice. + * `LinkedHashMap`, `BpmnParseUtil.parseOperatonExtensionProperties` an + * external task's by `name` into a `HashMap`, which keeps no position. */ const PROPERTY_REWRITE_BY_KEY = { id: ', at its first position with its last value, as DefaultFormHandler.parseProperties keeps it (LinkedHashMap.put)', name: ' with its last value, as BpmnParseUtil.parseOperatonExtensionProperties keeps it (HashMap.put), at its first position', } as const; -/** `subject` names the owner in the warnings. */ function readPropertyEntries( properties: ModdleElement, keyAttr: keyof typeof PROPERTY_REWRITE_BY_KEY, subject: string, report: Report, ): ExtensionProperty[] { - const entries = - (properties.get('values') as ModdleElement[] | undefined) ?? []; + const entries = properties.get('values') as ModdleElement[]; const byKey = new Map(); entries.forEach((entry, i) => { const key = readString(entry, keyAttr); - // Both readers `put` the attribute as written, so `value=""` is an entry - // holding the empty string, not a missing one. + // Both readers `put` the attribute as written, so `value=""` is an entry. const raw = entry.get('value'); const value = typeof raw === 'string' ? raw : undefined; if (key === undefined || value === undefined) { @@ -7438,7 +6883,6 @@ function readPropertyEntries( return [...byKey.values()]; } -/** Map an `operaton:formField` type to its DSL type, refusing what the DSL lacks. */ function importFormFieldType( operatonType: string | undefined, fieldId: string, @@ -7460,11 +6904,9 @@ function importFormFieldType( /** * The one `` child of `type`. `Element.elementNS` throws - * on a second child of one tag and `BpmnParse.execute` lets that bubble, so - * the document fails to deploy wherever `reader`, the engine method that - * asks for the child, reaches it; the callers mirror those readers. `when` - * names the shape the reader runs on where a caller cannot cheaply gate the - * refusal on it. + * on a second child of one tag wherever `reader`, the engine method that asks + * for it, runs; `when` names that shape where a caller cannot cheaply gate + * the refusal on it. */ function onlyExtensionElement( el: ModdleElement, @@ -7480,25 +6922,20 @@ function onlyExtensionElement( ownerId, `${matches.length} <${tag}> blocks, which Element.elementNS throws on ` + `when ${reader} reads them${when} ("Parsing exception: multiple ` + - `elements with tag name '${tag.slice(tag.indexOf(':') + 1)}' ` + + `elements with tag name '${localNameOf(tag)}' ` + 'found"), and BpmnParse.execute lets that fail the deployment', ); } return matches[0]; } -/** Read `operaton:inputOutput` in declaration order, which is Operaton's evaluation order. */ +/** Declaration order is Operaton's evaluation order. */ function readIoMapping( el: ModdleElement, ownerId: string, warnings: ImportWarning[], ): IoMapped { - const io = onlyExtensionElement( - el, - 'operaton:InputOutput', - ownerId, - 'BpmnParseUtil.parseInputOutput', - ); + const io = onlyIoMapping(el, ownerId); if (io === undefined) return {}; return ioMapped( @@ -7514,8 +6951,7 @@ function readIoParameters( warnings: ImportWarning[], ): IoParameter[] { const tag = `operaton:${direction}Parameter`; - const params = - (io.get(`${direction}Parameters`) as ModdleElement[] | undefined) ?? []; + const params = io.get(`${direction}Parameters`) as ModdleElement[]; const read = params.map((param) => { const name = readString(param, 'name'); if (name === undefined) { @@ -7546,13 +6982,11 @@ function readIoParameters( } /** - * Read the value of an `operaton:inputParameter`, `operaton:outputParameter`, - * or `operaton:entry`: verbatim body text, or exactly one nested value. The - * moddle descriptor declares the nested value as a repeating property so that - * two of them stay visible here; a single-valued one would keep the last and - * hide the loss. `BpmnParseUtil.parseNestedParamValueProvider` reads the one - * child element and never the body text beside it, so that combination is - * carried as the child with a warning rather than refused. + * A parameter's or an entry's value: verbatim body text, or exactly one + * nested value. The moddle descriptor declares the nested value as a + * repeating property so that two of them stay visible here. + * `BpmnParseUtil.parseNestedParamValueProvider` reads the one child element + * and never the body text beside it, so that combination warns. */ function readParameterValue( holder: ModdleElement, @@ -7561,8 +6995,7 @@ function readParameterValue( warnings: ImportWarning[], ): IoValue { const text = readString(holder, 'value'); - const nested = - (holder.get('definitions') as ModdleElement[] | undefined) ?? []; + const nested = holder.get('definitions') as ModdleElement[]; if (nested.length > 1) { throw new UnsupportedExtensionFormError( @@ -7590,11 +7023,9 @@ function readParameterValue( } /** - * Map one nested `operaton:inputOutput` value, recursing through lists and - * maps. The moddle descriptor declares both positions as the shared abstract + * The moddle descriptor declares both positions as the shared abstract * supertype, so an `operaton:entry` parses under a parameter as readily as in - * an `operaton:map`; `position` is what tells the two apart and refuses the - * first. + * an `operaton:map`; `position` is what tells the two apart. */ function readNestedValue( def: ModdleElement, @@ -7607,16 +7038,16 @@ function readNestedValue( case 'operaton:List': return { kind: 'list', - items: ((def.get('items') as ModdleElement[] | undefined) ?? []).map( - (item) => readNestedValue(item, ownerId, where, 'item', warnings), + items: (def.get('items') as ModdleElement[]).map((item) => + readNestedValue(item, ownerId, where, 'item', warnings), ), }; case 'operaton:Map': return { kind: 'map', - entries: ( - (def.get('entries') as ModdleElement[] | undefined) ?? [] - ).map((entry) => readMapEntry(entry, ownerId, where, warnings)), + entries: (def.get('entries') as ModdleElement[]).map((entry) => + readMapEntry(entry, ownerId, where, warnings), + ), }; case 'operaton:Script': return readScriptValue( @@ -7669,12 +7100,9 @@ function readMapEntry( } /** - * The rule broken by naming a deployment resource where only an inline body - * can be written, shared between an `operaton:script` value's own `resource` - * and a `bpmn:scriptTask`'s `operaton:resource`: the same rule under two - * spellings of the attribute, stated once. `ScriptUtil.getScript` prefers a - * resource over an inline body whenever both are given, so the deployed - * script runs whether or not this surface can carry the body too. + * `ScriptUtil.getScript` runs a resource in place of an inline body whenever + * both are given, so importing the body would keep a script the engine never + * runs; one rule for a script value's `resource` and a task's `operaton:resource`. */ function externalResourceDetail(where: string, resource: string): string { return ( @@ -7713,10 +7141,8 @@ function readScriptValue( /** * The grammar's fence terminal ends at the first three backticks after its * tag, so a body holding three has no fence that can enclose it. An empty - * body (moddle reads a whitespace-only one as empty too) is carried: the - * engine deploys it, since `ScriptUtil.getScript` checks the source for null - * and not for emptiness, but the printed fence draws the validator's - * empty-body error. + * body is carried: `ScriptUtil.getScript` checks the source for null and not + * for emptiness, so the engine deploys it. */ function checkScriptBody( code: string, @@ -7752,14 +7178,10 @@ interface ListenerSpec { } /** - * Emission order, which is also import order: `CoreModelElement - * .addListenerToMap` and `TaskDefinition.addTaskListener` both append to a - * per-event list rather than replace, so several listeners on one event (a - * logging listener beside a metrics listener) are an ordinary shape and run - * in document order. A `timeout` listener is different: - * `TaskDefinition.addTimeoutTaskListener` keys it by its own id, so several - * `timeout` listeners each fire on their own timer rather than sharing an - * order. Members are read in the order they are refused in. + * Document order is run order: `CoreModelElement.addListenerToMap` and + * `TaskDefinition.addTaskListener` append to a per-event list, so several + * listeners on one event are an ordinary shape; a `timeout` listener is keyed + * by its own id instead (`TaskDefinition.addTimeoutTaskListener`). */ function readListeners( el: ModdleElement, @@ -7796,7 +7218,6 @@ function readExecutionListeners( ); } -/** A user task's `operaton:taskListener` children; a `timeout` also carries its timer. */ function readTaskListeners( el: ModdleElement, ownerId: string, @@ -7855,12 +7276,9 @@ function readListenerBinding( } /** - * `BpmnParse.parseTaskListener` checks none of `class`, `expression`, or - * `delegateExpression` for emptiness before deploying, so an empty one on a - * task listener never refuses. An empty `class` or `delegateExpression` fails - * once the listener's own event fires and the engine tries to run nothing; - * an empty `expression` evaluates to the empty text instead - * ({@link resolveListenerBinding}). + * `BpmnParse.parseTaskListener` checks no binding attribute for emptiness, so + * an empty `class` or `delegateExpression` deploys and fails when the event + * fires; an empty `expression` evaluates to the empty text instead. */ function buildEmptyTaskListenerBindingWarning( ownerId: string, @@ -7879,15 +7297,11 @@ function buildEmptyTaskListenerBindingWarning( } /** - * Resolve the single executable binding a listener runs. `BpmnParse - * .parseExecutionListener`/`parseTaskListener` read `class`, then + * `BpmnParse.parseExecutionListener`/`parseTaskListener` read `class`, then * `expression`, then `delegateExpression`, then an `operaton:script` child, - * and deploy whichever comes first; naming more than one loses nothing the - * engine would have run, so every loser is carried as a warning rather than - * refused. Naming none never runs, so that alone refuses. Each binding - * attribute is read through {@link readText} rather than {@link readString}, - * so an empty `class`, `expression`, or `delegateExpression` is seen and not - * folded into "absent". + * and run the first; every loser warns rather than refuses, since the engine + * drops it too. Each attribute is read through {@link readText}, so an empty + * one is seen rather than folded into absent. */ function resolveListenerBinding( listener: ModdleElement, @@ -7915,8 +7329,7 @@ function resolveListenerBinding( ); } const [winner, ...losers] = present; - // Named against the listener: on a step, "on 'Svc'" would read as the - // step's own binding. + // Named against the listener: "on 'Svc'" would read as the step's own binding. for (const loser of losers) { warnings.push( buildShadowedImplementationWarning( @@ -7944,9 +7357,8 @@ function resolveListenerBinding( } return { kind: 'class', className: className! }; case 'expression': - // `ExpressionExecutionListener.notify` and `ExpressionTaskListener - // .notify` both call `expression.getValue`, and JUEL reads `""` as the - // empty string, so neither listener kind fails on it. + // Both listener kinds call `expression.getValue`, and JUEL reads `""` + // as the empty string, so neither fails on it. if (expression === '') { warnings.push({ elementId: ownerId, @@ -8024,8 +7436,7 @@ function readListenerTimer( 'bpmn:timerEventDefinition children, and a timeout has one due time', ); } - // A timeout listener has no job settings on the surface, so the lock flag - // `parseTimer` reads off its definition has nowhere to land. + // A timeout listener has no job settings on the surface. const exclusive = readNamespacedFlag(defs[0], 'exclusive'); if (exclusive !== undefined) { warnings.push({ @@ -8042,12 +7453,7 @@ function readListenerTimer( return { kind: 'timer', ...readTimerDefinition(defs[0], ownerId) }; } -/** - * Refuse two pieces of extension content on one element sharing the word that - * tells them apart. The surface writes each as the word it repeats, so the - * second has nowhere to go: importing it would produce a process that cannot be - * written back, dropping it would change what the element runs. - */ +/** The surface writes each piece of extension content under its key once, so a second has nowhere to go. */ function refuseRepeatedExtensionKey( keys: readonly string[], ownerId: string, @@ -8062,10 +7468,7 @@ function refuseRepeatedExtensionKey( } } -/** - * A listener declares its fields as a property of its own; a step carries them - * loose in `extensionElements`, beside every other extension child it holds. - */ +/** A listener declares `fields` as a property (moddle materializes it, empty or not); a step carries them loose in `extensionElements`. */ function fieldChildren(carrier: ModdleElement): ModdleElement[] { const declared = carrier.get('fields') as ModdleElement[] | undefined; return ( @@ -8074,7 +7477,7 @@ function fieldChildren(carrier: ModdleElement): ModdleElement[] { ); } -/** What names a binding that receives no field list, in the drop it draws. */ +/** How the drop names a binding that receives no field list. */ const FIELDLESS_BINDING: Readonly< Record<'expression' | 'external' | 'decision' | 'script', string> > = { @@ -8085,12 +7488,9 @@ const FIELDLESS_BINDING: Readonly< }; /** - * Read the `operaton:field` children a carrier holds onto the binding it - * resolved to. Operaton builds the field list for the behaviours a class, a - * delegate expression and a built-in type select and hands it to no other, on - * a step and on both listener kinds alike, so a field under any other binding - * is reported rather than carried into a slot the engine would never read it - * from. + * Operaton hands the field list to the behaviours a class, a delegate + * expression and a built-in type select and to no other, on a step and on + * both listener kinds alike, so a field under any other binding is reported. */ function withInjectedFields( binding: B, @@ -8123,11 +7523,9 @@ function withInjectedFields( const RAW_TEMPLATE_OPEN = /^[$#]\{/; /** - * The three slots Operaton writes a field's value in, and whether it evaluates - * that slot rather than injecting it verbatim. The IR holds one text for all - * three and picks the slot back off its leading `${` or `#{`, so a slot whose - * text disagrees with it has no spelling here and is reported instead of - * coming back as the other one. + * The three slots Operaton writes a field's value in, and whether it + * evaluates that slot. The IR holds one text and picks the slot back off its + * leading `${` or `#{`, so a slot whose text disagrees with it is reported. */ const FIELD_VALUE_SLOTS = [ { @@ -8143,11 +7541,7 @@ const FIELD_VALUE_SLOTS = [ }, ] as const; -/** - * A value quoted inside a one-sentence warning: one line, with whatever - * whitespace it carries still visible, since that whitespace is often the - * reason the value is being reported. - */ +/** A value quoted in a warning, its whitespace kept visible since that is often why it is reported. */ function oneLine(text: string): string { return text.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t'); } @@ -8171,11 +7565,9 @@ function readField( ); } - // Every slot is read verbatim. `parseExpressionFieldDeclaration` hands the - // body to `createExpression` untrimmed, and - // `getStringValueFromAttributeOrElement` reads `childElement.getText()` - // without trimming either, so the whitespace an indented body carries is - // part of the composite expression the engine evaluates. + // Every slot is read verbatim: `parseExpressionFieldDeclaration` and + // `getStringValueFromAttributeOrElement` trim nothing, so an indented + // body's whitespace is part of what the engine evaluates. const written = FIELD_VALUE_SLOTS.map((slot) => ({ slot, value: readString(field, slot.property), @@ -8184,14 +7576,13 @@ function readField( slot.value !== undefined, ); - // `parseFieldDeclaration` reads the two literal slots first and never reaches - // the expression child once one of them answers, so a literal wins here too. + // `parseFieldDeclaration` reads the two literal slots first and never + // reaches the expression child once one answers. const literals = written.filter((slot) => !slot.slot.evaluated); const evaluated = written.find((slot) => slot.slot.evaluated); const chosen = literals[0] ?? evaluated; - // `parseFieldDeclaration` calls `addError` on a field naming no slot, and - // `getStringValueFromAttributeOrElement` calls it on one naming the - // attribute and the child of the same slot, so neither document deploys. + // `parseFieldDeclaration` fails the deployment on a field naming no slot, + // and `getStringValueFromAttributeOrElement` on one naming both literal slots. if (chosen === undefined || literals.length > 1) { throw new UnsupportedExtensionFormError( ownerId, @@ -8202,12 +7593,10 @@ function readField( ); } - // The compiler writes a value opening with `${` or `#{`, after any - // whitespace, into the expression slot and any other into `stringValue`, - // and the script spells the first as a raw template, whose opener follows - // its quote directly; a quoted literal opening with an expression is - // refused. A value the round trip would move to the other slot, or one the - // script cannot spell, is reported rather than quietly reshaped. + // The compiler writes a value opening with `${` or `#{` into the expression + // slot and any other into `stringValue`, and spells the first as a raw + // template, whose opener follows its quote directly. A value the round trip + // would move to the other slot, or cannot spell, is reported. const evaluatedBack = EXPRESSION_OPEN.test(chosen.value); let writtenBack: string | undefined; if (chosen.slot.evaluated && !evaluatedBack) { @@ -8257,12 +7646,6 @@ function readField( type FieldValueSlot = (typeof FIELD_VALUE_SLOTS)[number]; -/** - * Report one `operaton:field` as a drop. `reason` says why this field never - * reaches the bean it names, which differs between a binding that receives no - * field list, a value slot the round trip cannot preserve, and a position this - * tool holds no field on at all. - */ function warnFieldDrop( field: ModdleElement, ownerId: string, diff --git a/packages/transform/test/ast-to-ir.test.ts b/packages/transform/test/ast-to-ir.test.ts index d00e538..ba67986 100644 --- a/packages/transform/test/ast-to-ir.test.ts +++ b/packages/transform/test/ast-to-ir.test.ts @@ -1,10 +1,3 @@ -/** - * Each test parses inline BpmnScript DSL through the real Langium grammar and - * asserts the flat, BPMN-shaped IR that `astToIr` desugars it to. The expected - * IR is inline rather than read from fixture files so the desugaring rules are - * pinned here. - */ - import { beforeAll, describe, expect, it } from 'vitest'; import { EmptyFileSystem } from 'langium'; import { parseHelper } from 'langium/test'; @@ -83,40 +76,29 @@ beforeAll(() => { // ── Test helpers ───────────────────────────────────────────────────────────── -/** Parse DSL source, assert no parser errors, and desugar to IR. */ async function ir(source: string): Promise { const doc = await parse(source); expect(doc.parseResult.parserErrors).toHaveLength(0); return astToIr(doc.parseResult.value); } -/** - * The header declarations the trigger tables name their codes from. An error - * and an escalation share one declaration namespace, so the escalation code `X` - * takes a name of its own and is keyed back to `X` by its code setting. - */ +// An error and an escalation share one declaration namespace, so the +// escalation with code `X` needs a name of its own. const CODE_DECLS = 'error PF error X escalation LS escalation EscX(code: "X")'; -/** The settings parens holding the items that were written, or nothing at all. */ const parens = (...items: string[]): string => { const written = items.filter((item) => item !== ''); return written.length > 0 ? `(${written.join(', ')})` : ''; }; -/** - * `service A` (the chain's host) followed by the given statements, under the - * header declarations the statements name. - */ const afterA = (statements: string, decls = ''): Promise => ir(`process p { ${decls} service A(class: "x.A") ${statements} }`); -/** The ids of every exclusive gateway in a container. */ const xorGatewayIds = (container: FlowContainer): string[] => container.flowElements .filter((fe) => fe.kind === 'exclusiveGateway') .map((fe) => fe.id); -/** Assert `source` lowers to one end event, pushed to `_2` by a name collision. */ async function expectCollidedEnd(source: string): Promise { const ends = (await ir(source)).flowElements.filter( (fe) => fe.kind === 'endEvent', @@ -125,7 +107,7 @@ async function expectCollidedEnd(source: string): Promise { expect(ends[0]!.id).toBe('EndEvent_P_2'); } -/** Find a flow by `source -> target` (asserting exactly one such pair). */ +/** The one flow `source -> target`, asserting there is exactly one. */ function flow( container: FlowContainer, source: string, @@ -222,39 +204,30 @@ describe('astToIr: implicit sequence and implicit start/end', () => { expect(only(result, 'endEvent').id).toBe(endId); expect(flow(result, startId, endId)).toBeDefined(); }); - - it('synthesizes a start/end pair for a bodyless sub-process', async () => { - const result = await ir(`process p { subprocess S { } }`); - const sub = subProcess(result, 'S'); - const startId = makeStartEventId('S', new Set()); - const endId = makeEndEventId('S', new Set()); - - expect(only(sub, 'startEvent').id).toBe(startId); - expect(only(sub, 'endEvent').id).toBe(endId); - expect(flow(sub, startId, endId)).toBeDefined(); - }); }); describe('astToIr: if/else exclusive gateway', () => { - const SOURCE = `process P { - if (amount > 1000) { user S } else { service A(class: "com.example.X") } -}`; + const splitId = makeGatewaySplitId('P_0'); + const joinId = makeGatewayJoinId('P_0'); + const defaultFlowId = makeDefaultFlowId(splitId); + const start: FlowElement = { kind: 'startEvent', id: 'StartEvent_P' }; + const end: FlowElement = { kind: 'endEvent', id: 'EndEvent_P' }; it('splits on the condition, rejoins both branches, and makes the else the unconditioned default', async () => { - const splitId = makeGatewaySplitId('P_0'); - const joinId = makeGatewayJoinId('P_0'); - const defaultFlowId = makeDefaultFlowId(splitId); - - expect(await ir(SOURCE)).toEqual( + expect( + await ir( + `process P { if (amount > 1000) { user S } else { service A(class: "com.example.X") } }`, + ), + ).toEqual( processIr( 'P', [ - { kind: 'startEvent', id: 'StartEvent_P' }, + start, gateway(splitId, defaultFlowId), gateway(joinId), { kind: 'userTask', id: 'S' }, serviceTask('A', classBinding('com.example.X')), - { kind: 'endEvent', id: 'EndEvent_P' }, + end, ], [ edge(splitId, 'S', { condition: '${amount > 1000}' }), @@ -268,42 +241,68 @@ describe('astToIr: if/else exclusive gateway', () => { ); }); - it('a branch ending in an explicit end gets no join continuation', async () => { - const result = await ir( - `process P { if (x) { user S end Done } else { user A } }`, - ); - const joinId = makeGatewayJoinId('P_0'); - // The if-branch terminates at `Done`; no flow from Done into the join. - expect( - result.sequenceFlows.filter( - (f) => f.sourceRef === 'Done' && f.targetRef === joinId, - ), - ).toEqual([]); - // The else branch still continues into the join. - expect(flow(result, 'A', joinId)).toBeDefined(); - }); - - it('prunes the join when both the if and else branch terminate, taking its join settings with it, and emits no implicit end', async () => { - const result = await ir( - `process P { error Failed if (c) (joinAsyncBefore: true) { throw error B(Failed) } else { end S } }`, - ); - const joinId = makeGatewayJoinId('P_0'); - expect(result.flowElements.some((fe) => fe.id === joinId)).toBe(false); - // Nothing falls through out of the construct, so the container gets no - // synthesized end either. - const implicitEndId = makeEndEventId('P', new Set()); - expect(result.flowElements.some((fe) => fe.id === implicitEndId)).toBe( - false, - ); - }); - - it('keeps the split->join default flow when there is no else (never pruned)', async () => { - const result = await ir(`process P { if (c) { end A } }`); - const splitId = makeGatewaySplitId('P_0'); - const joinId = makeGatewayJoinId('P_0'); - expect(result.flowElements.some((fe) => fe.id === joinId)).toBe(true); - expect(flow(result, splitId, joinId)).toBeDefined(); - }); + it.each([ + [ + 'a branch ending in an explicit end sends nothing on to the join', + `if (x) { user S end Done } else { user A }`, + [ + start, + gateway(splitId, defaultFlowId), + gateway(joinId), + { kind: 'userTask', id: 'S' }, + { kind: 'endEvent', id: 'Done' }, + { kind: 'userTask', id: 'A' }, + end, + ], + [ + edge('S', 'Done'), + edge(splitId, 'S', { condition: '${x}' }), + edge(splitId, 'A', { id: defaultFlowId }), + edge('A', joinId), + edge('StartEvent_P', splitId), + edge(joinId, 'EndEvent_P'), + ], + ], + [ + 'both branches terminating prunes the join, its join settings with it, and the implicit end', + `error Failed if (c) (joinAsyncBefore: true) { throw error B(Failed) } else { end S }`, + [ + start, + gateway(splitId, defaultFlowId), + { kind: 'endEvent', id: 'B', eventDefinition: errorDef('Failed') }, + { kind: 'endEvent', id: 'S' }, + ], + [ + edge(splitId, 'B', { condition: '${c}' }), + edge(splitId, 'S', { id: defaultFlowId }), + edge('StartEvent_P', splitId), + ], + ], + [ + 'no else keeps the split->join default flow, so the join is never pruned', + `if (c) { end A }`, + [ + start, + gateway(splitId, defaultFlowId), + gateway(joinId), + { kind: 'endEvent', id: 'A' }, + end, + ], + [ + edge(splitId, 'A', { condition: '${c}' }), + edge(splitId, joinId, { id: defaultFlowId }), + edge('StartEvent_P', splitId), + edge(joinId, 'EndEvent_P'), + ], + ], + ] satisfies Array<[string, string, FlowElement[], SequenceFlow[]]>)( + '%s', + async (_title, statement, elements, flows) => { + const result = await ir(`process P { ${statement} }`); + expect(result.flowElements).toEqual(elements); + expect(result.sequenceFlows).toEqual(flows); + }, + ); }); describe('astToIr: else-if chain', () => { @@ -338,8 +337,8 @@ describe('astToIr: while loop', () => { const loopId = makeGatewayLoopId('P_1'); const defaultFlowId = makeDefaultFlowId(loopId); - // The exact element list is also what pins that a loop never lowers to - // loop characteristics on an activity: it is gateway plus back-edge only. + // The whole element list also pins that a loop is a gateway plus back-edge, + // never loop characteristics on an activity. expect( await ir(`process P { user Pre while (rejected) { user R } user Post }`), ).toEqual( @@ -365,23 +364,18 @@ describe('astToIr: while loop', () => { ); }); - it('a body that always throws stays valid: the loop gateway keeps one incoming and two outgoing', async () => { + it('a body that always throws leaves the loop gateway its entry and its exit, with no back-edge', async () => { const result = await ir( `process P { error Failed while (c) { throw error B(Failed) } end Done }`, ); const loopId = makeGatewayLoopId('P_0'); - const incoming = result.sequenceFlows.filter((f) => f.targetRef === loopId); - const outgoing = result.sequenceFlows.filter((f) => f.sourceRef === loopId); - // Only the entry from start: the body throws, so there is no back-edge. - expect(incoming).toHaveLength(1); - // The conditioned entry into the body and the unconditioned exit. - expect(outgoing).toHaveLength(2); - expect(flow(result, loopId, 'Done')).toBeDefined(); - }); - - // Revert: the back-edge added without `body.exitFlowId` -> the inner exit - // loses its reserved id, and `irToXml` refuses the default the inner gateway - // still declares. + expect(result.sequenceFlows).toEqual([ + edge(loopId, 'B', { condition: '${c}' }), + edge(loopId, 'Done', { id: makeDefaultFlowId(loopId) }), + edge('StartEvent_P', loopId), + ]); + }); + it("a body ending in a loop carries that loop's reserved exit on the back-edge", async () => { const outerLoop = makeGatewayLoopId('P_0'); const innerLoop = makeGatewayLoopId('P_0_0'); @@ -427,79 +421,46 @@ describe('astToIr: do-while loop', () => { }); describe('astToIr: goto', () => { - it('emits a sequence flow to the node named Foo', async () => { - const result = await ir(`process P { user A goto Foo user Foo end Done }`); - // The implicit flow out of A lands on the goto target Foo. - const gotoFlow = flow(result, 'A', 'Foo'); - expect(gotoFlow.targetRef).toBe('Foo'); - // No synthesized node is created for the goto itself. - expect(result.flowElements.map((fe) => fe.id)).not.toContain('goto'); - }); - - it('suppresses implicit fall-through after a goto', async () => { - // After `goto Foo` control transfers, so no implicit end follows the goto. - const result = await ir(`process P { user A goto A }`); - // The only flows are start->A and the back-jump A->A. - const selfJump = flow(result, 'A', 'A'); - expect(selfJump).toBeDefined(); - // No synthesized end (control never falls off the end). - expect(result.flowElements.filter((fe) => fe.kind === 'endEvent')).toEqual( - [], - ); - }); - - it('a goto into a compound block resolves to the compound body entry', async () => { - // Only leaf statements expose `name=ID`, so a goto into a compound names - // the first statement of its body and bypasses the split gateway, which - // still routes the true branch there itself. - const result = await ir( - `process P { user A goto Inner if (x) { user Inner } }`, - ); - const splitId = makeGatewaySplitId('P_2'); - - expect(flow(result, 'A', 'Inner').targetRef).toBe('Inner'); - expect(flow(result, splitId, 'Inner')).toBeDefined(); - }); - + const splitId = makeGatewaySplitId('P_2'); + const joinId = makeGatewayJoinId('P_2'); it.each([ - ['a topic-bound service task', 'service Ship(topic: "shipping")', 'Ship'], - ['a script task', 'script Calc ```js\nx = 1;\n```', 'Calc'], - ])( - 'lands on %s, whose name is registered even though its head takes no plain block', - async (_title, declaration, target) => { - const result = await ir( - `process P { user A goto ${target} ${declaration} }`, - ); - expect(flow(result, 'A', target).targetRef).toBe(target); - }, - ); + [ + 'lands the flow out of the step before it on the target, minting no node of its own', + `user A goto Foo user Foo end Done`, + ['StartEvent_P', 'A', 'Foo', 'Done'], + [edge('A', 'Foo'), edge('Foo', 'Done'), edge('StartEvent_P', 'A')], + ], + [ + 'ends the chain, so no implicit end follows it', + `user A goto A`, + ['StartEvent_P', 'A'], + [edge('A', 'A'), edge('StartEvent_P', 'A')], + ], + [ + // Only leaf statements carry a name, so a goto into a compound names the + // first statement of its body and bypasses the split. + "names a compound body's first statement, which the split still routes to itself", + `user A goto Inner if (x) { user Inner }`, + ['StartEvent_P', 'A', splitId, joinId, 'Inner', 'EndEvent_P'], + [ + edge('A', 'Inner'), + edge(splitId, 'Inner', { condition: '${x}' }), + edge('Inner', joinId), + edge(splitId, joinId, { id: makeDefaultFlowId(splitId) }), + edge('StartEvent_P', 'A'), + edge(joinId, 'EndEvent_P'), + ], + ], + ])('%s', async (_title, statements, elementIds, flows) => { + const result = await ir(`process P { ${statements} }`); + expect(allElementIds(result)).toEqual(elementIds); + expect(result.sequenceFlows).toEqual(flows); + }); }); -describe('astToIr: synthesized id determinism', () => { - it('nested compound coordinates nest by structural index', async () => { - // An `if` at body index 0 whose `then` block holds a `while` at index 0. - const result = await ir(`process P { if (x) { while (y) { user A } } }`); - const ids = result.flowElements.map((fe) => fe.id); - expect(ids).toContain(makeGatewaySplitId('P_0')); - // The while's coordinate is the if's coord (`P_0`) plus the `then` branch's - // discriminating segment (`_t`) plus its own index (`_0`). - expect(ids).toContain(makeGatewayLoopId('P_0_t_0')); - }); - - it('two re-parses of the same source produce byte-identical IR (determinism)', async () => { - const source = `process P { - user Pre - if (amount > 1000) { user S } else { service A(class: "com.example.X") } - parallel { { user L } { user R } } - }`; - const a = await ir(source); - const b = await ir(source); - expect(JSON.stringify(a)).toEqual(JSON.stringify(b)); - }); - - // Gateway ids are purely positional and never consult `taken`; what - // `collectNamedIds` guards is the implicit start/end ids, which do. A - // statement named like the synthesized end pushes that end to `_2`. +describe('astToIr: authored names against synthesized ids', () => { + // Gateway ids are positional and never consult `taken`; only the implicit + // start and end ids do. it.each([ ['user EndEvent_P'], ['step EndEvent_P'], @@ -519,11 +480,8 @@ describe('astToIr: synthesized id determinism', () => { }); describe('astToIr: attribute mapping', () => { - /** - * Where each process header key lands in the IR. The lowering reads the six - * one by one because they land on differently named fields, so this pairing - * is what keeps it from drifting behind the vocabulary. - */ + // Where each header key lands. The lowering reads the six one by one, so + // this pairing is what keeps it from drifting behind the vocabulary. const HEADER_FIELD_BY_KEY: Readonly> = { label: 'name', documentation: 'documentation', @@ -567,11 +525,6 @@ describe('astToIr: attribute mapping', () => { }); it.each([ - [ - 'a label setting becomes the process name', - `process P(label: "My Process") { user A }`, - { name: 'My Process' }, - ], ['a bare header leaves every key out', `process P { user A }`, {}], [ 'a duplicated versionTag keeps the first (the validator owns the diagnostic)', @@ -583,15 +536,6 @@ describe('astToIr: attribute mapping', () => { `process p(label: "Invoice", versionTag: "1.4") { var amount: number user A }`, { name: 'Invoice', versionTag: '1.4' }, ], - [ - 'a header carrying all three engine settings reaches the IR unchanged', - `process p(historyTimeToLive: "P90D", candidateStarterUsers: "demo,manager", candidateStarterGroups: "adjusters") { user A }`, - { - historyTimeToLive: 'P90D', - candidateStarterUsers: 'demo,manager', - candidateStarterGroups: 'adjusters', - }, - ], ])('%s', async (_title, source, expected) => { const { name, @@ -618,7 +562,6 @@ describe('astToIr: attribute mapping', () => { }); describe('astToIr: documentation setting', () => { - /** `documentation` on a `FlowElement` needs `in`, since three kinds carry no such field at all. */ const docOf = (container: FlowContainer, id: string): string | undefined => { const node = byId(container, id); return 'documentation' in node ? node.documentation : undefined; @@ -729,7 +672,6 @@ describe('astToIr: service task binding variants', () => { expect(only(result, 'serviceTask').binding).toEqual(binding); }); - // Revert: drop the `type` branch from `writtenBinding` -> every row binds nothing. const TAGS = [ ['service', undefined], ['send', 'send'], @@ -788,21 +730,18 @@ describe('astToIr: script task', () => { 'drops a trailing \\r from a \\r\\n-terminated opening fence line', 'process P { script total ```js\r\ntotal = amount * 1.1;\n``` }', ], + [ + // `ScriptingEngines.getScriptEngineForLanguage` lowercases the language + // before the lookup, so a mixed-case tag lowers to the canonical + // spelling. + 'lowers a mixed-case fence tag to its canonical scriptFormat', + 'process P { script total ```JS\ntotal = amount * 1.1;\n``` }', + ], ])('%s', async (_title, source) => { expect(only(await ir(source), 'scriptTask')).toEqual( scriptTask('total', 'javascript', 'total = amount * 1.1;\n'), ); }); - - // `ScriptingEngines.getScriptEngineForLanguage` lowercases the language - // before it looks an engine up, so a mixed-case tag still lowers to the - // canonical spelling rather than being carried through as written. - it('a mixed-case fence tag lowers to its canonical scriptFormat', async () => { - const source = 'process P { script total ```JS\nx = 1;\n``` }'; - expect(only(await ir(source), 'scriptTask')).toEqual( - scriptTask('total', 'javascript', 'x = 1;\n'), - ); - }); }); describe('astToIr: task-kind lowering (step/send/receive/decide)', () => { @@ -841,7 +780,6 @@ describe('astToIr: task-kind lowering (step/send/receive/decide)', () => { expect('messageName' in unnamed).toBe(false); }); - /** The binding a `decide D(decision: "riskRating", )` lowers to. */ const decideBinding = async (member: string) => only( await ir( @@ -948,93 +886,24 @@ describe('astToIr: sibling-branch coordinate uniqueness', () => { 'Z', 'EndEvent_P', ]); - - const gatewayIds = xorGatewayIds(result); - // then -> `_t`, first else-if -> `_e0`, else -> `_e`. - expect(gatewayIds).toContain(makeGatewaySplitId('P_0_t_0')); - expect(gatewayIds).toContain(makeGatewaySplitId('P_0_e0_0')); - expect(gatewayIds).toContain(makeGatewaySplitId('P_0_e_0')); }); }); -describe('astToIr: all synthesized ids are globally unique (property check)', () => { - // Every representative desugaring shape from the suite above. If any pair of - // synthesized gateway/event ids collides, the resulting IR is malformed. - const FIXTURES: { name: string; source: string }[] = [ - { name: 'implicit sequence', source: `process P { user A user B }` }, - { - name: 'explicit start/end', - source: `process P { start S user A end E }`, - }, - { - name: 'if/else', - source: `process P { if (x) { user S } else { service A(class: "c") } }`, - }, - { - name: 'else-if chain', - source: `process P { if (a) { user X } else if (b) { user Y } else { user Z } }`, - }, - { - name: 'while', - source: `process P { user Pre while (r) { user R } user Post }`, - }, - { name: 'do-while', source: `process P { do { user R } while (r) }` }, - { - name: 'parallel', - source: `process P { parallel { { user A } { user B } } }`, - }, - { - name: 'nested if in if-then', - source: `process P { if (x) { if (y) { user A } } }`, - }, - { - name: 'nested if in then vs else', - source: `process P { if (a) { if (b) { user X } } else { if (c) { user Y } } }`, - }, - { - name: 'race with a nested parallel branch', - source: `process P { - await { - message("M") { parallel { { user X } { user Y } } } - signal("S") { user Z } - } - }`, - }, - { - name: 'nested compound in parallel branches', - source: `process P { parallel { { if (a) { user X } } { if (b) { user Y } } } }`, - }, - { - name: 'nested compounds in two sibling parallel branches and loops', - source: `process P { - while (a) { if (b) { user X } } - do { if (c) { user Y } } while (d) - parallel { { while (e) { user Z } } { while (f) { user W } } } - }`, - }, - ]; - - for (const { name, source } of FIXTURES) { - it(`${name}: every element id is unique`, async () => { - const result = await ir(source); - const ids = allElementIds(result); - const seen = new Map(); - for (const id of ids) seen.set(id, (seen.get(id) ?? 0) + 1); - const dups = [...seen.entries()] - .filter(([, n]) => n > 1) - .map(([id]) => id); - expect(dups).toEqual([]); - }); - } +describe('astToIr: synthesized ids never collide', () => { + it('compounds nested in sibling parallel branches and in both loop bodies keep distinct gateway ids', async () => { + const result = await ir(`process P { + while (a) { if (b) { user X } } + do { if (c) { user Y } } while (d) + parallel { { while (e) { user Z } } { while (f) { user W } } } + }`); + const ids = allElementIds(result); + expect(ids.filter((id, i) => ids.indexOf(id) !== i)).toEqual([]); + }); - /** - * The four shapes that hold an id back for a flow they have not pushed yet: - * an `if` chain's fallback, an inclusive fork's fallback, and the exit of - * either loop. The id is `Flow__default`, which is also what a - * statement named `default` reached from that same gateway would take for - * its own incoming flow, so the claim has to be made before the branch is - * lowered or the document carries two flows under one id. - */ + // The four shapes that reserve a default-flow id before pushing the flow. A + // statement named `default` reached from the same gateway would take that + // `Flow__default` for its own incoming flow, so the claim has to + // precede the branch. const RESERVING_SHAPES = [ `process P { if (a) { user default } else { user B } }`, `process P { parallel { if (a) { user default } else { user B } } }`, @@ -1055,9 +924,8 @@ describe('astToIr: all synthesized ids are globally unique (property check)', () }); it('holds no id back for a split that weighs nothing, so a `default` beside it keeps the plain one', async () => { - // An AND split leaves no branch out, so it names no fallback and pushes no - // flow under the reserved id. Claiming it anyway would move the authored - // collider onto a suffixed id with nothing to show for it. + // An AND split leaves no branch out, so it names no fallback; claiming the + // id anyway would move the authored collider onto a suffix for nothing. const result = await ir( `process P { parallel { { user default } { user B } } }`, ); @@ -1109,43 +977,34 @@ describe('astToIr: sub-process lowering', () => { }); }); - it('roots the body coordinate at the sub-process own structural coordinate', async () => { - const result = await ir( - `process p { - user Pre(assignee: "x") - subprocess S { if (c) { user A(assignee: "y") } } - }`, - ); - const sub = subProcess(result, 'S'); - const gatewayIds = xorGatewayIds(sub); - // Positional coordinate `p_1` roots the body; the nested `if` is `p_1_0`. - expect(gatewayIds).toContain(makeGatewaySplitId('p_1_0')); - expect(gatewayIds).toContain(makeGatewayJoinId('p_1_0')); - }); - - it('composes coordinates through if-branch segments and nested sub-processes', async () => { - // A sub-process at index 0 of the `then` block of an `if` at process index 2 - // has coordinate `p_2_t_0`; a compound in its body is `p_2_t_0_0`. - const inThen = await ir( - `process p { - user A(assignee: "x") - user B(assignee: "x") - if (c) { subprocess S { if (d) { user X(assignee: "x") } } } - }`, - ); - const sInThen = subProcess(inThen, 'S'); - expect(xorGatewayIds(sInThen)).toContain(makeGatewaySplitId('p_2_t_0_0')); - - // A sub-process nested in a sub-process composes coordinates: outer `p_1`, - // inner `p_1_0`; a compound in the inner body is `p_1_0_0`. - const nested = await ir( - `process p { - user A(assignee: "x") - subprocess Outer { subprocess Inner { if (d) { user X(assignee: "x") } } } - }`, - ); - const inner = subProcess(subProcess(nested, 'Outer'), 'Inner'); - expect(xorGatewayIds(inner)).toContain(makeGatewaySplitId('p_1_0_0')); + it.each([ + [ + 'roots the body coordinate at the sub-process own structural coordinate', + `user Pre subprocess S { if (c) { user A } }`, + ['S'], + 'p_1_0', + ], + [ + 'composes the coordinate through an if-branch segment', + `user A user B if (c) { subprocess S { if (d) { user X } } }`, + ['S'], + 'p_2_t_0_0', + ], + [ + 'composes the coordinate through a nested sub-process', + `user A subprocess Outer { subprocess Inner { if (d) { user X } } }`, + ['Outer', 'Inner'], + 'p_1_0_0', + ], + ])('%s', async (_title, statements, path, coordinate) => { + const sub = path.reduce( + (container: FlowContainer, id) => subProcess(container, id), + await ir(`process p { ${statements} }`), + ); + expect(xorGatewayIds(sub)).toEqual([ + makeGatewaySplitId(coordinate), + makeGatewayJoinId(coordinate), + ]); }); it('maps an inline label to the container name, an empty body still getting its start/end pair', async () => { @@ -1172,31 +1031,32 @@ describe('astToIr: sub-process lowering', () => { goto S }`, ); - expect(flow(result, 'Begin', 'Before')).toBeDefined(); - expect(flow(result, 'Before', 'S')).toBeDefined(); - expect(flow(result, 'S', 'After')).toBeDefined(); - // A `goto S` in the parent lands on the sub-process: its entry is its id. - expect(flow(result, 'After', 'S')).toBeDefined(); + expect(result.sequenceFlows).toEqual([ + edge('Begin', 'Before'), + edge('Before', 'S'), + edge('S', 'After'), + edge('After', 'S'), + ]); }); it('collision-resolves name-seeded implicit ids against the process-wide taken set', async () => { - // An explicit parent step already occupies `EndEvent_S`, so the - // sub-process's implicit end falls back to `EndEvent_S_2`. const result = await ir( `process p { user EndEvent_S(assignee: "x") subprocess S { user A(assignee: "x") } }`, ); - expect(result.flowElements.map((fe) => fe.id)).toContain('EndEvent_S'); - const sub = subProcess(result, 'S'); - const ids = sub.flowElements.map((fe) => fe.id); - expect(ids).toContain('StartEvent_S'); - expect(ids).toContain('EndEvent_S_2'); - expect(ids).not.toContain('EndEvent_S'); - - const all = allElementIdsDeep(result); - expect(new Set(all).size).toBe(all.length); + expect(allElementIds(result)).toEqual([ + 'StartEvent_p', + 'EndEvent_S', + 'S', + 'EndEvent_p', + ]); + expect(allElementIds(subProcess(result, 'S'))).toEqual([ + 'StartEvent_S', + 'A', + 'EndEvent_S_2', + ]); }); it('lowers everything but the tag identically under both heads', async () => { @@ -1213,15 +1073,6 @@ describe('astToIr: sub-process lowering', () => { expect(attempted).toEqual({ ...plain, element: 'transaction' }); }); - it('keeps the tag clear of the repeat clause loop variable', async () => { - const block = subProcess( - await ir(`process p { attempt B for each line in lines { step Q } }`), - 'B', - ); - expect(block.element).toBe('transaction'); - expect(block.loop?.elementVariable).toBe('line'); - }); - it('tags each block by its own head when they nest', async () => { const result = await ir( `process p { attempt Outer { subprocess Inner { attempt Deep { } } } }`, @@ -1296,28 +1147,19 @@ describe('astToIr: call activity lowering', () => { expect(call).toEqual(expected); }); - it('lowers a dotted mapping source to the expression variant, not variable', async () => { - const result = await ir( - `process p { call X(process: "p") { in doubled = order.total } }`, - ); - // A VarRef carrying accessors is not a bare single-segment reference, so it - // renders through renderExpression into the expression variant rather than - // a plain variable copy. - expect(only(result, 'callActivity').inMappings).toEqual([ - { - kind: 'expression', - sourceExpression: '${order.total}', - target: 'doubled', - }, - ]); - }); - - it('lowers a literal mapping source to its bare text, not wrapped in ${...}', async () => { + it.each([ + [ + 'a dotted source is an expression, not a variable', + 'order.total', + '${order.total}', + ], + ['a literal source is its bare text, not wrapped in ${...}', '"u"', 'u'], + ])('%s', async (_title, written, sourceExpression) => { const result = await ir( - `process p { call X(process: "p") { in y = "u" } }`, + `process p { call X(process: "p") { in y = ${written} } }`, ); expect(only(result, 'callActivity').inMappings).toEqual([ - { kind: 'expression', sourceExpression: 'u', target: 'y' }, + { kind: 'expression', sourceExpression, target: 'y' }, ]); }); @@ -1328,7 +1170,6 @@ describe('astToIr: call activity lowering', () => { expect(only(result, 'callActivity').businessKey).toBe('abc'); }); - /** The binding a `call X` carrying the given extra member lowers to. */ const callBinding = async (member: string) => only( await ir(`process p { call X${parens('process: "p"', member)} }`), @@ -1354,31 +1195,6 @@ describe('astToIr: call activity lowering', () => { const empty = await ir(`process p { call X { } }`); expect(only(empty, 'callActivity').calledElement).toBe(''); }); - - it('maps the label to name and resolves a goto elsewhere to the call node', async () => { - const result = await ir( - `process p { user A goto F call F(label: "Fulfil order", process: "p") }`, - ); - const call = only(result, 'callActivity'); - expect(call.name).toBe('Fulfil order'); - expect(flow(result, 'A', 'F').targetRef).toBe('F'); - }); - - it('lowers a call inside a subprocess body into the nested container', async () => { - const result = await ir( - `process p { subprocess S { call C(process: "p") } }`, - ); - const sub = subProcess(result, 'S'); - expect(only(sub, 'callActivity').id).toBe('C'); - expect(result.flowElements.map((fe) => fe.id)).not.toContain('C'); - expect( - result.sequenceFlows.some( - (f) => f.sourceRef === 'C' || f.targetRef === 'C', - ), - ).toBe(false); - expect(flow(sub, 'StartEvent_S', 'C')).toBeDefined(); - expect(flow(sub, 'C', 'EndEvent_S')).toBeDefined(); - }); }); describe('astToIr: on-handler lowering', () => { @@ -1421,19 +1237,20 @@ describe('astToIr: on-handler lowering', () => { `process p { error PF ${head} { start In(label: "Caught") service R(class: "x.R") } }`, ); const handler = subProcess(result, makeEventSubProcessId('p_0')); - // No start event is synthesized: the explicit start is the trigger start. - expect(handler.flowElements.map((fe) => fe.id)).not.toContain( - makeStartEventId(makeEventSubProcessId('p_0'), new Set()), - ); - const start = only(handler, 'startEvent'); - expect(start.id).toBe('In'); - expect(start.name).toBe('Caught'); - expect(start.eventDefinition).toEqual(expected); + expect(handler.flowElements).toEqual([ + { + kind: 'startEvent', + id: 'In', + name: 'Caught', + eventDefinition: expected, + }, + serviceTask('R', classBinding('x.R')), + { kind: 'endEvent', id: makeEndEventId(handler.id, new Set()) }, + ]); }, ); it('roots the handler body coordinate at its own structural coordinate', async () => { - // Handler at process index 2, `if` at handler-body index 1 -> coord p_2_1. const result = await afterA( ` service B(class: "x.B") on error(PF) { @@ -1444,27 +1261,10 @@ describe('astToIr: on-handler lowering', () => { 'error PF', ); const handler = subProcess(result, makeEventSubProcessId('p_2')); - const gatewayIds = xorGatewayIds(handler); - expect(gatewayIds).toContain(makeGatewaySplitId('p_2_1')); - expect(gatewayIds).toContain(makeGatewayJoinId('p_2_1')); - }); - - it('composes a handler nested inside a sub-process', async () => { - const result = await ir( - `process p { - error PF - subprocess S { - service A(class: "x.A") - on error(PF) { service R(class: "x.R") } - } - }`, - ); - // subprocess S is p_0, handler body index 1 -> EventSubProcess_p_0_1. - const sub = subProcess(result, 'S'); - const handler = subProcess(sub, makeEventSubProcessId('p_0_1')); - expect(handler.triggeredByEvent).toBe(true); - const all = allElementIdsDeep(result); - expect(new Set(all).size).toBe(all.length); + expect(xorGatewayIds(handler)).toEqual([ + makeGatewaySplitId('p_2_1'), + makeGatewayJoinId('p_2_1'), + ]); }); it.each([ @@ -1564,30 +1364,24 @@ describe('astToIr: hosted-handler lowering', () => { expect(boundary.eventDefinition).toEqual(errorDef('PF')); expect(boundary.cancelActivity).toBeUndefined(); - // No wrapping container: the body's step is a sibling of the main flow, and - // the boundary node follows its host in the element list, because the layout - // engine needs the host shape before the attacher. - expect( - result.flowElements.filter((fe) => fe.kind === 'subProcess'), - ).toEqual([]); - const ids = result.flowElements.map((fe) => fe.id); - expect(ids.indexOf('A')).toBeLessThan(ids.indexOf(boundaryId)); - expect(ids).toContain('R'); - - // The escape chain runs boundary -> R -> its own implicit end, seeded from - // the boundary id, and never rejoins the main flow. + // The boundary follows its host in the element list: the layout engine + // needs the host shape before the attacher. const escapeEndId = makeEndEventId(boundaryId, new Set()); - expect(flow(result, boundaryId, 'R')).toBeDefined(); - expect(flow(result, 'R', escapeEndId)).toBeDefined(); + expect(allElementIds(result)).toEqual([ + 'StartEvent_p', + 'A', + boundaryId, + 'R', + escapeEndId, + 'EndEvent_p', + ]); expect(byId(result, escapeEndId).kind).toBe('endEvent'); - - // The main chain is untouched, and the container's own end id does not - // shift because a handler is present. - expect(flow(result, 'StartEvent_p', 'A')).toBeDefined(); - expect(flow(result, 'A', 'EndEvent_p')).toBeDefined(); - expect(result.sequenceFlows.some((f) => f.targetRef === boundaryId)).toBe( - false, - ); + expect(result.sequenceFlows).toEqual([ + edge(boundaryId, 'R'), + edge('R', escapeEndId), + edge('StartEvent_p', 'A'), + edge('A', 'EndEvent_p'), + ]); }); it('stores cancelActivity: false for an alongside handler only', async () => { @@ -1621,12 +1415,21 @@ describe('astToIr: hosted-handler lowering', () => { 'error PF', ); const boundaryId = makeBoundaryEventId('A', 'error', new Set()); - expect(flow(result, boundaryId, 'R')).toBeDefined(); - expect(flow(result, 'R', 'B')).toBeDefined(); - // The chain transferred control, so no implicit end terminates it. - expect(result.flowElements.map((fe) => fe.id)).not.toContain( - makeEndEventId(boundaryId, new Set()), - ); + expect(allElementIds(result)).toEqual([ + 'StartEvent_p', + 'A', + 'B', + boundaryId, + 'R', + 'EndEvent_p', + ]); + expect(result.sequenceFlows).toEqual([ + edge('A', 'B'), + edge('R', 'B'), + edge(boundaryId, 'R'), + edge('StartEvent_p', 'A'), + edge('B', 'EndEvent_p'), + ]); }); it('still yields boundary -> end for an empty body', async () => { @@ -1646,7 +1449,6 @@ describe('astToIr: hosted-handler lowering', () => { const boundaries = result.flowElements.filter( (fe) => fe.kind === 'boundaryEvent', ); - // Statement order decides which handler keeps the unsuffixed id. expect(boundaries.map((fe) => fe.id)).toEqual([ 'Boundary_A_timer', 'Boundary_A_timer_2', @@ -1668,12 +1470,15 @@ describe('astToIr: hosted-handler lowering', () => { const sub = subProcess(result, 'S'); const boundary = only(sub, 'boundaryEvent'); expect(boundary.attachedToRef).toBe('A'); - expect(sub.flowElements.map((fe) => fe.id)).toContain('R'); - expect(result.flowElements.some((fe) => fe.kind === 'boundaryEvent')).toBe( - false, - ); - const all = allElementIdsDeep(result); - expect(new Set(all).size).toBe(all.length); + expect(allElementIds(sub)).toEqual([ + 'StartEvent_S', + 'A', + boundary.id, + 'R', + makeEndEventId(boundary.id, new Set()), + 'EndEvent_S', + ]); + expect(allElementIds(result)).toEqual(['StartEvent_p', 'S', 'EndEvent_p']); }); it('keeps a host-less handler an event sub-process when a hosted one shares the container', async () => { @@ -1683,8 +1488,6 @@ describe('astToIr: hosted-handler lowering', () => { `, 'error PF escalation LS', ); - // The hosted handler is inline; the host-less one still wraps its body in a - // triggeredByEvent container with a trigger-carrying start. expect(only(result, 'boundaryEvent').attachedToRef).toBe('A'); const handlerId = makeEventSubProcessId('p_2'); const handler = subProcess(result, handlerId); @@ -1714,8 +1517,7 @@ describe('astToIr: hosted-handler lowering', () => { 'error PF escalation LS', ); // A hosted handler's body is not a scope of its own, so a host-less handler - // written inside it guards the whole enclosing container. The event - // sub-process lands beside the process body, not in the escape chain. + // written inside it guards the whole enclosing container. const handler = result.flowElements.find( (fe): fe is SubProcess => fe.kind === 'subProcess' && fe.triggeredByEvent === true, @@ -1739,9 +1541,8 @@ describe('astToIr: hosted-handler lowering', () => { }); it('lowers a handler whose host does not resolve to a boundary event all the same', async () => { - // The desugarer is total: an unresolvable host is a validator diagnostic, - // and the construct a handler lowers to is decided by the presence of the - // host slot, not by whether it resolved. + // An unresolved host is the validator's diagnostic; the lowering keys on + // the host slot, not on resolution. const result = await afterA( 'on Missing: timer("PT1H") { service R(class: "x.R") }', ); @@ -1754,7 +1555,6 @@ describe('astToIr: hosted-handler lowering', () => { }); }); -/** `A -> -> B`, the chain a throw or an emit is written into. */ const throwChain = (statement: string): Promise => afterA( ` ${statement} @@ -1764,9 +1564,8 @@ const throwChain = (statement: string): Promise => ); describe('astToIr: throw/emit lowering', () => { - // The id is the authored name where one is written, else the positional one. // A word the position does not admit still lowers: to `error` for a throw, - // and to `escalation` for an emit, BPMN having no intermediate error throw. + // to `escalation` for an emit, BPMN having no intermediate error throw. it.each([ ['throw error(PF)', makeThrowEventId('p_1'), errorDef('PF')], ['throw error', makeThrowEventId('p_1'), errorDef()], @@ -1787,11 +1586,11 @@ describe('astToIr: throw/emit lowering', () => { const result = await throwChain(statement); expect(byId(result, throwId).kind).toBe('endEvent'); expect(definitionOf(result, throwId)).toEqual(expected); - expect(flow(result, 'A', throwId)).toBeDefined(); - expect(result.sequenceFlows.some((f) => f.sourceRef === throwId)).toBe( - false, - ); - // B is still lowered as a possible jump target, but the throw is terminal. + expect(result.sequenceFlows).toEqual([ + edge('A', throwId), + edge('StartEvent_p', 'A'), + edge('B', 'EndEvent_p'), + ]); expect(byId(result, 'B')).toBeDefined(); }, ); @@ -1929,53 +1728,8 @@ describe('astToIr: error and escalation declarations', () => { }); }); -describe('astToIr: handler totality', () => { - it('keeps the chain intact around a mid-body handler', async () => { - const result = await afterA( - ` on error(X) { service R(class: "x.R") } - service B(class: "x.B") - `, - 'error X', - ); - expect(flow(result, 'A', 'B')).toBeDefined(); - const handlerId = makeEventSubProcessId('p_1'); - for (const f of result.sequenceFlows) { - expect(f.sourceRef).not.toBe(handlerId); - expect(f.targetRef).not.toBe(handlerId); - } - }); -}); - -describe('astToIr: new-trigger composition (nested coordinates)', () => { - it('a timer handler inside a subprocess roots its coordinate at the subprocess body', async () => { - const result = await ir( - `process p { - subprocess S { - service A(class: "x.A") - on timer("PT1H") { service R(class: "x.R") } - } - }`, - ); - const sub = subProcess(result, 'S'); - const start = handlerStart(sub, 'p_0_1'); - expect(start.eventDefinition).toEqual(timerDef('duration', 'PT1H')); - }); - - it('an on-condition handler containing an if nests gateway coordinates under its own handler id', async () => { - const result = await ir( - 'process p { on condition(amount > 100) { service X(class: "x.X") if (c) { service Y(class: "x.Y") } } }', - ); - const handler = subProcess(result, makeEventSubProcessId('p_0')); - const gatewayIds = xorGatewayIds(handler); - expect(gatewayIds).toContain(makeGatewaySplitId('p_0_1')); - expect(gatewayIds).toContain(makeGatewayJoinId('p_0_1')); - const start = only(handler, 'startEvent'); - expect(start.eventDefinition).toEqual(conditionDef('${amount > 100}')); - }); -}); - -describe('astToIr: compensation handler lowering', () => { - it('lowers a compensation handler out of the sequence chain inside a subprocess', async () => { +describe('astToIr: handler inside a sub-process', () => { + it('lowers a compensation handler out of the sequence chain, rooted at the sub-process body coordinate', async () => { const result = await ir( `process p { subprocess S { @@ -2002,55 +1756,6 @@ describe('astToIr: compensation handler lowering', () => { }); }); -describe('astToIr: compensation throw/emit lowering', () => { - it('lowers `emit compensation` normally inside an on-error handler body and inside an on-compensation body', async () => { - const inErrorHandler = await ir( - `process p { error PF on error(PF) { emit compensation } }`, - ); - const errorHandler = subProcess( - inErrorHandler, - makeEventSubProcessId('p_0'), - ); - const emitId = makeThrowEventId('p_0_0'); - expect(byId(errorHandler, emitId).kind).toBe('intermediateThrowEvent'); - expect(definitionOf(errorHandler, emitId)).toEqual({ - kind: 'compensation', - }); - - const inCompensationHandler = await ir( - `process p { on compensation { emit compensation } }`, - ); - const compHandler = subProcess( - inCompensationHandler, - makeEventSubProcessId('p_0'), - ); - expect(byId(compHandler, emitId).kind).toBe('intermediateThrowEvent'); - expect(definitionOf(compHandler, emitId)).toEqual({ kind: 'compensation' }); - }); -}); - -describe('astToIr: compensation coordinate composition', () => { - it('composes coordinates when a compensation handler and an if are siblings inside a subprocess', async () => { - const result = await ir( - `process p { - subprocess S { - if (c) { service Y(class: "x.Y") } - on compensation { service U(class: "x.U") } - } - }`, - ); - const sub = subProcess(result, 'S'); - // S's own coordinate is p_0, so the `if` at S-body index 0 is p_0_0. - const gatewayIds = xorGatewayIds(sub); - expect(gatewayIds).toContain(makeGatewaySplitId('p_0_0')); - expect(gatewayIds).toContain(makeGatewayJoinId('p_0_0')); - - // The compensation handler at S-body index 1 is EventSubProcess_p_0_1. - const start = handlerStart(sub, 'p_0_1'); - expect(start.eventDefinition).toEqual({ kind: 'compensation' }); - }); -}); - describe('astToIr: await intermediate catch lowering', () => { it('lowers `await message` to an intermediate catch wired into the chain', async () => { const result = await throwChain('await message("M")'); @@ -2061,8 +1766,6 @@ describe('astToIr: await intermediate catch lowering', () => { expect(flow(result, catchId, 'B')).toBeDefined(); }); - // Each timer particle maps to its BPMN timerKind with the expression carried - // verbatim; a condition renders through renderExpression, same as an `if`. it.each([ [`process p { await timer("PT1H") }`, timerDef('duration', 'PT1H')], [ @@ -2095,10 +1798,25 @@ describe('astToIr: await intermediate catch lowering', () => { ); const firstId = makeIntermediateCatchEventId('p_0'); const secondId = makeIntermediateCatchEventId('p_1'); - expect(firstId).not.toBe(secondId); - expect(byId(result, firstId).kind).toBe('intermediateCatchEvent'); - expect(byId(result, secondId).kind).toBe('intermediateCatchEvent'); - expect(flow(result, firstId, secondId)).toBeDefined(); + expect(result.flowElements).toEqual([ + { kind: 'startEvent', id: 'StartEvent_p' }, + { + kind: 'intermediateCatchEvent', + id: firstId, + eventDefinition: messageDef('First'), + }, + { + kind: 'intermediateCatchEvent', + id: secondId, + eventDefinition: signalDef('Second'), + }, + { kind: 'endEvent', id: 'EndEvent_p' }, + ]); + expect(result.sequenceFlows).toEqual([ + edge(firstId, secondId), + edge('StartEvent_p', firstId), + edge(secondId, 'EndEvent_p'), + ]); }); }); @@ -2150,22 +1868,12 @@ describe('astToIr: engine attributes (asyncBefore/asyncAfter/exclusive/jobPriori jobPriority: '50', retryCycle: 'PT5M', }; - const UNSET: Partial = { - asyncBefore: undefined, - asyncAfter: undefined, - exclusive: undefined, - jobPriority: undefined, - retryCycle: undefined, - }; - - /** The five engine fields of a node, whether or not it carries any. */ const engineFields = (fe: FlowElement): Partial => { const { asyncBefore, asyncAfter, exclusive, jobPriority, retryCycle } = fe as Partial; return { asyncBefore, asyncAfter, exclusive, jobPriority, retryCycle }; }; - /** Every flow element of a process, keyed by id, as its engine fields. */ const engineFieldsById = ( process: BpmnProcess, ): Record> => @@ -2207,11 +1915,6 @@ describe('astToIr: engine attributes (asyncBefore/asyncAfter/exclusive/jobPriori ); expect(byId(thrown, makeThrowEventId('p_0'))).toMatchObject(SET); }); - - it('reads nothing off a block that declares no engine attribute', async () => { - const result = await ir('process p { user T }'); - expect(engineFields(only(result, 'userTask'))).toEqual(UNSET); - }); }); describe('astToIr: an unauthored optional field is absent, never empty', () => { @@ -2264,7 +1967,6 @@ describe('astToIr: jobPriority/priority numeric-or-EL reading', () => { expect(await jobPriority('"7"')).toBe('7'); expect(await jobPriority('"${priority}"')).toBe('${priority}'); - // A user task priority is the same reading on another field name. const task = await ir('process p { user T(priority: 20) }'); expect(only(task, 'userTask').priority).toBe('20'); }); @@ -2312,11 +2014,9 @@ describe('astToIr: engine attributes on `on` handlers, placed by host slot', () expect(start.jobPriority).toBeUndefined(); }); - // The timer job is the start event's: `BpmnParse.parseTimer` takes its - // priority off that activity and `DefaultFailedJobParseListener.parseStartEvent` - // its retry cycle off that element, and neither looks at the enclosing - // sub-process. Revert: hand the timer keys to the sub-process and the start - // in both rows loses them. + // `BpmnParse.parseTimer` reads the priority and + // `DefaultFailedJobParseListener.parseStartEvent` the retry cycle off the + // start event itself, never the enclosing sub-process. it.each([ [ 'a timer handler puts the three timer-job keys on its start and nothing on the sub-process', @@ -2369,7 +2069,6 @@ describe('astToIr: engine attributes on `on` handlers, placed by host slot', () }); describe('astToIr: input/output parameter value forms', () => { - /** The single input parameter of the process's only user task. */ async function firstInput(source: string) { const result = await ir(source); const params = only(result, 'userTask').inputParameters; @@ -2501,7 +2200,6 @@ describe('astToIr: input/output parameter partition and ordering', () => { }); describe('astToIr: execution listeners', () => { - /** The single execution listener of the process's only service task. */ async function firstListener(source: string) { const result = await ir(source); const listeners = only(result, 'serviceTask').executionListeners; @@ -2654,7 +2352,7 @@ describe('astToIr: task listeners', () => { ['at "2024-01-01T00:00:00Z"', timerDef('date', '2024-01-01T00:00:00Z')], ['every "R3/PT1H"', timerDef('cycle', 'R3/PT1H')], // The template alternative of the time slot carries its quotes out of the - // lexer, so this row is what pins them being stripped. + // lexer; this row pins them stripped. ['at "${deadline}"', timerDef('date', '${deadline}')], ])( 'carries the `%s` clause of a timeout listener as its timer', @@ -2668,11 +2366,6 @@ describe('astToIr: task listeners', () => { }, ); - it('omits the timer on a non-timeout task listener', async () => { - const result = await ir('process p { user T { on create(class: "x.C") } }'); - expect(only(result, 'userTask').taskListeners?.[0]?.timer).toBeUndefined(); - }); - it('omits the list when the block declares no task listener', async () => { const result = await ir('process p { user T { on start(class: "x") } }'); expect(only(result, 'userTask').taskListeners).toBeUndefined(); @@ -2836,8 +2529,7 @@ describe('astToIr: external task extras', () => { ...externalBinding('rate-card'), errorMappings: [{ errorCode: 'order.failed', condition: '${flag}' }], }, - // A class binding takes none of the three: the engine never reads them - // off any owner but `parseExternalServiceTask`. + // The engine reads the three off no owner but `parseExternalServiceTask`. legacy: classBinding('com.example.Legacy'), }); }); @@ -2861,35 +2553,49 @@ describe('astToIr: external task extras', () => { }); }); -describe('astToIr: start/end triggers and message throw/emit', () => { +describe('astToIr: start/end triggers', () => { + const startS = { kind: 'startEvent', id: 'S' } as const; it.each([ - [`start S message("OrderReceived")`, messageDef('OrderReceived')], - [`start S signal("Fired")`, signalDef('Fired')], - [`start S timer("PT1H")`, timerDef('duration', 'PT1H')], + [ + `start S message("OrderReceived")`, + { eventDefinition: messageDef('OrderReceived') }, + ], + [`start S signal("Fired")`, { eventDefinition: signalDef('Fired') }], + [ + `start S timer("PT1H")`, + { eventDefinition: timerDef('duration', 'PT1H') }, + ], [ `start S timer(at: "2026-08-01T09:00:00")`, - timerDef('date', '2026-08-01T09:00:00'), + { eventDefinition: timerDef('date', '2026-08-01T09:00:00') }, ], - [`start S timer(every: "R/PT10M")`, timerDef('cycle', 'R/PT10M')], - [`start S condition(amount > 100)`, conditionDef('${amount > 100}')], - ])('lowers `%s` to its event definition', async (statement, expected) => { - const result = await ir(`process p { ${statement} }`); - expect(only(result, 'startEvent').eventDefinition).toEqual(expected); - }); - - it('keeps a start label beside its trigger', async () => { - const result = await ir( - `process p { start S message("M", label: "Scheduled") }`, - ); - const start = only(result, 'startEvent'); - expect(start.name).toBe('Scheduled'); - expect(start.eventDefinition).toEqual(messageDef('M')); - }); - - it('reads initiator off a process start', async () => { - const result = await ir(`process p { start S(initiator: "starter") }`); - expect(only(result, 'startEvent').initiator).toBe('starter'); - }); + [ + `start S timer(every: "R/PT10M")`, + { eventDefinition: timerDef('cycle', 'R/PT10M') }, + ], + [ + `start S condition(amount > 100)`, + { eventDefinition: conditionDef('${amount > 100}') }, + ], + [ + `start S message("M", label: "Scheduled")`, + { name: 'Scheduled', eventDefinition: messageDef('M') }, + ], + [`start S(initiator: "starter")`, { initiator: 'starter' }], + [ + `start S message("M") { form { amount: number } }`, + { + formFields: [{ id: 'amount', type: 'number' }], + eventDefinition: messageDef('M'), + }, + ], + ])( + 'lowers `%s` to a start carrying exactly that', + async (statement, carried) => { + const result = await ir(`process p { ${statement} }`); + expect(only(result, 'startEvent')).toEqual({ ...startS, ...carried }); + }, + ); it.each([ ['a plain start', `process p { start S user A end E }`, 'startEvent'], @@ -2908,39 +2614,37 @@ describe('astToIr: start/end triggers and message throw/emit', () => { }, ); - it('keeps a terminate end label beside its definition', async () => { - const result = await ir( + it.each([ + [ + 'a terminate end at process level', `process p { start S user A end E terminate(label: "All stop") }`, - ); - const end = only(result, 'endEvent'); - expect(end.name).toBe('All stop'); - expect(end.eventDefinition).toEqual({ kind: 'terminate' }); - }); - - it('lowers a cancel end to a cancel event definition, keeping its label', async () => { - const block = subProcess( - await ir( - `process p { attempt B { end E cancel(label: "Give up the booking") } }`, - ), - 'B', - ); - const end = only(block, 'endEvent'); - expect(end.name).toBe('Give up the booking'); - expect(end.eventDefinition).toEqual({ kind: 'cancel' }); - }); - - it('keeps a start trigger alongside its form fields', async () => { - const result = await ir( - `process p { start S message("M") { form { amount: number } } }`, - ); - const start = only(result, 'startEvent'); - expect(start.eventDefinition).toEqual(messageDef('M')); - expect(start.formFields).toEqual([{ id: 'amount', type: 'number' }]); - }); + [], + { kind: 'terminate' }, + ], + [ + 'a cancel end inside an attempt block', + `process p { attempt B { end E cancel(label: "All stop") } }`, + ['B'], + { kind: 'cancel' }, + ], + ] satisfies Array<[string, string, string[], { kind: string }]>)( + 'lowers %s to its definition, keeping the label', + async (_title, source, path, eventDefinition) => { + const container = path.reduce( + (c: FlowContainer, id) => subProcess(c, id), + await ir(source), + ); + expect(only(container, 'endEvent')).toEqual({ + kind: 'endEvent', + id: 'E', + name: 'All stop', + eventDefinition, + }); + }, + ); }); describe('astToIr: repeat clause', () => { - /** The loop of the one activity a statement lowers to, named `X` throughout. */ const loopOf = async ( statement: string, ): Promise => @@ -3026,7 +2730,6 @@ describe('astToIr: repeat clause', () => { expect(await loopOf(statement)).toEqual({ cardinality: '2' }); }); - /** What `X` carries at each of its two job positions: on the loop, and on the element itself. */ const jobPositionsOf = ( node: FlowElement, ): { loop: LoopCharacteristics | undefined; own: JobSettings } => { @@ -3045,9 +2748,7 @@ describe('astToIr: repeat clause', () => { }; // Both positions in one parens, so a reader taking the other's spelling - // shows. `runJobPriority` has no reader in the engine, so a stray one must - // reach neither position. Revert: read the loop through the identity key - // mapper, or keep `jobPriority` in the loop spread. + // shows; `runJobPriority` has no reader in the engine, so it reaches neither. const BOTH_POSITIONS = 'asyncBefore: true, jobPriority: 5, retryCycle: "R3/PT10M", runAsyncBefore: true, runAsyncAfter: true, runExclusive: false, runRetryCycle: "R2/PT1M", runJobPriority: 7'; @@ -3126,7 +2827,12 @@ describe('astToIr: conditioned parallel branches', () => { const result = await ir( `process P { parallel { { throw error B("e") } { end S } } }`, ); - expect(result.flowElements.some((fe) => fe.id === joinId)).toBe(false); + expect(result.flowElements).toEqual([ + { kind: 'startEvent', id: 'StartEvent_P' }, + { kind: 'parallelGateway', id: forkId }, + { kind: 'endEvent', id: 'B', eventDefinition: errorDef('e') }, + { kind: 'endEvent', id: 'S' }, + ]); }); it('the first else branch takes the reserved default flow id and a second else is a plain branch', async () => { @@ -3140,7 +2846,6 @@ describe('astToIr: conditioned parallel branches', () => { expect(flow(result, forkId, 'B').conditionExpression).toBeUndefined(); expect(flow(result, forkId, 'C').id).toBe(`Flow_${forkId}_C`); expect(flow(result, forkId, 'C').conditionExpression).toBeUndefined(); - // The fallback flow is on the else branch, so none runs to the join. expect( result.sequenceFlows.filter( (f) => f.sourceRef === forkId && f.targetRef === joinId, @@ -3159,11 +2864,9 @@ describe('astToIr: conditioned parallel branches', () => { ).toEqual([edge(forkId, joinId, { condition: '${a > 1}' })]); }); - // A condition on one branch makes both gateways inclusive. Every element - // and flow of the process, so a row pins where the fallback runs and that - // none is written where an unconditioned sibling would leave it dead. - // Revert: the reservation back to any inclusive fork -> the unconditioned - // rows find a fallback. + // A condition on one branch makes both gateways inclusive. Whole element and + // flow lists, so a row pins where the fallback runs and that none is written + // beside an unconditioned sibling. const inclusive = (id: string, flowId?: string): InclusiveGateway => ({ kind: 'inclusiveGateway', id, @@ -3293,11 +2996,14 @@ describe('astToIr: race lowering', () => { const terminating = await ir( `process P { error Failed await { message("M") { end Done } signal("S") { throw error B(Failed) } } }`, ); - expect(terminating.flowElements.some((fe) => fe.id === joinId)).toBe(false); - // Nothing falls through, so the container gets no synthesized end either. - expect(terminating.flowElements.some((fe) => fe.id === 'EndEvent_P')).toBe( - false, - ); + expect(allElementIds(terminating)).toEqual([ + 'StartEvent_P', + raceId, + firstCatch, + 'Done', + secondCatch, + 'B', + ]); }); it('a branch setting lands on the catch event and a head setting on the gateway, neither crossing over', async () => { @@ -3332,12 +3038,19 @@ describe('astToIr: race lowering', () => { } }`, ); - expect(allElementIds(raceInParallel)).toContain( + expect(allElementIds(raceInParallel)).toEqual([ + 'StartEvent_P', + makeGatewayForkId('P_0'), + joinId, makeGatewayRaceId('P_0_b0_0'), - ); - expect(allElementIds(raceInParallel)).toContain( + makeGatewayJoinId('P_0_b0_0'), + makeIntermediateCatchEventId('P_0_b0_0_b0'), + 'X', makeIntermediateCatchEventId('P_0_b0_0_b1'), - ); + 'Y', + 'Z', + 'EndEvent_P', + ]); const parallelInRace = await ir( `process P { @@ -3347,17 +3060,19 @@ describe('astToIr: race lowering', () => { } }`, ); - expect(allElementIds(parallelInRace)).toContain( - makeIntermediateCatchEventId('P_0_b0'), - ); - expect(allElementIds(parallelInRace)).toContain( + expect(allElementIds(parallelInRace)).toEqual([ + 'StartEvent_P', + raceId, + joinId, + firstCatch, makeGatewayForkId('P_0_b0_0'), - ); - - for (const result of [raceInParallel, parallelInRace]) { - const ids = allElementIds(result); - expect(new Set(ids).size).toBe(ids.length); - } + makeGatewayJoinId('P_0_b0_0'), + 'X', + 'Y', + secondCatch, + 'Z', + 'EndEvent_P', + ]); }); }); @@ -3386,10 +3101,8 @@ describe('astToIr: gateway settings', () => { const raceId = makeGatewayRaceId('P_0'); const joinId = makeGatewayJoinId('P_0'); - // Every gateway of the process with the settings it carries, so a row pins - // both that the settings landed and that nothing else was minted. Revert: - // read the join with the identity mapper too -> every row with a join goes - // red; drop the spread from one lowering -> its row goes red. + // Every gateway of the process with what it carries, so a row pins both that + // the settings landed and that nothing else was minted. it.each([ [ 'an if chain mints one split for the head keys, one join for the join keys, and no gateway per else if', @@ -3443,17 +3156,14 @@ describe('astToIr: gateway settings', () => { // ── Local helpers ──────────────────────────────────────────────────────────── -/** The event definition carried by the flow element with the given id. */ function definitionOf(container: FlowContainer, id: string): unknown { return (byId(container, id) as { eventDefinition?: unknown }).eventDefinition; } -/** The execution binding carried by the flow element with the given id. */ function bindingOf(container: FlowContainer, id: string): unknown { return (byId(container, id) as { binding?: unknown }).binding; } -/** What a gateway carries beyond its identity and default flow: its job settings. */ function settingsOn(gateway: Gateway): JobSettings { const { kind: _kind, @@ -3464,27 +3174,10 @@ function settingsOn(gateway: Gateway): JobSettings { return settings; } -/** Collect every flow-element id (events, tasks, gateways) of a process. */ -function allElementIds(process: BpmnProcess): string[] { - return process.flowElements.map((fe) => fe.id); -} - -/** - * Collect every flow-element id across a container and all of its nested - * sub-process containers, the document-uniqueness view. - */ -function allElementIdsDeep(container: FlowContainer): string[] { - const ids: string[] = []; - for (const fe of container.flowElements) { - ids.push(fe.id); - if (fe.kind === 'subProcess') { - ids.push(...allElementIdsDeep(fe)); - } - } - return ids; +function allElementIds(container: FlowContainer): string[] { + return container.flowElements.map((fe) => fe.id); } -/** The start event of the `on` handler lowered at the given coordinate. */ function handlerStart(container: FlowContainer, coordinate: string) { return only( subProcess(container, makeEventSubProcessId(coordinate)), diff --git a/packages/transform/test/cfg-analysis.test.ts b/packages/transform/test/cfg-analysis.test.ts index d385c0c..f88b952 100644 --- a/packages/transform/test/cfg-analysis.test.ts +++ b/packages/transform/test/cfg-analysis.test.ts @@ -1,13 +1,8 @@ /** - * The CFG analysis over a table of control-flow graphs. - * - * The module is pure graph machinery with no DSL knowledge: it builds a - * control-flow graph from a {@link BpmnProcess}, computes dominators and - * post-dominators, and answers the dominance / back-edge queries the - * restructuring pattern catalog needs. Each row pins the complete analysis of - * one graph, so a spurious relation fails as loudly as a missing one. Rows - * sharing an oracle assert an equivalence: the exclusive and the parallel - * diamond, the intermediate catch and the intermediate throw. + * Each row pins the complete analysis of one graph, so a spurious relation + * fails as loudly as a missing one. Rows sharing an oracle assert an + * equivalence: the exclusive and the parallel diamond, the intermediate catch + * and the intermediate throw. */ import { describe, expect, it } from 'vitest'; @@ -457,27 +452,4 @@ describe('cfg analysis', () => { expect(cfg.postDominates('nope', 'm')).toBe(false); expect(cfg.postDominates('m', 'nope')).toBe(false); }); - - it('keeps totality for a node unreachable from the entry and for the two sentinels', () => { - // "orphan" has no path from the entry, so it is absent from the - // dominator tree, but it does reach the exit, so the post-dominator - // tree numbers it independently: the two interval trees answer these - // cases on their own terms rather than sharing one bounds check. - const cfg = analyzeCfg( - graph( - [start('start'), task('reachable'), task('orphan'), end('end')], - 'start>reachable reachable>end orphan>end', - ), - ); - - expect(cfg.dominates('orphan', 'orphan')).toBe(false); - expect(cfg.dominates(VIRTUAL_ENTRY, 'orphan')).toBe(false); - expect(cfg.dominates(VIRTUAL_EXIT, 'orphan')).toBe(false); - expect(cfg.dominates(VIRTUAL_ENTRY, 'end')).toBe(true); - expect(cfg.dominates('end', VIRTUAL_ENTRY)).toBe(false); - - expect(cfg.postDominates(VIRTUAL_EXIT, 'orphan')).toBe(true); - expect(cfg.postDominates('orphan', VIRTUAL_EXIT)).toBe(false); - expect(cfg.postDominates(VIRTUAL_EXIT, VIRTUAL_EXIT)).toBe(true); - }); }); diff --git a/packages/transform/test/form-fields.test.ts b/packages/transform/test/form-fields.test.ts index 9ded410..357a931 100644 --- a/packages/transform/test/form-fields.test.ts +++ b/packages/transform/test/form-fields.test.ts @@ -1,15 +1,7 @@ /** - * Form-field support across the whole pipeline. - * - * A `form { ... }` block on a `start` event or `user` task becomes an - * `operaton:formData` extension element so Operaton Tasklist renders a labeled - * form. These tests pin every transform in both directions: - * - `astToIr`: the AST form block lowers to IR `formFields`. - * - `irToXml`: IR form fields serialize to `operaton:formData`/`formField`, - * mapping the DSL `number` type to Operaton `long`. - * - `xmlToIr`: the extension element is read back (no spurious drop warning), - * mapping `long` to `number`; an unmappable type is refused. - * - `irToDsl`: form fields round-trip back to a `form { ... }` block. + * A `form { ... }` block on a `start` or a `user` task becomes an + * `operaton:formData` extension element, with the DSL `number` type written + * as Operaton's `long`; one suite per transform direction. */ import { beforeAll, describe, expect, it } from 'vitest'; @@ -45,15 +37,11 @@ async function ir(source: string): Promise { return astToIr(doc.parseResult.value); } -function startOf(process: BpmnProcess): StartEvent { - const s = process.flowElements.find((e) => e.kind === 'startEvent'); - return s as StartEvent; -} +const startOf = (process: BpmnProcess): StartEvent => + process.flowElements.find((e) => e.kind === 'startEvent') as StartEvent; -function userOf(process: BpmnProcess): UserTask { - const u = process.flowElements.find((e) => e.kind === 'userTask'); - return u as UserTask; -} +const userOf = (process: BpmnProcess): UserTask => + process.flowElements.find((e) => e.kind === 'userTask') as UserTask; const SOURCE = `process loan(label: "Loan") { start RequestReceived { @@ -101,15 +89,17 @@ describe('irToXml: form fields serialize to operaton:formData', () => { it('emits formData/formField, mapping number to long', async () => { const xml = await irToXml(await ir(SOURCE)); - expect(xml).toContain('operaton:formData'); - expect(xml).toContain('operaton:formField'); - // number -> long, boolean stays boolean. - expect(xml).toMatch(/id="amount"[^>]*type="long"/); - expect(xml).toMatch(/id="creditScore"[^>]*type="long"/); - expect(xml).toMatch(/id="approved"[^>]*type="boolean"/); - expect(xml).toContain('label="Loan amount"'); - expect(xml).toContain('defaultValue="700"'); - expect(xml).toContain('defaultValue="false"'); + expect( + xml.match(/[\s\S]*?<\/operaton:formData>/g), + ).toEqual([ + '\n' + + ' \n' + + ' \n' + + ' ', + '\n' + + ' \n' + + ' ', + ]); }); }); @@ -119,7 +109,6 @@ describe('xmlToIr: form fields round-trip through XML', () => { const xml = await irToXml(original); const { ir: reimported, warnings } = await xmlToIr(xml); - // No warning about the formData extension element being dropped. expect(warnings.filter((w) => /form/i.test(w.message))).toHaveLength(0); expect(startOf(reimported).formFields).toEqual( diff --git a/packages/transform/test/humanize.test.ts b/packages/transform/test/humanize.test.ts index e26f41b..b75f37d 100644 --- a/packages/transform/test/humanize.test.ts +++ b/packages/transform/test/humanize.test.ts @@ -1,8 +1,7 @@ /** - * Unit tests for {@link humanize}: the id -> human-readable BPMN `name` - * derivation. The table doubles as the contract: the IR -> XML serializer - * derives a `name` with this function, and the XML -> IR importer drops any - * `name` equal to it, so the exact mapping must stay stable. + * The table is the contract: `irToXml` derives a `name` with this function + * and `xmlToIr` drops any `name` equal to it, so the exact mapping must stay + * stable. */ import { describe, expect, it } from 'vitest'; @@ -11,13 +10,9 @@ import { humanize } from '../src/humanize.js'; describe('humanize', () => { it.each([ ['invoice-approval', 'Invoice Approval'], - ['parallel-approval', 'Parallel Approval'], ['structured-control-flow', 'Structured Control Flow'], ['ReviewInvoice', 'Review Invoice'], - ['SeniorApproval', 'Senior Approval'], - ['AutoApprove', 'Auto Approve'], ['ApproveA', 'Approve A'], - ['AuditLog', 'Audit Log'], ['Done', 'Done'], ['review_invoice', 'Review Invoice'], ['HTTPRequest', 'HTTP Request'], diff --git a/packages/transform/test/ir-to-dsl.test.ts b/packages/transform/test/ir-to-dsl.test.ts index 4a6a3d4..0c6b41b 100644 --- a/packages/transform/test/ir-to-dsl.test.ts +++ b/packages/transform/test/ir-to-dsl.test.ts @@ -1,10 +1,5 @@ -/** - * `irToDsl` is the inverse of the desugaring `astToIr`: it turns a flat, - * BPMN-shaped IR back into structured DSL source. The IR fixtures are inline - * literals matching byte-for-byte what `astToIr` emits for the corresponding - * source, so the idempotence assertions are exact rather than - * reachability-based. - */ +// The inline IR fixtures are what `astToIr` emits for the source they name, +// so a round trip is asserted exactly rather than by reachability. import { describe, it, expect, beforeAll } from 'vitest'; import { AstUtils, EmptyFileSystem } from 'langium'; @@ -93,15 +88,12 @@ beforeAll(() => { validate = validationHelper(services.BpmnScript); }); -// Normalization helpers mirror the round-trip contract: IR equivalence up to -// synthesized-id renaming, never byte-for-byte text or literal-id equality. - -/** Synthesized-id families that the desugarer mints. */ +// Round-trip equivalence is up to the ids the compiler mints, never byte for +// byte. const SYNTH_GATEWAY = /^Gateway_.*_(split|join|fork|loop)$/; const SYNTH_START = /^StartEvent_/; const SYNTH_END = /^EndEvent_/; -/** Map a possibly-synthesized id to a stable role token for comparison. */ function normId(id: string): string { if (SYNTH_GATEWAY.test(id)) return ''; if (SYNTH_START.test(id)) return ''; @@ -109,31 +101,23 @@ function normId(id: string): string { return id; } -/** Canonical key for an element (kind + normalized id). */ function elemKey(kind: string, id: string): string { return `${kind}:${normId(id)}`; } -/** Canonical key for an edge (normalized endpoints + condition). */ function edgeKey(f: SequenceFlow): string { const cond = f.conditionExpression ? `[${f.conditionExpression}]` : ''; return `${normId(f.sourceRef)}->${normId(f.targetRef)}${cond}`; } -/** Sorted multiset of element keys (order-independent). */ function elementMultiset(ir: BpmnProcess): string[] { return ir.flowElements.map((e) => elemKey(e.kind, e.id)).sort(); } -/** Sorted multiset of edge keys (order-independent). */ function edgeMultiset(ir: BpmnProcess): string[] { return ir.sequenceFlows.map(edgeKey).sort(); } -/** - * Parse `dsl` and assert no parser errors, returning the desugared IR. - * Surfaces parser error messages on failure to make regressions debuggable. - */ async function reDesugar(dsl: string): Promise { const doc = await parse(dsl); const errors = doc.parseResult.parserErrors; @@ -146,12 +130,9 @@ async function reDesugar(dsl: string): Promise { return astToIr(doc.parseResult.value); } -/** - * Errors the compiler draws on what the model holds rather than on how it was - * printed: an id the model chose, a listener the model carries twice, a step - * the model puts where the engine refuses it. A fixture feeding one names it, - * so the gate below stays a gate for everything else. - */ +// Errors the compiler draws on what the model holds rather than on how it was +// printed; a fixture feeding one names it, so `printed` stays a gate for +// everything else. const MODEL_REFUSAL = { reservedId: 'matches a reserved synthesized-id pattern', mintedId: 'is the id the compiler generates for the implicit', @@ -165,14 +146,11 @@ const MODEL_REFUSAL = { deadElse: 'could never run', undoAlongside: 'there is no running flow to run alongside', formDefaultShape: 'The default ', + emptyBlock: 'has no flow steps', } as const; -/** - * Print `ir`, assert the emitted source re-parses and compiles, and return it. - * Parsing alone passes a print the compiler refuses, which is how source that - * draws "can never run" stayed green: the statement a printed jump cut off - * parses fine and lowers to a step nothing reaches. - */ +// Re-parsing alone passes a print the compiler refuses: a statement a printed +// jump cut off parses fine and lowers to a step nothing reaches. async function printed( ir: BpmnProcess, ...refused: (keyof typeof MODEL_REFUSAL)[] @@ -188,23 +166,15 @@ async function printed( return dsl; } -/** - * The source without its `var` lines. The header declares every variable the - * model reads in a position the script writes bare, whether or not the print - * kept that position, so a dropped condition leaves its name there and the - * body is where its absence is asserted. - */ +// The header declares every variable the model reads bare whether or not the +// print kept the reading position, so a dropped condition is asserted absent +// from the body, not from the source. const bodyOf = (source: string): string => source .split('\n') .filter((line) => !line.startsWith(' var ')) .join('\n'); -/** - * Assert local idempotence up to id normalization: `irToDsl(ir)` re-parses and - * re-desugars to an IR with the same normalized element + edge multisets as - * `ir`. - */ async function expectIdempotent( ir: BpmnProcess, ...refused: (keyof typeof MODEL_REFUSAL)[] @@ -216,12 +186,8 @@ async function expectIdempotent( return dsl; } -/** - * Real-node reachability set (gateway-transparent): for every non-gateway node, - * the set of non-gateway nodes reachable through any number of gateway hops. - * In degraded graphs the literal edge set legitimately changes as gateways are - * synthesized, but connectivity between real nodes must be preserved exactly. - */ +// Degrading a graph to jumps re-mints gateways, so what a print must keep +// exactly is which real nodes reach which through any number of gateway hops. function realReachability(ir: BpmnProcess): Set { const real = new Set( ir.flowElements.filter((e) => !isGateway(e)).map((e) => e.id), @@ -254,19 +220,6 @@ const terminating = (end: FlowElement): BpmnProcess => [{ id: 'F', sourceRef: 'S', targetRef: end.id }], ); -/** `true` iff the output contains a top-level `goto` statement. */ -function hasGoto(dsl: string): boolean { - return /\bgoto\s+\w/.test(dsl); -} - -/** `true` iff the output contains the `gateway` keyword. */ -function hasGatewayKeyword(dsl: string): boolean { - // A `gateway` statement would read `gateway ` at the start of a line. - return /(^|\n)\s*gateway\s/.test(dsl); -} - -// Inline IR fixtures: the exact shapes `astToIr` emits for each construct. - /** Desugared `if (amount > 1000) { user B } else { service C }` at body index 2. */ const IF_ELSE_IR: BpmnProcess = minimalProcess( [ @@ -341,58 +294,24 @@ const PARALLEL_IR: BpmnProcess = minimalProcess( ], ); -/** - * The whole printed source for {@link PARALLEL_IR}. Both branches reach the - * join, so the clean-join path handles it and the terminating-branch recovery - * is never entered. - */ -const PARALLEL_SOURCE = - 'process p {\n' + - ' start S\n' + - ' parallel {\n' + - ' {\n' + - ' user X(label: "X")\n' + - ' }\n' + - ' {\n' + - ' service Y(class: "com.example.Y")\n' + - ' }\n' + - ' }\n' + - ' end E\n' + - '}\n'; - -/** - * Canonical invoice IR: the `xmlToIr` import shape of the handwritten golden - * (an XOR split with named branch flows, no explicit join). Drives the - * "structured restructuring of a real import" assertions. - */ -const INVOICE_IR: BpmnProcess = { - ...HANDWRITTEN_IMPORT_IR, - name: 'Invoice Approval', -}; - -/** The whole printed source for {@link IF_ELSE_IR}, asserted from two angles. */ -const IF_ELSE_SOURCE = - 'process p {\n' + - ' var amount: any\n' + - ' start S\n' + - ' user A(label: "A task")\n' + - ' if (amount > 1000) {\n' + - ' user B(label: "B task")\n' + - ' } else {\n' + - ' service C(class: "com.example.C")\n' + - ' }\n' + - ' end E\n' + - '}\n'; - describe('irToDsl: structured restructuring', () => { - // Each row is the whole source, so anything the emitter adds fails it too: - // no `gateway` statement, no `goto`, no `and` between parallel branches, and - // 2-space indentation per nesting level. + // The whole source per row, so anything the emitter adds (a `gateway` + // statement, a `goto`) fails it too. it.each([ [ 'restructures a desugared if/else to `if (...) { } else { }`', IF_ELSE_IR, - IF_ELSE_SOURCE, + 'process p {\n' + + ' var amount: any\n' + + ' start S\n' + + ' user A(label: "A task")\n' + + ' if (amount > 1000) {\n' + + ' user B(label: "B task")\n' + + ' } else {\n' + + ' service C(class: "com.example.C")\n' + + ' }\n' + + ' end E\n' + + '}\n', ], [ 'restructures a desugared while to `while (...) { }`, with no process label where the IR has no name', @@ -421,11 +340,33 @@ describe('irToDsl: structured restructuring', () => { [ 'restructures a desugared parallel to nested `parallel { { } { } }` blocks', PARALLEL_IR, - PARALLEL_SOURCE, + 'process p {\n' + + ' start S\n' + + ' parallel {\n' + + ' {\n' + + ' user X(label: "X")\n' + + ' }\n' + + ' {\n' + + ' service Y(class: "com.example.Y")\n' + + ' }\n' + + ' }\n' + + ' end E\n' + + '}\n', ], - [ - 'restructures the canonical invoice import to if/else under a labeled process header', - INVOICE_IR, + ])( + '%s, and round-trips to an equivalent IR', + async (_title, ir, expected) => { + expect(await expectIdempotent(ir)).toBe(expected); + }, + ); + + it('restructures the handwritten invoice import to if/else under a labeled header, and its header, assignees, binding and condition survive the round trip', async () => { + const ir: BpmnProcess = { + ...HANDWRITTEN_IMPORT_IR, + name: 'Invoice Approval', + }; + const dsl = await printed(ir); + expect(dsl).toBe( 'process invoice-approval(label: "Invoice Approval") {\n' + ' var amount: any\n' + ' start ReviewStart\n' + @@ -437,60 +378,25 @@ describe('irToDsl: structured restructuring', () => { ' }\n' + ' end Done\n' + '}\n', - ], - ])('%s', async (_title, ir, expected) => { - expect(await printed(ir)).toBe(expected); - }); -}); - -describe('irToDsl: local idempotence (re-desugar equivalence)', () => { - it.each([ - ['if/else round-trips to an equivalent IR', IF_ELSE_IR], - ['while round-trips to an equivalent IR (back-edge consumed)', WHILE_IR], - ['do-while round-trips to an equivalent IR', DO_WHILE_IR], - ['parallel round-trips to an equivalent IR', PARALLEL_IR], - ])('%s', async (_title, ir) => { - await expectIdempotent(ir); - }); - - it('invoice import preserves assignee, class binding and condition through re-desugar', async () => { - const ir = await reDesugar(await printed(INVOICE_IR)); - - const review = ir.flowElements.find( - (e) => e.kind === 'userTask' && e.id === 'ReviewInvoice', ); - expect(review?.kind === 'userTask' && review.assignee).toBe('demo'); - const auto = ir.flowElements.find( - (e) => e.kind === 'serviceTask' && e.id === 'AutoApprove', + const back = await reDesugar(dsl); + expect([back.id, back.name, back.isExecutable]).toEqual([ + 'invoice-approval', + 'Invoice Approval', + true, + ]); + expect(back.flowElements.filter((e) => !isGateway(e))).toEqual( + ir.flowElements.filter((e) => !isGateway(e)), ); expect( - auto?.kind === 'serviceTask' && - auto.binding.kind === 'class' && - auto.binding.className, - ).toBe('com.example.invoice.AutoApproveDelegate'); - - const cond = ir.sequenceFlows.find( - (f) => f.conditionExpression !== undefined, - ); - expect(cond?.conditionExpression).toBe('${amount > 1000}'); - }); - - it('process id, name and isExecutable survive the round-trip', async () => { - const ir = await reDesugar(await printed(INVOICE_IR)); - expect(ir.id).toBe('invoice-approval'); - expect(ir.name).toBe('Invoice Approval'); - expect(ir.isExecutable).toBe(true); + back.sequenceFlows.flatMap((f) => f.conditionExpression ?? []), + ).toEqual(['${amount > 1000}']); }); }); describe('irToDsl: goto degradation (every edge with a form keeps it)', () => { - /** - * Hand-built unstructured IR: two XOR gateways whose branches cross so no - * single post-dominating join exists (`G2` re-enters `A`, which `G1` also - * targets). The contract: >=1 `goto`, valid source, and every real-node - * connection preserved on re-desugar. - */ + /** Two XOR splits whose branches cross (`G2` re-enters `A`, which `G1` also targets), so no join post-dominates either. */ const IRREDUCIBLE_IR: BpmnProcess = minimalProcess( [ { kind: 'startEvent', id: 'S' }, @@ -513,17 +419,16 @@ describe('irToDsl: goto degradation (every edge with a form keeps it)', () => { it('emits valid source with at least one goto, losing no real-node connection', async () => { const dsl = await printed(IRREDUCIBLE_IR); - expect(hasGoto(dsl)).toBe(true); + expect(dsl).toMatch(/\bgoto\s+\w/); expect(realReachability(await reDesugar(dsl))).toEqual( realReachability(IRREDUCIBLE_IR), ); }); /** - * An XOR split with three routes out, unreachable through the desugaring - * pipeline (a desugared XOR always weighs at least one route) but a shape the - * emitter must still be total on. `weighed` puts a condition on the first - * route, leaving one surplus unconditioned route or two. + * An XOR split with three routes out, which no desugared source produces (the + * compiler weighs at least one route) but the emitter must still be total + * on; `weighed` conditions the first route. */ const threeWayXor = (weighed?: string): BpmnProcess => minimalProcess( @@ -607,7 +512,6 @@ describe('irToDsl: goto degradation (every edge with a form keeps it)', () => { for (const ir of degenerate) { const dsl = irToDsl(ir); expect(typeof dsl).toBe('string'); - // Each must re-parse without parser errors (totality). await reDesugar(dsl); } }); @@ -633,16 +537,19 @@ describe('irToDsl: multiple and named ends', () => { it('emits both named ends as explicit `end` statements, losing neither end connection', async () => { const dsl = await printed(TWO_ENDS_IR); - expect(dsl).toContain('end Approved(label: "Approved")'); - expect(dsl).toContain('end Rejected(label: "Rejected")'); - - const ir = await reDesugar(dsl); - const ends = ir.flowElements - .filter((e) => e.kind === 'endEvent') - .map((e) => e.id) - .sort(); - expect(ends).toEqual(['Approved', 'Rejected']); - expect(realReachability(ir)).toEqual(realReachability(TWO_ENDS_IR)); + expect(dsl).toBe( + 'process p {\n' + + ' var ok: any\n' + + ' start S\n' + + ' if (ok) {\n' + + ' end Approved(label: "Approved")\n' + + ' }\n' + + ' end Rejected(label: "Rejected")\n' + + '}\n', + ); + expect(realReachability(await reDesugar(dsl))).toEqual( + realReachability(TWO_ENDS_IR), + ); }); }); @@ -678,7 +585,6 @@ describe('irToDsl: service-task bindings', () => { 'renders a delegateExpression binding with the `delegate` alias', serviceTask('Ship', delegateBinding('${shipDelegate}')), 'service Ship(delegate: "${shipDelegate}")', - // The XML-level `delegateExpression` name never surfaces in the source. 'delegateExpression', 'delegateExpression', ], @@ -686,7 +592,6 @@ describe('irToDsl: service-task bindings', () => { 'renders an external binding as `service X(topic: "...")`', serviceTask('Notify', externalBinding('notifications')), 'service Notify(topic: "notifications")', - // An external binding keeps the `service` keyword, never `external`. 'external Notify', 'external', ], @@ -703,12 +608,9 @@ describe('irToDsl: service-task bindings', () => { }); }); -/** - * The behaviour Operaton builds itself for `operaton:type="mail"`/`"shell"` - * carries fields exactly as a class binding does, and its own checks refuse a - * mail task with no `to` or a shell task with no `command`, so a dropped field - * fails the validation `printed` runs before the binding comparison does. - */ +// Operaton's own `operaton:type="mail"`/`"shell"` behaviours carry fields as a +// class binding does, and their checks refuse a mail task with no `to` or a +// shell task with no `command`, so a dropped field already fails `printed`. describe('irToDsl: mail and shell task bindings', () => { const MAIL_FIELDS: FieldInjection[] = [ { name: 'to', value: 'ops@example.com' }, @@ -759,66 +661,53 @@ describe('irToDsl: mail and shell task bindings', () => { }); describe('irToDsl: an external task prints its priority, properties and mappings only with something to print', () => { - /** Two properties and two mappings, one code declared under a chosen name, one left for `codeDeclarations` to synthesize. */ - const MAPPED_PROCESS: BpmnProcess = { - ...around( - serviceTask('V', { - kind: 'external', - topic: 't', - properties: [ - { key: 'amount', value: '100' }, - { key: 'currency', value: 'EUR' }, - ], - errorMappings: [ - { - errorCode: 'DECLINED', - condition: '${externalTask.errorMessage == "declined"}', - }, - { errorCode: 'TIMEOUT', condition: '${externalTask.retries == 0}' }, - ], - }), - ), - errorDecls: [{ name: 'PaymentDeclined', code: 'DECLINED' }], - }; - - it.each([ + it.each<[string, ServiceTaskBinding, string]>([ [ 'a topic alone prints no parens beyond it', - around(serviceTask('V', { kind: 'external', topic: 't' })), + { kind: 'external', topic: 't' }, 'service V(topic: "t")', ], [ 'an integer taskPriority prints bare, as jobPriority does', - around( - serviceTask('V', { kind: 'external', topic: 't', taskPriority: '42' }), - ), + { kind: 'external', topic: 't', taskPriority: '42' }, 'service V(topic: "t", taskPriority: 42)', ], [ 'an expression taskPriority prints quoted so it re-lexes as raw EL', - around( - serviceTask('V', { - kind: 'external', - topic: 't', - taskPriority: '${amount > 1000 ? 90 : 10}', - }), - ), + { + kind: 'external', + topic: 't', + taskPriority: '${amount > 1000 ? 90 : 10}', + }, 'service V(topic: "t", taskPriority: "${amount > 1000 ? 90 : 10}")', ], [ 'a taskPriority opening with #{ prints as written', - around( - serviceTask('V', { - kind: 'external', - topic: 't', - taskPriority: '#{x}', - }), - ), + { kind: 'external', topic: 't', taskPriority: '#{x}' }, 'service V(topic: "t", taskPriority: "#{x}")', ], + [ + 'an empty property value prints as "" and lowers back to the empty string, which both engine readers store', + { kind: 'external', topic: 't', properties: [{ key: 'k', value: '' }] }, + 'service V(topic: "t") {\n property k = ""\n }', + ], [ 'properties then mappings print after the fields, a declared code by its name and an undeclared one under a synthesized header', - MAPPED_PROCESS, + { + kind: 'external', + topic: 't', + properties: [ + { key: 'amount', value: '100' }, + { key: 'currency', value: 'EUR' }, + ], + errorMappings: [ + { + errorCode: 'DECLINED', + condition: '${externalTask.errorMessage == "declined"}', + }, + { errorCode: 'TIMEOUT', condition: '${externalTask.retries == 0}' }, + ], + }, 'service V(topic: "t") {\n' + ' property amount = "100"\n' + ' property currency = "EUR"\n' + @@ -826,28 +715,20 @@ describe('irToDsl: an external task prints its priority, properties and mappings ' error TIMEOUT when externalTask.retries == 0\n' + ' }', ], - ] as const)('%s', async (_title, process, expected) => { - const dsl = await printed(process); + ])('%s', async (_title, binding, expected) => { + const dsl = await printed({ + ...around(serviceTask('V', binding)), + errorDecls: [{ name: 'PaymentDeclined', code: 'DECLINED' }], + }); expect(dsl).toContain(expected); - }); - - it('an empty property value prints as "" and lowers back to the empty string, which both engine readers store', async () => { - const binding: ServiceTaskBinding = { - kind: 'external', - topic: 't', - properties: [{ key: 'k', value: '' }], - }; - const dsl = await printed(around(serviceTask('V', binding))); - expect(dsl).toContain('property k = ""'); - const svc = (await reDesugar(dsl)).flowElements.find((e) => e.id === 'V'); - expect(svc?.kind === 'serviceTask' && svc.binding).toEqual(binding); + const back = byId(await reDesugar(dsl), 'V'); + expect(back.kind === 'serviceTask' && back.binding).toEqual(binding); }); }); describe('irToDsl: task kinds', () => { - /** Statement lines, indentation stripped, so a match is the whole statement. */ - const statements = async (ir: BpmnProcess): Promise => - (await printed(ir)).split('\n').map((line) => line.trim()); + const source = (statement: string): string => + `process p {\n start S\n ${statement}\n end E\n}\n`; it.each([ [ @@ -862,6 +743,12 @@ describe('irToDsl: task kinds', () => { 'receive Wait(message: "OrderPaid")', 'receive Wait(label: "Draft it", message: "OrderPaid")', ], + [ + 'a receive task with no message name prints as a bare statement', + { kind: 'receiveTask', id: 'Wait' }, + 'receive Wait', + 'receive Wait(label: "Draft it")', + ], [ 'a send element prints under the send keyword', { @@ -885,18 +772,12 @@ describe('irToDsl: task kinds', () => { 'decide Rate(label: "Draft it", decision: "riskRating")', ], ] as const)('%s', async (_title, node, nameless, labeled) => { - expect(await statements(around(node))).toContain(nameless); - expect(await statements(around({ ...node, name: 'Draft it' }))).toContain( - labeled, + expect(await printed(around(node))).toBe(source(nameless)); + expect(await printed(around({ ...node, name: 'Draft it' }))).toBe( + source(labeled), ); }); - it('prints a receive task with no message name as a bare statement', async () => { - expect( - await statements(around({ kind: 'receiveTask', id: 'Wait' })), - ).toContain('receive Wait'); - }); - it('prints a decision binding decision, version pin, mapping, result variable', async () => { const rate = around({ kind: 'serviceTask', @@ -929,7 +810,6 @@ describe('irToDsl: fenced script task', () => { 'script Compute ```javascript\nvar x = 1;\nexecution.setVariable("x", x);```', ], [ - // The emitter must prepend no block indentation to the opaque body. 'reproduces a body carrying its own indentation byte-for-byte', scriptTask('Guard', 'groovy', 'if (ok) {\n doThing();\n}'), '```groovy\nif (ok) {\n doThing();\n}```', @@ -942,18 +822,14 @@ describe('irToDsl: fenced script task', () => { }, 'script Compute(label: "Compute totals") ```javascript', ], - ])('%s', async (_title, node, expected) => { - expect(await printed(around(node))).toContain(expected); - }); - - it('emits a fenced script that re-parses to an equivalent scriptTask', async () => { - const ir = await reDesugar( - await printed(around(scriptTask('Compute', 'javascript', 'x = 1'))), - ); - const script = ir.flowElements.find((e) => e.kind === 'scriptTask'); - expect(script?.kind === 'scriptTask' && script.format).toBe('javascript'); - expect(script?.kind === 'scriptTask' && script.code).toBe('x = 1'); - }); + ])( + '%s, and re-parses to the same scriptTask', + async (_title, node, expected) => { + const dsl = await printed(around(node)); + expect(dsl).toContain(expected); + expect(byId(await reDesugar(dsl), node.id)).toEqual(node); + }, + ); }); describe('irToDsl: sub-process emission', () => { @@ -1057,6 +933,7 @@ describe('irToDsl: sub-process emission', () => { ); }); + // An empty body is the model's; the compiler refuses it on the way back. it.each([ [ 'prints the quoted label for a named sub-process', @@ -1065,19 +942,22 @@ describe('irToDsl: sub-process emission', () => { name: 'Handle order', }, 'subprocess Sub(label: "Handle order") {', + [], ], [ 'prints an empty named sub-process body as an opening brace immediately followed by a closing one', { ...chainedSub('Sub', []), name: 'Handle order' }, ' subprocess Sub(label: "Handle order") {\n }\n', + ['emptyBlock'], ], [ 'prints an unnamed empty sub-process body without a label', chainedSub('Sub', []), ' subprocess Sub {\n }\n', + ['emptyBlock'], ], - ])('%s', (_title, node, expected) => { - expect(irToDsl(around(node))).toContain(expected); + ] as const)('%s', async (_title, node, expected, refused) => { + expect(await printed(around(node), ...refused)).toContain(expected); }); }); @@ -1093,9 +973,7 @@ describe('irToDsl: call activity', () => { businessKey: '${execution.processBusinessKey}', inMappings: [ { kind: 'all' }, - // source === target -> bare shorthand. { kind: 'variable', source: 'amount', target: 'amount' }, - // source !== target -> `target = source`. { kind: 'variable', source: 'x', target: 'y' }, { kind: 'expression', @@ -1154,8 +1032,8 @@ describe('irToDsl: call activity', () => { 'call X(process: "p", version: "${v}")', undefined, ], - ] as const)('%s', (_title, binding, printed, absent) => { - const dsl = irToDsl( + ] as const)('%s', async (_title, binding, statement, absent) => { + const dsl = await printed( around({ kind: 'callActivity', id: 'X', @@ -1163,7 +1041,7 @@ describe('irToDsl: call activity', () => { ...(binding ? { binding } : {}), }), ); - expect(dsl).toContain(printed); + expect(dsl).toContain(statement); if (absent !== undefined) expect(dsl).not.toContain(absent); }); @@ -1190,10 +1068,7 @@ describe('irToDsl: call activity', () => { }); }); -/** - * `S -> E` alongside a handler `H` whose body is an `if` over `A`: the fixture - * that pins how deep a construct nests inside a handler. - */ +/** `S -> E` beside a handler `H` whose body is an `if` over `A`. */ const handlerWithIf = (eventDefinition: EventDefinition): BpmnProcess => minimalProcess( [ @@ -1302,9 +1177,8 @@ describe('irToDsl: event layer', () => { ].join('\n'), ); - // Whole source rather than the header alone: a declaration is only right if - // the name it claims is the one every use site raises, and `printed` - // compiles the result, so a name with nothing to resolve to fails here too. + // Whole source: a declaration is only right if the name it claims is the + // one every use site raises. expect(await printed(ir)).toBe( [ 'process p {', @@ -1325,21 +1199,8 @@ describe('irToDsl: event layer', () => { ); }); - it('prints an escalation end event as a throw, and a plain end as end', async () => { - const ir: BpmnProcess = minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - typedEvent('endEvent', 'Esc', escalationDef('X')), - ], - [{ id: 'F', sourceRef: 'S', targetRef: 'Esc' }], - ); - const dsl = await printed(ir); - expect(dsl).toContain('throw escalation Esc(X)'); - expect(dsl).not.toContain('end Esc'); - }); - - // An undo block only belongs inside the block whose work it undoes, so the - // process-level fixture draws that refusal from the model it was built from. + // An undo block belongs inside the block whose work it undoes, so the + // process-level fixture draws that refusal from the model. it.each([ ['error', errorDef('C'), '\n on error(C) {\n', undefined], [ @@ -1379,16 +1240,26 @@ describe('irToDsl: event layer (message / signal / timer / conditional)', () => edge('EmitSig', 'ThrowSig', { id: 'SF_EmitSig_ThrowSig' }), ], ); - const dsl = await printed(ir); - expect(dsl).toContain(' emit signal EmitSig("Cancelled")\n'); - expect(dsl).toContain(' throw signal ThrowSig("Cancelled")\n'); - expect(dsl).toContain(' on message("PaymentReceived") {\n'); - expect(dsl).toContain(' on signal("Cancelled", alongside) {\n'); - // Handlers print last: both headers follow the throw. - expect(dsl.indexOf('on message')).toBeGreaterThan( - dsl.indexOf('throw signal'), - ); - expect(dsl.indexOf('on signal')).toBeGreaterThan(dsl.indexOf('on message')); + expect(await printed(ir)).toBe( + [ + 'process proc {', + ' start PStart', + ' emit signal EmitSig("Cancelled")', + ' throw signal ThrowSig("Cancelled")', + ' on message("PaymentReceived") {', + ' start MsgStart', + ' user OnMsg_Work', + ' end OnMsg_End', + ' }', + ' on signal("Cancelled", alongside) {', + ' start SigStart', + ' user OnSig_Work', + ' end OnSig_End', + ' }', + '}', + '', + ].join('\n'), + ); }); /** `S -> E` beside the trailing handlers under test. */ @@ -1472,96 +1343,77 @@ describe('irToDsl: event layer (message / signal / timer / conditional)', () => 'emit message Ping("Ack", topic: "send-ack")', ); }); - - it('refuses an emit carrying a non-emittable definition', () => { - const badEmit: BpmnProcess = minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - typedEvent( - 'intermediateThrowEvent', - 'Bad', - timerDef('duration', 'PT1H'), - ), - { kind: 'endEvent', id: 'E' }, - ], - flowChain('S', 'Bad', 'E'), - ); - expect(() => irToDsl(badEmit)).toThrow(/timer/); - }); }); // A top-level start's own trigger has nowhere else to print; an event // sub-process's start puts its trigger in the `on` header instead. - describe('irToDsl: triggered start events', () => { - const startWith = (def: EventDefinition): BpmnProcess => - minimalProcess( - [ - { kind: 'startEvent', id: 'S', eventDefinition: def }, - { kind: 'endEvent', id: 'E' }, - ], - [{ id: 'F', sourceRef: 'S', targetRef: 'E' }], - ); - - it.each([ + it.each< + [ + title: string, + start: Partial< + Omit, 'kind'> + >, + expected: string, + refused: (keyof typeof MODEL_REFUSAL)[], + ] + >([ [ - 'message', - messageDef('OrderReceived'), + 'a message trigger', + { eventDefinition: messageDef('OrderReceived') }, 'start S message("OrderReceived")', + [], ], - ['signal', signalDef('Cancelled'), 'start S signal("Cancelled")'], - ['timer after', timerDef('duration', 'PT1H'), 'start S timer("PT1H")'], [ - 'timer at', - timerDef('date', '2026-08-01T09:00:00'), + 'a signal trigger', + { eventDefinition: signalDef('Cancelled') }, + 'start S signal("Cancelled")', + [], + ], + [ + 'a duration timer', + { eventDefinition: timerDef('duration', 'PT1H') }, + 'start S timer("PT1H")', + [], + ], + [ + 'a date timer as `at`', + { eventDefinition: timerDef('date', '2026-08-01T09:00:00') }, 'start S timer(at: "2026-08-01T09:00:00")', + [], ], [ - 'timer every', - timerDef('cycle', 'R/PT10M'), + 'a repeating timer as `every`', + { eventDefinition: timerDef('cycle', 'R/PT10M') }, 'start S timer(every: "R/PT10M")', + [], + ], + [ + 'the label as a setting beside the trigger', + { name: 'Order in', eventDefinition: messageDef('OrderReceived') }, + 'start S message("OrderReceived", label: "Order in")', + [], + ], + [ + 'the trigger whole even under a synthesized StartEvent_ id', + { id: 'StartEvent_p', eventDefinition: messageDef('OrderReceived') }, + 'start StartEvent_p message("OrderReceived")', + ['mintedId'], ], ])( - 'prints a top-level start carrying a %s trigger', - async (_title, def, expected) => { - expect(await printed(startWith(def))).toContain(expected); + 'prints a top-level start carrying %s', + async (_title, start, expected, refused) => { + const { id = 'S', ...rest } = start; + const ir = minimalProcess( + [ + { kind: 'startEvent', id, ...rest }, + { kind: 'endEvent', id: 'E' }, + ], + [edge(id, 'E')], + ); + expect(await printed(ir, ...refused)).toContain(expected); }, ); - - it('prints the label as a setting beside the trigger', async () => { - const ir = minimalProcess( - [ - { - kind: 'startEvent', - id: 'S', - name: 'Order in', - eventDefinition: messageDef('OrderReceived'), - }, - { kind: 'endEvent', id: 'E' }, - ], - [{ id: 'F', sourceRef: 'S', targetRef: 'E' }], - ); - expect(await printed(ir)).toContain( - 'start S message("OrderReceived", label: "Order in")', - ); - }); - - it('prints a triggered start whole even under a synthesized StartEvent_ id', async () => { - const ir = minimalProcess( - [ - { - kind: 'startEvent', - id: 'StartEvent_p', - eventDefinition: messageDef('OrderReceived'), - }, - { kind: 'endEvent', id: 'E' }, - ], - [{ id: 'F', sourceRef: 'StartEvent_p', targetRef: 'E' }], - ); - expect(await printed(ir, 'mintedId')).toContain( - 'start StartEvent_p message("OrderReceived")', - ); - }); }); describe('irToDsl: event sub-process start-trigger suppression', () => { @@ -1585,15 +1437,14 @@ describe('irToDsl: event sub-process start-trigger suppression', () => { expect(dsl).not.toContain('start MsgStart message'); expect(dsl).not.toContain('StartEvent_OnSig'); expect(dsl).toContain(' on signal("Cancelled") {\n end SigEnd\n }\n'); - // The trigger appears exactly once: in the `on` header, never on the start. expect(dsl.split('message("PaymentReceived")')).toHaveLength(2); expect(dsl.split('signal("Cancelled")')).toHaveLength(2); }); }); describe('irToDsl: ends spelling their own word', () => { - // The third column names the refusals the model itself draws: a reserved id - // the model chose, a cancel end the model puts outside an `attempt`. + // The third column names the refusals the model draws: a reserved id it + // chose, a cancel end it puts outside an `attempt`. it.each([ [ 'a terminate end', @@ -1788,9 +1639,8 @@ describe('irToDsl: link pairs and named catches', () => { ); }); - // `Catch_p_2` is the id `ast-to-ir` mints for the second (unnamed) await - // statement in process `p`'s body; the round trip must re-derive the same - // one, so the exact coordinate is pinned rather than guessed. + // `Catch_p_2` is the id the compiler mints for the second unnamed await in + // `p`, so the round trip re-derives it. it('prints a named await of any trigger with its name and an unnamed one with none', async () => { const ir = minimalProcess( [ @@ -1821,14 +1671,9 @@ describe('irToDsl: link pairs and named catches', () => { }); describe('irToDsl: event layer (compensation)', () => { - /** - * A process exercising the whole compensation surface: `emit compensation` - * mid-chain, a terminal `throw compensation`, and a trailing `on - * compensation` handler. Compensation is payload-less, so none of the three - * carry a code or a name. - */ const COMPENSATION: EventDefinition = { kind: 'compensation' }; + /** An `emit` mid-chain, a terminal `throw` and a trailing handler, none carrying a code or a name. */ const compensationIr: BpmnProcess = chained( [ { kind: 'startEvent', id: 'PStart' }, @@ -1884,18 +1729,8 @@ describe('irToDsl: event layer (compensation)', () => { }); }); -// A boundary event's chain is entered by no start or link catch, so the entry -// passes never reach it and a pass of its own prints it, before the orphan -// sweep would flush it as a detached top-level chain. The chain lives in the -// same container as the main flow, so the shared emitted-node bookkeeping is -// what makes a rejoin degrade to a `goto`. - describe('irToDsl: boundary events', () => { - /** - * `start S -> user -> end E`, flows F1 and F2, with `rest` and - * `flows` appended verbatim: the boundary event, its escape chain, and - * their edges. - */ + /** `S -> host -> E` with the boundary event, its escape chain and their edges appended. */ const boundaryIr = ( host: string, rest: readonly FlowElement[], @@ -1948,8 +1783,6 @@ describe('irToDsl: boundary events', () => { ); }); - // The header is printed whether the escape chain carries statements, is - // empty, or the host is not in this container at all. it.each([ [ 'alongside for a non-interrupting boundary, its chain indented under it', @@ -1997,7 +1830,6 @@ describe('irToDsl: boundary events', () => { ], [], [' on Elsewhere: message("M") {\n }\n'], - // The host the model names is nowhere for the compiler to resolve. ['hostOutsideContainer'], ], ] as const)('prints %s', async (_title, host, rest, flows, has, refused) => { @@ -2053,14 +1885,25 @@ describe('irToDsl: boundary events', () => { { id: 'F5', sourceRef: 'Retry', targetRef: 'Ship' }, ], ); - const dsl = await printed(ir); - expect(dsl).toContain(' on Fetch: error(GONE) {\n'); - expect(dsl).toContain(' user Retry\n'); - expect(dsl).toContain(' goto Ship\n'); - expect(dsl.match(/^ *user Ship$/gm)).toHaveLength(1); - }); - - it('restructures an if/else inside an escape chain (the boundary is a second CFG entry)', async () => { + expect(await printed(ir)).toBe( + [ + 'process p {', + ' error GONE', + ' start S', + ' user Fetch', + ' user Ship', + ' end E', + ' on Fetch: error(GONE) {', + ' user Retry', + ' goto Ship', + ' }', + '}', + '', + ].join('\n'), + ); + }); + + it('restructures an if/else inside an escape chain (the boundary is a second CFG entry)', async () => { const ir = boundaryIr( 'Review', [ @@ -2080,12 +1923,25 @@ describe('irToDsl: boundary events', () => { { id: 'B6', sourceRef: 'Gateway_p_9_join', targetRef: 'Aborted' }, ], ); - const dsl = await printed(ir); - expect(dsl).toContain(' on Review: signal("Abort") {\n'); - expect(dsl).toContain(' if (paid) {\n'); - expect(dsl).toContain(' } else {\n'); - expect(hasGoto(dsl)).toBe(false); - expect(hasGatewayKeyword(dsl)).toBe(false); + expect(await printed(ir)).toBe( + [ + 'process p {', + ' var paid: any', + ' start S', + ' user Review', + ' end E', + ' on Review: signal("Abort") {', + ' if (paid) {', + ' user Refund', + ' } else {', + ' user Keep', + ' }', + ' end Aborted', + ' }', + '}', + '', + ].join('\n'), + ); }); it('prints two boundaries on one host as two blocks in IR order', async () => { @@ -2110,48 +1966,23 @@ describe('irToDsl: boundary events', () => { edge('Boundary_Review_escalation', 'Loud', { id: 'F4' }), ], ); - const dsl = await printed(ir); - const timer = dsl.indexOf('on Review: timer("PT2H") {'); - const escalation = dsl.indexOf('on Review: escalation(LOUD, code: c) {'); - expect(timer).toBeGreaterThan(-1); - expect(escalation).toBeGreaterThan(timer); - // Each boundary prints exactly one header: neither the escape-chain walk - // nor the orphan sweep may print a boundary a second time. - expect(dsl.match(/^ *on Review: /gm)).toHaveLength(2); - }); - - it('keeps the handler block trailing when the body also flushes sweep gotos', async () => { - const source = [ - 'process p {', - ' error X', - ' var r: string', - ' user Intake', - ' if (r == "A") { goto Alpha } else { goto Beta }', - ' user Alpha', - ' user Beta', - ' end E', - ' on Intake: error(X) { user Fix }', - '}', - '', - ].join('\n'); - const doc = await parse(source); - expect(doc.parseResult.parserErrors).toHaveLength(0); - - const dsl = irToDsl(astToIr(doc.parseResult.value)); - // A handler reads like a catch block: no ordinary statement, and in - // particular no swept `goto`, may follow it. - expect(dsl.indexOf('on Intake: error(X) {')).toBeGreaterThan( - dsl.lastIndexOf('goto '), - ); - // Re-opening the emitted source must raise no handler-placement error. - const reparsed = await parse(dsl, { validation: true }); - expect( - (reparsed.diagnostics ?? []) - .map((d) => - typeof d.message === 'string' ? d.message : d.message.value, - ) - .filter((m) => m.includes('catch blocks')), - ).toEqual([]); + expect(await printed(ir)).toBe( + [ + 'process p {', + ' escalation LOUD', + ' start S', + ' user Review', + ' end E', + ' on Review: timer("PT2H") {', + ' end Late', + ' }', + ' on Review: escalation(LOUD, code: c) {', + ' end Loud', + ' }', + '}', + '', + ].join('\n'), + ); }); it('keeps the handler block trailing when the container holds an orphan fragment', async () => { @@ -2165,13 +1996,24 @@ describe('irToDsl: boundary events', () => { ], [{ id: 'F3', sourceRef: 'Boundary_Review_error', targetRef: 'Fix' }], ); - const dsl = await printed(ir, 'orphanStep'); - expect(dsl.indexOf('on Review: error(X) {')).toBeGreaterThan( - dsl.indexOf('user Stranded'), + expect(await printed(ir, 'orphanStep')).toBe( + [ + 'process p {', + ' error X', + ' start S', + ' user Review', + ' end E', + ' user Stranded', + ' on Review: error(X) {', + ' user Fix', + ' }', + '}', + '', + ].join('\n'), ); }); - it('prints the handler block for a boundary event a malformed flow edge points at', async () => { + it('prints the handler block for a boundary event a malformed flow edge points at, once', async () => { const ir: BpmnProcess = minimalProcess( [ { kind: 'startEvent', id: 'S' }, @@ -2181,12 +2023,19 @@ describe('irToDsl: boundary events', () => { ], flowChain('S', 'A', 'Boundary_A_error', 'Fix'), ); - const dsl = await printed(ir); - expect(dsl).toContain('on A: error(X) {'); - expect(dsl).toContain('user Fix'); - // Printed at its arrival point and nowhere else: the boundary pass must - // find it already emitted. - expect(dsl.match(/^ *on A: /gm)).toHaveLength(1); + expect(await printed(ir)).toBe( + [ + 'process p {', + ' error X', + ' start S', + ' user A', + ' on A: error(X) {', + ' user Fix', + ' }', + '}', + '', + ].join('\n'), + ); }); it('prints a boundary event inside the sub-process container that holds its host', async () => { @@ -2219,20 +2068,13 @@ describe('irToDsl: boundary events', () => { ); expect(await printed(ir)).toContain(' on Check: condition(stale) {\n'); }); - - it('leaves a container without boundary events printing exactly as before', () => { - expect(irToDsl(IF_ELSE_IR)).toBe(IF_ELSE_SOURCE); - }); }); -// A reserved `StartEvent_`/`EndEvent_`/`Throw_` id is the desugarer's own -// doing, not something an author could type, so printing it back out as a name -// produces source the validator rejects. These ids are omitted (start/end) or -// dropped from the name slot (throw/emit) instead. - +// A reserved `StartEvent_`/`EndEvent_`/`Throw_` id is the compiler's own, so +// printing it as a name yields source the validator rejects: the start or end +// is left out, the throw or emit loses its name. describe('irToDsl: synthesized terminal omission', () => { - /** A synthesized implicit start/end pair wrapping a sibling container that - * carries its own authored start/end. */ + /** A synthesized start/end pair around a sub-process carrying its own authored ones. */ const IMPLICIT_TERMINALS_IR: BpmnProcess = minimalProcess( [ { kind: 'startEvent', id: 'StartEvent_p' }, @@ -2267,8 +2109,7 @@ describe('irToDsl: synthesized terminal omission', () => { expect(dsl).toContain('end Done'); }); - // A synthesized `Throw_` id is dropped from the name slot; an authored one is - // spelled. No row may leave `Throw_` anywhere in the source. + // No row may leave `Throw_` anywhere in the source. it.each([ [ 'an authored message end', @@ -2277,6 +2118,13 @@ describe('irToDsl: synthesized terminal omission', () => { messageDef('Ack'), 'throw message Ack("Ack")', ], + [ + 'an authored escalation end', + 'endEvent', + 'Esc', + escalationDef('X'), + 'throw escalation Esc(X)', + ], [ 'a synthesized message end', 'endEvent', @@ -2328,9 +2176,8 @@ describe('irToDsl: synthesized terminal omission', () => { expect(dsl).not.toContain('Throw_'); }); - // Only the exact ids the compiler mints for a container are synthesized; - // `StartEvent_1` is the id a modelling tool mints, an authored name like - // any other, and it keeps its statement, its label and its initiator. + // Only the exact id the compiler mints for a container is synthesized; + // `StartEvent_1` is a modelling tool's, an authored name like any other. it("prints a modelling tool's default start and end under their own ids, label and initiator kept", async () => { const { ir, warnings } = await xmlToIr(bpmnDoc` @@ -2356,9 +2203,8 @@ describe('irToDsl: synthesized terminal omission', () => { expect((await reDesugar(source)).flowElements).toEqual(ir.flowElements); }); - // The trigger moves into the `on` header, so nothing else holds a start - // under the handler's minted id inside the body either; a start under any - // other id is authored and keeps its statement. + // The trigger moves into the `on` header, so a start under the handler's + // minted id prints nothing; any other id is authored and keeps its statement. it.each([ [ "the handler's minted start id, so the label is reported and the start left out", @@ -2392,10 +2238,9 @@ describe('irToDsl: synthesized terminal omission', () => { ); }); -// An id the model may carry and the script cannot spell (`Task.1`, a keyword, -// a trailing hyphen) prints under a name minted from it, with one report per -// rename, at every site an id is written: the process head, every statement -// head, a `goto`, a boundary's host, and a throw's or catch's name. +// An id the script cannot spell (`Task.1`, a keyword, a trailing hyphen) prints +// under a name minted from it at every site an id is written, with one report +// per rename. describe('irToDsl: ids the script cannot spell print under a minted name', () => { const ir = processIr( 'WFP-6-', @@ -2457,9 +2302,8 @@ describe('irToDsl: ids the script cannot spell print under a minted name', () => expect((await reDesugar(source)).id).toBe('WFP_6_'); }); - // The mint from `Task.1` lands on `Task_1`, which the document already - // spells for another element; resolving against every id, not just the - // names minted so far, is what pushes it to the next free suffix. + // The mint from `Task.1` lands on `Task_1`, which another element already + // spells: minted names resolve against every id, not only the mints so far. it('a minted name that another id already spells takes the next free suffix, and the source compiles clean', async () => { const { source, warnings } = printDsl( chained([ @@ -2485,11 +2329,10 @@ describe('irToDsl: ids the script cannot spell print under a minted name', () => }); }); -// Leaving a synthesized plain end out anywhere but its block's tail would wire -// its predecessor into whatever follows on the page, so it prints under its -// reserved id and is reported. The compiler never mints one in the positions -// rows 2 and 3 put it in, so those rows compile an authored end there and -// give it the minted id afterwards. +// A synthesized plain end left out anywhere but its block's tail would wire its +// predecessor into whatever follows, so it prints under its reserved id and is +// reported. The compiler never mints one where rows 2 and 3 put it, so those +// rows compile an authored end there and rename it afterwards. describe("irToDsl: a synthesized plain end that is not its block's tail", () => { const start = (id: string): FlowElement => ({ kind: 'startEvent', id }); const step = (id: string): FlowElement => ({ kind: 'task', id }); @@ -2615,83 +2458,10 @@ describe("irToDsl: a synthesized plain end that is not its block's tail", () => }); }); -describe('irToDsl: guard-clause continuation', () => { - it('recovers a throw-guard `if` with the continuation at the body level and no gateway token', async () => { - // `if (c) { throw }` with no else: the then-branch terminates, the default - // continues the main flow. There is no clean post-dominating join, so the - // fallback consumes the sole default edge as the continuation. - const ir = await reDesugar(`process p { - error BOOM - start S - service Pre(class: "x.Pre") - if (amount > 1000) { - throw error(BOOM) - } - service Post(class: "x.Post") - end Done -} -`); - const dsl = await expectIdempotent(ir); - - // Both the split and the join are elided, so no synthesized gateway id - // appears and nothing jumps to one. - expect(dsl).not.toContain('goto Gateway_'); - expect(dsl).not.toContain('Gateway_'); - - // The guard's terminal prints inline, not as a jump to the throw node. - expect(dsl).toContain('throw error(BOOM)'); - expect(dsl).not.toContain('goto Throw_'); - - // The continuation prints AFTER the `if`, at the container body level, not - // swept to the end past a terminating gateway. - const ifIdx = dsl.indexOf('if (amount > 1000)'); - const postIdx = dsl.indexOf('service Post'); - const doneIdx = dsl.indexOf('end Done'); - expect(ifIdx).toBeGreaterThan(-1); - expect(postIdx).toBeGreaterThan(ifIdx); - expect(doneIdx).toBeGreaterThan(postIdx); - }); - - it('keeps a loop-body statement after a terminal-branch guard inside the while block', async () => { - // `while (...) { A; if (d) { throw }; B }`: the guard's terminal branch must - // not push `B` out of the loop. - const ir = await reDesugar(`process p { - error X - start S - while (retries < 3) { - service A(class: "x.A") - if (retries < 1) { - throw error(X) - } - service B(class: "x.B") - } - end Done -} -`); - const dsl = await expectIdempotent(ir); - expect(dsl).not.toContain('goto Gateway_'); - - const lines = dsl.split('\n'); - const indentOf = (s: string): number => s.length - s.trimStart().length; - const whileIdx = lines.findIndex((l) => l.includes('while (retries < 3)')); - const bIdx = lines.findIndex((l) => l.includes('service B')); - const doneIdx = lines.findIndex((l) => l.includes('end Done')); - - // `B` appears after the `while` header, before `end Done`, and indented - // deeper than it, so it is nested inside the loop rather than after it. - expect(whileIdx).toBeGreaterThan(-1); - expect(bIdx).toBeGreaterThan(whileIdx); - expect(doneIdx).toBeGreaterThan(bIdx); - expect(indentOf(lines[bIdx]!)).toBeGreaterThan(indentOf(lines[doneIdx]!)); - }); -}); - describe('irToDsl: authored terminal in a guard clause', () => { it('keeps a goto for an authored end reached from more than one predecessor', async () => { - // `Done` has two predecessors, `split`'s guarded route and `split2`'s. - // Without `split2`, `Done` would post-dominate `split` and the printer - // would fold the shape into a re-merging `if`/`else` before - // `branchStaysInRegion` is ever asked about the terminal. + // Without `split2`, `Done` would post-dominate `split` and the shape would + // fold into a re-merging `if`/`else` before the terminal is ever asked about. const ir = minimalProcess( [ { kind: 'startEvent', id: 'S' }, @@ -2734,11 +2504,9 @@ describe('irToDsl: authored terminal in a guard clause', () => { // A branch whose chain ends before the join is walked inline only where the // block's tail is the end the printer leaves out: hoisted behind that end, the -// chain would push it off the tail and print it under its reserved id. With -// an authored end closing the block the chain stays a jump at its authored -// scope, so the coordinate ids of its unnamed events survive the round trip, -// which the multiset comparison pins; a chain running into the elided end is -// that tail and stays a jump too. +// chain would push it off the tail and print it under its reserved id. Behind +// an authored end the chain stays a jump, so the coordinate ids of its unnamed +// events survive, which the multiset comparison pins. describe('irToDsl: an authored chain a branch owns', () => { it.each([ [ @@ -2818,11 +2586,7 @@ describe('irToDsl: an authored chain a branch owns', () => { }); describe('irToDsl: routes leaving a loop beside the two it is built from', () => { - /** - * A review loop with an escalate exit: the loop head routes back, escalates, - * or carries on. The loop is built from the back-edge and one route out, and - * the third route is taken where the loop leaves off. - */ + /** The loop head routes back, escalates, or carries on; the loop is built from the first two. */ const PRE_TEST_IR: BpmnProcess = minimalProcess( [ { kind: 'startEvent', id: 'S' }, @@ -2946,11 +2710,9 @@ describe('irToDsl: routes leaving a loop beside the two it is built from', () => describe('irToDsl: a split inside a loop body whose every route is conditioned', () => { /** - * The approve-review loop: `Approve` splits under a condition per route, one - * route leaving the loop for `Pay` and the rest running through a step into - * the loop gateway, which routes back under `${clarified}` and out under - * `${!clarified}`. No route anywhere is unconditioned and no split names a - * fallback, which is the shape a modeler draws. + * `Approve` splits under a condition per route, one leaving the loop for + * `Pay` and the rest running through a step into the loop gateway. No route + * is unconditioned and no split names a fallback: the shape a modeler draws. */ const reviewLoopIr = ( staying: readonly (readonly [step: string, condition: string])[], @@ -2984,12 +2746,8 @@ describe('irToDsl: a split inside a loop body whose every route is conditioned', const loopTail = ' }\n' + ' } while (clarified)\n'; const reviewBranch = ' } else if (!approved) {\n' + ' user Review\n'; - // Revert symptoms: `emitRoutes` without the enclosing-construct continuation - // -> every branch is a jump, `Review` is hoisted after `end E2`, and its - // route into the loop gateway is a dropped-edge marker; `cleanJoin` without - // the containment check -> the shared-end row takes `E` as the join, `Review` - // is walked past the printed loop head, and `G2` draws the dropped-edge - // marker with `end E` inside the `do`. + // Revert symptom: `cleanJoin` without the containment check takes `E` as + // the shared-end row's join and prints `end E` inside the `do`. it.each([ [ 'walks the one route that stays in the loop inline and jumps on the one that leaves', @@ -3054,35 +2812,40 @@ describe('irToDsl: a split inside a loop body whose every route is conditioned', }); /** - * A multi-out real node whose routes end apart, so neither branch has a join - * to walk to and each keeps its edge as a jump. The second lands on a one-out - * pass-through gateway `Gateway_p_9_join -> R`: naming the gateway is - * impossible, so the jump forwards through it to the real successor. + * A step whose two routes end apart, so each keeps its edge as a jump, and the + * second lands on a one-way gateway `G -> R`: a gateway cannot be named, so + * the jump forwards through it to the real successor. */ -const PASS_THROUGH_IR: BpmnProcess = minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - { kind: 'userTask', id: 'A' }, - gateway('Gateway_p_9_join'), - { kind: 'userTask', id: 'R' }, - { kind: 'endEvent', id: 'E' }, - { kind: 'endEvent', id: 'E2' }, - ], - [ - { id: 'f0', sourceRef: 'S', targetRef: 'A' }, - { id: 'f1', sourceRef: 'A', targetRef: 'E' }, - { id: 'f2', sourceRef: 'A', targetRef: 'Gateway_p_9_join' }, - { id: 'f3', sourceRef: 'Gateway_p_9_join', targetRef: 'R' }, - { id: 'f4', sourceRef: 'R', targetRef: 'E2' }, - ], -); +const passThroughIr = ( + kind: 'exclusiveGateway' | 'inclusiveGateway' | 'eventBasedGateway', + id = 'G', +): BpmnProcess => + minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'userTask', id: 'A' }, + { kind, id }, + { kind: 'userTask', id: 'R' }, + { kind: 'endEvent', id: 'E' }, + { kind: 'endEvent', id: 'E2' }, + ], + [ + edge('S', 'A'), + edge('A', 'E'), + edge('A', id), + edge(id, 'R'), + edge('R', 'E2'), + ], + ); + +/** The pass-through under an id shaped like a compiler-minted merge. */ +const PASS_THROUGH_IR = passThroughIr('exclusiveGateway', 'Gateway_p_9_join'); /** - * A parallel fork with a back-edge into it (`B -> fork`). By the time the - * back-arrival is realized, the fork's out-edges are all consumed, so there - * is no single successor to forward to, so the edge becomes a hand-repair - * marker rather than an unresolvable `goto` into the fork. This shape is only - * reachable through hostile input; the forward compiler never emits it. + * A parallel fork with a back-edge into it (`B -> fork`): by the time the + * arrival is realized every route out of the fork is consumed, so the edge + * takes the hand-repair marker rather than an unresolvable `goto`. Only + * hostile input reaches this shape; the compiler never lowers it. */ const GOTO_INTO_FORK_IR: BpmnProcess = minimalProcess( [ @@ -3102,98 +2865,18 @@ const GOTO_INTO_FORK_IR: BpmnProcess = minimalProcess( ); describe('irToDsl: never emit a goto to a gateway', () => { - it('forwards a goto through a one-out pass-through gateway to the real successor', async () => { - const dsl = irToDsl(PASS_THROUGH_IR); - // The jump names the real successor, never the elided gateway. - expect(dsl).toContain('goto R'); - expect(dsl).not.toContain('goto Gateway_'); - expect(dsl).not.toContain('Gateway_p_9_join'); - await reDesugar(dsl); - }); - - it('emits the hand-repair marker for a goto into a fork, never a gateway-targeting goto', async () => { - const dsl = irToDsl(GOTO_INTO_FORK_IR); - expect(dsl).toContain('// unstructured region: hand-repair required'); - expect(dsl).not.toContain('goto Gateway_'); - expect(dsl).not.toContain('goto Gateway_p_1_fork'); - // The marker is a hidden comment, so the output still parses. - await reDesugar(dsl); - }); -}); - -describe('irToDsl: parallel-fork recovery (terminating branch)', () => { - it('recovers an asymmetric fork as `parallel { ... }` with the throw inline, the continuation after, and an equal IR back', async () => { - // A `parallel` where one branch terminates (`throw`) and the other flows on - // to the join. The fork's immediate post-dominator is the virtual exit, so - // there is no clean parallel join and the fork must be recovered - // structurally rather than degrading to raw gotos. - const ir = await reDesugar(`process p { - error BOOM(message: "it broke") - start Begin - parallel { - { service A(label: "a", class: "x.A") } - { throw error(BOOM) } - } - end Finish -} -`); - const dsl = await expectIdempotent(ir); - - expect(dsl).toContain('parallel {'); - // Both branch bodies print inline; the terminating branch prints its throw - // in place, never as a jump to the (un-nameable) synthesized throw node. - expect(dsl).toContain('service A(label: "a", class: "x.A")'); - expect(dsl).toContain('throw error(BOOM)'); - - expect(hasGoto(dsl)).toBe(false); - expect(dsl).not.toContain('goto Throw_'); - expect(dsl).not.toContain('goto Gateway_'); - expect(dsl).not.toContain('Gateway_'); - expect(dsl).not.toContain('Throw_'); - - // The continuation prints AFTER the parallel, at the container body level - // (one indent), not swept to the end and not nested inside the block. - const parIdx = dsl.indexOf('parallel {'); - const finishIdx = dsl.indexOf('end Finish'); - expect(parIdx).toBeGreaterThan(-1); - expect(finishIdx).toBeGreaterThan(parIdx); - expect(dsl).toContain('\n end Finish'); - }); - - it('recovers the shared continuation of a nested fork with a terminating branch (idempotence)', async () => { - // An outer `parallel` whose surviving branches each hold their own nested - // `parallel`, plus one terminating `throw`. The continuation (`end Finish`) - // must resume after the OUTER join both survivors reconverge at, not the - // first survivor's inner join, which would drift it into a sibling branch - // and make the round-trip non-idempotent. - const ir = await reDesugar(`process p { - error BOOM(message: "it broke") - start Begin - parallel { - { - parallel { - { service A(label: "a", class: "x.A") } - { service B(label: "b", class: "x.B") } - } - } - { - parallel { - { service C(label: "c", class: "x.C") } - { service D(label: "d", class: "x.D") } - } - } - { throw error(BOOM) } - } - end Finish -} -`); - const dsl = await expectIdempotent(ir); - - // The continuation lands after the outer parallel at container level, and - // no edge is dropped through a bare gateway or throw-targeting goto. - expect(dsl).toContain('\n end Finish'); - expect(dsl).not.toContain('goto Gateway_'); - expect(dsl).not.toContain('goto Throw_'); + it('writes the hand-repair marker for a goto into a fork, never a gateway-targeting goto, and reports the dropped edge', async () => { + const { source, warnings } = printDsl(GOTO_INTO_FORK_IR); + expect(source).toContain( + `${UNSTRUCTURED_MARKER} (dropped edge into Gateway_p_1_fork)`, + ); + expect(source).not.toContain('goto Gateway_'); + expectReports( + warnings, + ['degradedSplit', 'Gateway_p_1_fork'], + ['droppedEdge', 'Gateway_p_1_fork'], + ); + await printed(GOTO_INTO_FORK_IR); }); }); @@ -3248,11 +2931,7 @@ describe('irToDsl: the process header and the start it opens on', () => { }); }); -/** - * The lines strictly between `form {` and the matching `}`, trimmed, brace - * depth tracked so a field's own block (parens and members nest inside a - * field line) does not end the slice early. - */ +/** The trimmed lines between `form {` and its `}`; a field's own block nests inside, hence the depth count. */ function formBlockLines(dsl: string): string[] { const lines = dsl.split('\n'); const start = lines.findIndex((line) => line.trim() === 'form {'); @@ -3376,9 +3055,8 @@ describe('irToDsl: form fields print their parens and block only with something 'formDefaultShape', ); // A non-canonical number re-lexes but lowers to another text (`1.50` to - // `1.5`), so it stays quoted. So does a value past `Number`'s safe - // precision (`9007199254740993`, one above 2^53): the double rounds it - // to `9007199254740992` before it ever reaches the printed literal. + // `1.5`), so it stays quoted, as does a value past 2^53, which the double + // rounds before it reaches the literal. expect(formBlockLines(dsl)).toEqual([ 'a: boolean = "maybe"', 'b: number = "1 + 1"', @@ -3396,11 +3074,7 @@ describe('irToDsl: form fields print their parens and block only with something }); describe('irToDsl: engine attributes', () => { - /** - * One of every statement kind that carries engine settings, each carrying at - * least one, plus a boundary handler whose escape chain is a - * typed throw and a host-less handler. - */ + /** One of every statement kind that carries engine settings, each carrying at least one. */ const ENGINE_IR: BpmnProcess = { id: 'p', isExecutable: true, @@ -3505,42 +3179,40 @@ describe('irToDsl: engine attributes', () => { }; it('renders the parens on every statement kind that takes them, in a fixed setting order', async () => { - const dsl = await printed(ENGINE_IR); - expect(dsl).toContain('start S(asyncAfter: true)'); - expect(dsl).toContain( - 'user U(label: "Review", assignee: "ana", ' + - 'formKey: "embedded:app:forms/r.html", candidateGroups: "ops", ' + - 'candidateUsers: "ana,bo", dueDate: "${due}", followUpDate: "P1D", ' + - 'priority: 20, asyncBefore: true, exclusive: false, jobPriority: 50, ' + - 'retryCycle: "R3/PT10M") {\n' + - ' form {\n' + - ' amount: number\n' + - ' }\n' + + expect(await printed(ENGINE_IR)).toBe( + [ + 'process p(versionTag: "3.1") {', + ' error BOOM', + ' error PF', + ' escalation ESC', + ' start S(asyncAfter: true)', + ' user U(label: "Review", assignee: "ana", formKey: "embedded:app:forms/r.html", candidateGroups: "ops", candidateUsers: "ana,bo", dueDate: "${due}", followUpDate: "P1D", priority: 20, asyncBefore: true, exclusive: false, jobPriority: 50, retryCycle: "R3/PT10M") {', + ' form {', + ' amount: number', + ' }', ' }', - ); - expect(dsl).toContain( - 'service V(expression: "${c.run(execution)}", resultVariable: "res", asyncBefore: true)', - ); - expect(dsl).toContain( - 'script Sc(resultVariable: "out", asyncAfter: true) ```javascript', - ); - expect(dsl).toContain( - 'call C(process: "other", businessKey: "bk", asyncBefore: true) {\n' + - ' in *\n' + + ' service V(expression: "${c.run(execution)}", resultVariable: "res", asyncBefore: true)', + ' script Sc(resultVariable: "out", asyncAfter: true) ```javascript', + 'x = 1;', + '```', + ' call C(process: "other", businessKey: "bk", asyncBefore: true) {', + ' in *', ' }', + ' subprocess Sub(asyncBefore: true) {', + ' user Inner', + ' }', + ' await timer("PT1H", asyncBefore: true)', + ' emit escalation(ESC, exclusive: false)', + ' end E(asyncBefore: true)', + ' on U: error(BOOM, asyncBefore: true) {', + ' throw error Failed(PF, asyncAfter: true)', + ' }', + ' on escalation(ESC, asyncBefore: true) {', + ' }', + '}', + '', + ].join('\n'), ); - expect(dsl).toContain('subprocess Sub(asyncBefore: true) {'); - expect(dsl).toContain('await timer("PT1H", asyncBefore: true)'); - expect(dsl).toContain('emit escalation(ESC, exclusive: false)'); - expect(dsl).toContain('end E(asyncBefore: true)'); - expect(dsl).toContain('throw error Failed(PF, asyncAfter: true)'); - - // Both handler headers carry their settings before the body brace. - expect(dsl).toContain('on U: error(BOOM, asyncBefore: true) {'); - expect(dsl).toContain('on escalation(ESC, asyncBefore: true) {'); - - // The process carries its own settings the way every element does. - expect(dsl).toContain('versionTag: "3.1"'); }); /** `S -> E` beside one timer handler whose start `startId` carries the timer job's three settings. */ @@ -3620,88 +3292,85 @@ describe('irToDsl: engine attributes', () => { ).toEqual({ exclusive: false, jobPriority: '5', retryCycle: 'R1/PT1M' }); }); - it('prints neither parens nor braces for a node carrying no engine attributes', async () => { - const dsl = await printed( - minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - { kind: 'userTask', id: 'U' }, - { - kind: 'subProcess', - id: 'Sub', - flowElements: [{ kind: 'userTask', id: 'Inner' }], - sequenceFlows: [], - }, - typedEvent('intermediateCatchEvent', 'Catch_p_1', signalDef('Ping')), - { kind: 'endEvent', id: 'E' }, - ], - flowChain('S', 'U', 'Sub', 'Catch_p_1', 'E'), - ), + it.each<[string, FlowElement[], string[]]>([ + [ + 'neither parens nor braces on a node carrying no engine attributes', + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'userTask', id: 'U' }, + { + kind: 'subProcess', + id: 'Sub', + flowElements: [{ kind: 'userTask', id: 'Inner' }], + sequenceFlows: [], + }, + typedEvent('intermediateCatchEvent', 'Catch_p_1', signalDef('Ping')), + { kind: 'endEvent', id: 'E' }, + ], + [ + ' start S', + ' user U', + ' subprocess Sub {', + ' user Inner', + ' }', + ' await signal("Ping")', + ' end E', + ], + ], + [ + 'booleans bare and only in their non-default direction', + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'userTask', id: 'A', asyncBefore: true, asyncAfter: true }, + { kind: 'userTask', id: 'B', exclusive: false }, + { kind: 'endEvent', id: 'E' }, + ], + [ + ' start S', + ' user A(asyncBefore: true, asyncAfter: true)', + ' user B(exclusive: false)', + ' end E', + ], + ], + [ + 'an all-digit priority bare and any other value quoted, a #{ opening as written and padding trimmed so it re-lexes as a raw template', + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'userTask', id: 'A', jobPriority: '50', priority: '7' }, + { + kind: 'userTask', + id: 'B', + jobPriority: '${order.rush}', + priority: '${p}', + }, + { + kind: 'userTask', + id: 'C', + jobPriority: '#{order.rush}', + priority: ' ${p}', + }, + { kind: 'endEvent', id: 'E' }, + ], + [ + ' start S', + ' user A(priority: 7, jobPriority: 50)', + ' user B(priority: "${p}", jobPriority: "${order.rush}")', + ' user C(priority: "${p}", jobPriority: "#{order.rush}")', + ' end E', + ], + ], + ])('prints %s', async (_title, elements, body) => { + const ir = minimalProcess( + elements, + flowChain(...elements.map((el) => el.id)), ); - expect(dsl).not.toContain('{ }'); - expect(dsl).not.toContain('()'); - expect(dsl).toContain('\n start S\n'); - expect(dsl).toContain('\n user U\n'); - expect(dsl).toContain('\n await signal("Ping")\n'); - expect(dsl).toContain('\n end E\n'); - expect(dsl).toContain('\n subprocess Sub {\n'); - }); - - it('prints booleans bare and only in their non-default direction', async () => { - const dsl = await printed( - minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - { kind: 'userTask', id: 'A', asyncBefore: true, asyncAfter: true }, - { kind: 'userTask', id: 'B', exclusive: false }, - { kind: 'endEvent', id: 'E' }, - ], - flowChain('S', 'A', 'B', 'E'), - ), + expect(await printed(ir)).toBe( + ['process p {', ...body, '}', ''].join('\n'), ); - expect(dsl).toContain('user A(asyncBefore: true, asyncAfter: true)'); - expect(dsl).toContain('user B(exclusive: false)'); - expect(dsl).not.toContain('"true"'); - expect(dsl).not.toContain('"false"'); }); +}); - it('prints an all-digit priority bare and any other value quoted, a #{ opening as written and padding trimmed so it re-lexes as a raw template', async () => { - const dsl = await printed( - minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - { kind: 'userTask', id: 'A', jobPriority: '50', priority: '7' }, - { - kind: 'userTask', - id: 'B', - jobPriority: '${order.rush}', - priority: '${p}', - }, - { - kind: 'userTask', - id: 'C', - jobPriority: '#{order.rush}', - priority: ' ${p}', - }, - { kind: 'endEvent', id: 'E' }, - ], - flowChain('S', 'A', 'B', 'C', 'E'), - ), - ); - expect(dsl).toContain('user A(priority: 7, jobPriority: 50)'); - expect(dsl).toContain( - 'user B(priority: "${p}", jobPriority: "${order.rush}")', - ); - expect(dsl).toContain( - 'user C(priority: "${p}", jobPriority: "#{order.rush}")', - ); - }); -}); - -/** - * A step whose two routes end apart, so neither branch has a join to walk to - * and the one landing on the end keeps its edge as a jump. - */ +/** A step whose two routes end apart, so the one landing on the end keeps its edge as a jump. */ const jumpToEndIr = ( end: Partial, 'kind'>>, ): BpmnProcess => { @@ -3723,11 +3392,7 @@ const jumpToEndIr = ( ); }; -/** - * A back edge to the start event: the loop cannot be recognized as a `while`, - * so the edge is written as a jump, asking the same question on the other side - * of the predicate. - */ +/** A back edge to the start, which no loop form takes, so it is written as a jump. */ const backEdgeIr = ( start: Partial, 'kind'>>, ): BpmnProcess => { @@ -3747,10 +3412,8 @@ const DONE_LISTENERS: ExecutionListener[] = [ ]; describe('irToDsl: whether a synthesized terminal prints', () => { - // One row per arm of the shared printability predicate: content that cannot - // be re-derived prints the statement and the jump resolves; anything else - // elides the terminal and the edge takes the marker instead. `expected` is - // the statement, or null for the elided arm. + // Content that cannot be re-derived prints the statement and the jump + // resolves; anything else elides the terminal and the edge takes the marker. it.each([ [ 'an engine attribute on an end', @@ -4149,10 +3812,8 @@ describe('irToDsl: listeners', () => { }); describe('irToDsl: field injection and form references', () => { - /** - * `printed` is the whole point of the assertion: a field or a form reference - * printed where the compiler refuses it would still read fine as text. - */ + // A field or a form reference printed where the compiler refuses it still + // reads fine as text, so `printed` is the assertion. it('prints a field before the io parameters on every carrier, and a form reference beside its binding', async () => { const dsl = await printed( chained([ @@ -4249,42 +3910,31 @@ describe('irToDsl: repeated activities', () => { elementVariable: 'line', }; - /** The kinds that carry a loop; anything else here is a compile error. */ - type RepeatableElement = Extract< - FlowElement, - { - kind: - | 'task' - | 'userTask' - | 'serviceTask' - | 'scriptTask' - | 'receiveTask' - | 'subProcess' - | 'callActivity'; - } - >; + type RepeatableElement = Extract; /** Print one repeated element, wired `S -> el -> E`, with its settings. */ const printRepeated = ( el: RepeatableElement, loop: LoopCharacteristics, - ): string => irToDsl(around({ ...el, asyncBefore: true, loop })); + ): Promise => printed(around({ ...el, asyncBefore: true, loop })); - /** Every kind that can repeat: the head its clause follows, its settings, and what closes the statement. */ - const KINDS = [ + // Every kind that can repeat: the head its clause follows, its settings, and + // what closes the statement. + it.each([ [ - { kind: 'task', id: 'Record', name: 'Record it' }, 'step Record', + { kind: 'task', id: 'Record', name: 'Record it' }, 'label: "Record it", asyncBefore: true', '', ], [ - { kind: 'userTask', id: 'Approve', name: 'Approve it' }, 'user Approve', + { kind: 'userTask', id: 'Approve', name: 'Approve it' }, 'label: "Approve it", asyncBefore: true', '', ], [ + 'send Notify', { kind: 'serviceTask', id: 'Notify', @@ -4292,11 +3942,11 @@ describe('irToDsl: repeated activities', () => { element: 'send', binding: classBinding('com.example.Notify'), }, - 'send Notify', 'label: "Notify them", class: "com.example.Notify", asyncBefore: true', '', ], [ + 'script Compute', { kind: 'scriptTask', id: 'Compute', @@ -4304,22 +3954,22 @@ describe('irToDsl: repeated activities', () => { format: 'javascript', code: 'x = 1', }, - 'script Compute', 'label: "Compute it", asyncBefore: true', ' ```javascript', ], [ + 'receive Wait', { kind: 'receiveTask', id: 'Wait', name: 'Wait for it', messageName: 'OrderPaid', }, - 'receive Wait', 'label: "Wait for it", message: "OrderPaid", asyncBefore: true', '', ], [ + 'subprocess Fulfil', { ...chainedSub('Fulfil', [ { @@ -4330,38 +3980,29 @@ describe('irToDsl: repeated activities', () => { ]), name: 'Fulfil it', }, - 'subprocess Fulfil', 'label: "Fulfil it", asyncBefore: true', ' {', ], [ + 'call Regional', { kind: 'callActivity', id: 'Regional', name: 'Run it', calledElement: 'regional-report', }, - 'call Regional', 'label: "Run it", process: "regional-report", asyncBefore: true', '', ], ] as const satisfies ReadonlyArray< - readonly [RepeatableElement, string, string, string] - >; - - it.each(KINDS)( - 'prints the clause between the name and the settings of %#', - (el, head, settings, tail) => { - expect(printRepeated(el, OVER_LINES)).toContain( + readonly [string, RepeatableElement, string, string] + >)( + 'prints the clause of `%s` between the name and the settings, and none without a loop', + async (head, el, settings, tail) => { + expect(await printRepeated(el, OVER_LINES)).toContain( `${head} for each line in lines(${settings})${tail}`, ); - }, - ); - - it.each(KINDS)( - 'leaves %# untouched when it carries no loop', - (el, head, settings, tail) => { - expect(irToDsl(around({ ...el, asyncBefore: true }))).toContain( + expect(await printed(around({ ...el, asyncBefore: true }))).toContain( `${head}(${settings})${tail}`, ); }, @@ -4401,10 +4042,10 @@ describe('irToDsl: repeated activities', () => { ], ] as const satisfies ReadonlyArray)( 'prints %j as `%s`', - (loop, clause) => { - expect(printRepeated({ kind: 'task', id: 'Record' }, loop)).toContain( - `step Record ${clause}(asyncBefore: true)`, - ); + async (loop, clause) => { + expect( + await printRepeated({ kind: 'task', id: 'Record' }, loop), + ).toContain(`step Record ${clause}(asyncBefore: true)`); }, ); @@ -4492,10 +4133,10 @@ describe('irToDsl: repeated activities', () => { }); }); -// BPMN has no slot for `var`, so the print declares what the body reads bare: -// every root the validator would otherwise report as undeclared, and nothing -// a form field, a catch binding, an io parameter, an element variable or a -// repetition's engine counters already type. +// BPMN has no slot for `var`, so the print declares every root the validator +// would otherwise report as undeclared, and nothing a form field, a catch +// binding, an io parameter, an element variable or a repetition's engine +// counters already type. describe('irToDsl: the header declares every variable the body reads bare', () => { it('declares each bare read once, typed any, in the order it appears', async () => { const ir = minimalProcess( @@ -4570,12 +4211,8 @@ describe('irToDsl: the header declares every variable the body reads bare', () = }); }); -/** - * The four settings a repetition writes on the loop element itself - * (`runAsyncBefore`, `runAsyncAfter`, `runExclusive`, `runRetryCycle`) sit in - * the same parens as the statement's own, after them, and read back onto the - * loop rather than the step. - */ +// The `run*` settings sit in the same parens as the statement's own, after +// them, and read back onto the loop rather than the step. describe('irToDsl: per-run settings on a repetition', () => { const RUN_LOOP: LoopCharacteristics = { cardinality: '3', @@ -4624,17 +4261,10 @@ describe('irToDsl: per-run settings on a repetition', () => { }); }); -/** - * A prose setting is read back as text rather than evaluated, so the printer's - * escaping and the lexer's unescaping have to be exact inverses over every - * input a modeler can type. The adversarial rows are the ones a quoted body - * opening with `${` reaches: that body lexes as a raw expression, and the - * reader unwrapping one strips the quotes without unescaping, so every escape - * inside it would come back as two characters. - * - * Re-parsing through the compiler is the assertion, not the printed text: text - * that looks right and lexes differently is the whole failure being guarded. - */ +// The printer's escaping and the lexer's unescaping have to be exact inverses, +// so the re-parse is the assertion, not the printed text. The adversarial rows +// open with `${`: that body lexes as a raw expression, whose reader strips the +// quotes without unescaping, so every escape inside would come back doubled. describe('irToDsl: prose comes back byte for byte', () => { /** Each shape, the prose, and whether printing it needs a backslash at all. */ const PROSE = [ @@ -4685,10 +4315,8 @@ describe('irToDsl: prose comes back byte for byte', () => { printed(around({ kind: 'userTask', id: 'Review', ...carrying(value) })); const dsl = await print(text); - // A statement prints on one line whatever its prose holds, which is what - // keeps the indentation of an enclosing block meaningful. Splitting on a - // lone carriage return too, since a raw one breaks a line for every reader - // of the file without breaking it for this test. + // A statement prints on one line whatever its prose holds; a lone carriage + // return breaks a line for every reader of the file, so it counts too. expect(dsl.split(/\r\n|\r|\n/)).toHaveLength( (await print('Review the order')).split(/\r\n|\r|\n/).length, ); @@ -4711,9 +4339,6 @@ describe('irToDsl: prose comes back byte for byte', () => { }); }); -// `printDsl` is the real entry point; the alias above unwraps `.source` for -// every suite that only asserts printed text. - /** * What each report says, keyed by the degradation it reports. `says` is every * phrase its message must carry; `never` is the phrase of a neighbouring report @@ -4817,11 +4442,7 @@ const REPORT = { /** BPMN vocabulary no report may spend on a reader who never drew a diagram. */ const JARGON = ['flow node', 'gateway', 'token', 'sequence flow']; -/** - * Assert the reports raised, in order: one `[report, elementId]` per warning, - * each matched on category, element, every phrase its report says, every phrase - * it must not, and the plain-words rule. - */ +/** The reports raised, in order, each matched on category, element, phrases and the plain-words rule. */ function expectReports( warnings: readonly PrintWarning[], ...expected: readonly (readonly [keyof typeof REPORT, string])[] @@ -4849,11 +4470,7 @@ function expectReports( /** How a route is written where the test cares: its flow id, its condition. */ type Route = { id?: string; condition?: string }; -/** - * A review loop closed by a second split: the head takes the body under - * `${again}`, the body runs into `split`, and the split routes back round the - * loop or on to the end. `back` and `on` name and weigh those two routes. - */ +/** A loop closed by a second split, whose routes back round the loop and on to the end `back` and `on` name and weigh. */ const loopIntoSplitIr = ( split: FlowElement, back: Route, @@ -4878,10 +4495,9 @@ const loopIntoSplitIr = ( ); /** - * A loop and the weighed escapes beside the route round it. `shape` puts the - * head before the body (a `while`) or after it (a `do`); `back` names and - * weighs the route round the loop; each escape leaves the head for a step of - * its own, which then ends the run. + * A loop head before the body (a `while`) or after it (a `do`), the route + * round it named and weighed by `back`, and weighed escapes each leaving the + * head for a step that ends the run. */ const loopWithEscapesIr = ({ shape = 'pre', @@ -4941,11 +4557,11 @@ describe('warnings: text the script has nowhere to write', () => { ], ); - it('reports the label on a split, says nothing about a split without one, and leaves the printed source alone', () => { + it('reports the label on a split, says nothing about a split without one, and leaves the printed source alone', async () => { const named = printDsl(splitIr('Amount check')); const plain = printDsl(splitIr()); - expect(named.source).toBe(plain.source); + expect(named.source).toBe(await printed(splitIr())); expect(plain.warnings).toEqual([]); expectReports(named.warnings, ['label', 'Split_1']); expect(named.warnings[0]?.message).toContain("'Amount check'"); @@ -5010,11 +4626,7 @@ describe('warnings: text the script has nowhere to write', () => { expectReports(warnings, ['label', 'NSplit'], ['label', 'HSplit']); }); - /** - * Every gateway here has one way in and one way out, so all of them are - * walked straight through and the printed source is the same whether they - * carry text or not. - */ + /** Every gateway has one way in and one out, so the print walks straight through them all. */ const gatewayTextIr = (carried: boolean): BpmnProcess => { const text = (documentation: string) => (carried ? { documentation } : {}); return minimalProcess( @@ -5048,11 +4660,11 @@ describe('warnings: text the script has nowhere to write', () => { ); }; - it('reports the documentation on every gateway kind, at any depth, and leaves the printed source alone', () => { + it('reports the documentation on every gateway kind, at any depth, and leaves the printed source alone', async () => { const carried = printDsl(gatewayTextIr(true)); const plain = printDsl(gatewayTextIr(false)); - expect(carried.source).toBe(plain.source); + expect(carried.source).toBe(await printed(gatewayTextIr(false))); expect(plain.warnings).toEqual([]); expectReports( carried.warnings, @@ -5067,59 +4679,26 @@ describe('warnings: text the script has nowhere to write', () => { }); describe('warnings: edges with no form in the script', () => { - /** A back-edge into a fork whose out-edges are all consumed by then. */ - const DROPPED_EDGE_IR: BpmnProcess = minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - { kind: 'parallelGateway', id: 'Gateway_d_1_fork' }, - { kind: 'userTask', id: 'A' }, - { kind: 'userTask', id: 'B' }, - { kind: 'endEvent', id: 'E' }, - ], - [ - edge('S', 'Gateway_d_1_fork'), - edge('Gateway_d_1_fork', 'A'), - edge('Gateway_d_1_fork', 'B'), - edge('A', 'E'), - edge('B', 'Gateway_d_1_fork'), - ], - ); - - it('reports the dropped edge and still prints the marker comment', () => { - const { source, warnings } = printDsl(DROPPED_EDGE_IR); - - expect(source).toContain(UNSTRUCTURED_MARKER); - expectReports( - warnings, - ['degradedSplit', 'Gateway_d_1_fork'], - ['droppedEdge', 'Gateway_d_1_fork'], - ); - }); - - /** - * An arrival with nowhere to land: `Ring1` and `Ring2` hand the forwarding - * walk to each other, so it comes back to where it started and the edge - * takes the marker instead of a jump. - */ - it('drops an arrival at a ring of one-way gateways', () => { - const ring = printDsl( - minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - { kind: 'userTask', id: 'A' }, - gateway('Ring1'), - gateway('Ring2'), - { kind: 'endEvent', id: 'E' }, - ], - [ - edge('S', 'A'), - edge('S', 'Ring1'), - edge('A', 'E'), - edge('Ring1', 'Ring2'), - edge('Ring2', 'Ring1'), - ], - ), + // `Ring1` and `Ring2` hand the forwarding walk to each other, so it comes + // back to where it started and the edge takes the marker instead of a jump. + it('drops an arrival at a ring of one-way gateways', async () => { + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'userTask', id: 'A' }, + gateway('Ring1'), + gateway('Ring2'), + { kind: 'endEvent', id: 'E' }, + ], + [ + edge('S', 'A'), + edge('S', 'Ring1'), + edge('A', 'E'), + edge('Ring1', 'Ring2'), + edge('Ring2', 'Ring1'), + ], ); + const ring = printDsl(ir); expect(ring.source).toContain( `${UNSTRUCTURED_MARKER} (dropped edge into Ring1)`, @@ -5134,19 +4713,13 @@ describe('warnings: edges with no form in the script', () => { ['droppedEdge', 'Ring1'], ['droppedEdge', 'Ring1'], ); + await printed(ir); }); - it('returns no warnings at all for a process that prints in full', () => { - expect(printDsl(around({ kind: 'userTask', id: 'A' })).warnings).toEqual( - [], - ); - }); - - // Only the exact `StartEvent_p` is the compiler's; the suffixed id it - // mints past a taken name is not, so a second plain start keeps its - // statement rather than vanishing. The print reports nothing: the two - // plain starts are the model's, which the import refuses ahead of the - // printer and the validator refuses on the way back. + // Only the exact `StartEvent_p` is the compiler's; the suffixed id it mints + // past a taken name is not, so a second plain start keeps its statement. The + // two plain starts are the model's, so the print reports nothing and the + // validator refuses them on the way back. it('a second plain start beside the synthesized one prints under its own id, and the validator reports the second default start', async () => { const ir = minimalProcess( [ @@ -5235,11 +4808,8 @@ describe('irToDsl: several starts entering one step', () => { }); describe('warnings: a split the script has no form for', () => { - /** - * A fork with nothing to rejoin at: every branch ends where it stands. The - * edges all keep a jump, so nothing is dropped and no marker is printed, and - * this warning is the only report that the split itself is gone. - */ + // A fork with nothing to rejoin at: every edge keeps a jump, so nothing is + // dropped and this report is the only trace that the split itself is gone. it('reports the fork it wrote as jumps, and drops no edge doing it', async () => { const ir = minimalProcess( [ @@ -5262,7 +4832,6 @@ describe('warnings: a split the script has no form for', () => { expect(source).not.toContain('parallel {'); expect(source).toContain('goto X'); expect(source).toContain('goto Y'); - // The marker names the split the jumps stand for, and no edge is dropped. expect(source).toContain( `${UNSTRUCTURED_MARKER} (split Gateway_p_1_fork degraded to jumps; was parallel)`, ); @@ -5273,10 +4842,10 @@ describe('warnings: a split the script has no form for', () => { describe('irToDsl: a step whose own routes split', () => { /** - * `A` leaves on `routes`, which meet again at `merge` before `E`. The - * engine leaves a step by every route whose condition holds or that carries - * none, and by the fallback alone when none was taken, so the routes print - * as the fork block that reads back the same way. + * `A` leaves on `routes`, which meet again at `merge` before `E`. The engine + * takes every route whose condition holds or that carries none, and the + * fallback alone when none was taken, so they print as the fork block that + * reads back the same way. */ const stepSplitIr = ( routes: readonly (readonly [target: string, route: Route])[], @@ -5473,10 +5042,9 @@ describe('irToDsl: a step whose own routes split', () => { }); it('prints the one route a loop left a step as the fall-through, as it does for a fork', () => { - // `Review` runs the escape and the route back at once. The loop prints - // the route back as its closing brace, leaving one route at the step's - // own position, and that route carries a condition the plain flow on has - // nowhere to write. + // The loop prints `Review`'s route back as its closing brace, leaving one + // weighed route at the step's position, and plain flow has nowhere to + // write its condition. const { warnings } = printDsl( minimalProcess( [ @@ -5501,28 +5069,12 @@ describe('irToDsl: a step whose own routes split', () => { expectReports(warnings, ['droppedFlowCondition', 'Review']); }); - - it('says nothing where a step has one route on', () => { - expect( - printDsl( - minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - { kind: 'userTask', id: 'A' }, - { kind: 'endEvent', id: 'E' }, - ], - flowChain('S', 'A', 'E'), - ), - ).warnings, - ).toEqual([]); - }); }); describe('irToDsl: a split degraded to jumps keeps its conditions on them', () => { it('writes each weighed route on its own jump under a marker naming the split and its kind', async () => { - // The merge is entered by a boundary chain too, so it is not the fork's - // own and the fork has no block. Its routes leave as jumps, each under the - // condition it carried, and the marker says which split they stand for. + // A boundary chain enters the merge too, so it is not the fork's own and + // the fork has no block. const ir = minimalProcess( [ { kind: 'startEvent', id: 'S' }, @@ -5739,7 +5291,6 @@ describe('irToDsl: a jump into a branch of a fork or a race', () => { ['inventedFallback', 'X'], ['crossBranchJump', 'B'], ); - // The refusal the report names. const errors = (await validate(source)).diagnostics .filter((d) => d.severity === 1) .map((d) => @@ -5838,6 +5389,109 @@ describe('irToDsl: a branch walk stops where the block comes back together', () // on itself leaves the whole graph with none. Each row is the shape as the // compiler lowers it, so the print has to come back as written. it.each([ + [ + 'a throw guard with no else prints the continuation after it at the body level, no gateway named', + [ + 'process p {', + ' error BOOM', + ' start S', + ' service Pre(class: "x.Pre")', + ' if (amount > 1000) {', + ' throw error(BOOM)', + ' }', + ' service Post(class: "x.Post")', + ' end Done', + '}', + ], + ], + [ + 'a throw guard inside a loop body keeps the statement after it inside the loop', + [ + 'process p {', + ' error X', + ' start S', + ' while (retries < 3) {', + ' service A(class: "x.A")', + ' if (retries < 1) {', + ' throw error(X)', + ' }', + ' service B(class: "x.B")', + ' }', + ' end Done', + '}', + ], + ], + [ + 'a fork with a throwing branch prints the throw inline and the continuation after the block', + [ + 'process p {', + ' error BOOM(message: "it broke")', + ' start Begin', + ' parallel {', + ' {', + ' service A(label: "a", class: "x.A")', + ' }', + ' {', + ' throw error(BOOM)', + ' }', + ' }', + ' end Finish', + '}', + ], + ], + [ + 'a fork whose ending branch runs a step first still joins the survivor at the merge', + [ + 'process p {', + ' start Begin', + ' parallel {', + ' {', + ' user A', + ' }', + ' {', + ' user B', + ' end Abandoned terminate', + ' }', + ' }', + ' end Finish', + '}', + ], + ], + [ + 'a fork whose surviving branches each hold a nested fork resumes after the outer join, not the first inner one', + [ + 'process p {', + ' error BOOM(message: "it broke")', + ' start Begin', + ' parallel {', + ' {', + ' parallel {', + ' {', + ' service A(label: "a", class: "x.A")', + ' }', + ' {', + ' service B(label: "b", class: "x.B")', + ' }', + ' }', + ' }', + ' {', + ' parallel {', + ' {', + ' service C(label: "c", class: "x.C")', + ' }', + ' {', + ' service D(label: "d", class: "x.D")', + ' }', + ' }', + ' }', + ' {', + ' throw error(BOOM)', + ' }', + ' }', + ' end Finish', + '}', + ], + ], [ 'a race with empty branches into a step that loops on itself prints the step after the block', [ @@ -6124,12 +5778,10 @@ describe('warnings: a condition the script has nowhere to write', () => { ], ); - // A split with one way out prints nothing of its own, so its one route is the - // same plain step-to-step flow a route between two steps is, and the report - // turns on whether the engine reads a condition there at all: a fork opening - // every route and a wait taking the first to resolve read none, so the drop - // costs nothing at run time and their reports say so instead. Reusing one - // message for the other would tell the reader a run changed that did not. + // A split with one way out prints nothing of its own, so the report turns on + // whether the engine reads a condition there at all: a fork opening every + // route and a wait taking the first to resolve read none, so their reports + // say the run is the same rather than telling the reader it changed. it.each([ ['a step', { kind: 'userTask', id: 'T' }, 'droppedFlowCondition'], [ @@ -6172,30 +5824,27 @@ describe('warnings: a condition the script has nowhere to write', () => { ); // A split that names a fallback is never left without a route, so the run - // carries on by another one instead of failing. The loop spends the fallback - // as its closing brace, which leaves the weighed route to print as the plain - // route on. + // carries on by another one instead of failing; the loop spends the fallback + // as its closing brace, leaving the weighed route to print as plain flow. it.each(['exclusiveGateway', 'inclusiveGateway'] as const)( 'reports a weighed route out of a %s that names a fallback as a run that goes on elsewhere', - (kind) => { - const { source, warnings } = printDsl( - loopIntoSplitIr( - { kind, id: 'Split', defaultFlowId: 'Flow_again' }, - { id: 'Flow_again' }, - { condition: '${settled}' }, - ), + async (kind) => { + const ir = loopIntoSplitIr( + { kind, id: 'Split', defaultFlowId: 'Flow_again' }, + { id: 'Flow_again' }, + { condition: '${settled}' }, ); + const { source, warnings } = printDsl(ir); expect(source).toContain('while (again) {'); expect(bodyOf(source)).not.toContain('settled'); expectReports(warnings, ['divertedRun', 'Split']); + await printed(ir); }, ); - // A choice whose fallback is weighed is the one the engine refuses at - // deployment, which the report beside this one says. A model that never runs - // takes no route, so the route this one leaves out is not one to describe as - // taken instead. + // A model the engine refuses to deploy takes no route, so the one this print + // leaves out is not described as taken instead. it('keeps the run that goes on elsewhere off a split whose fallback is weighed', () => { const { warnings } = printDsl( loopIntoSplitIr( @@ -6212,10 +5861,9 @@ describe('warnings: a condition the script has nowhere to write', () => { ); }); - it('reports it on a route the walk never reaches, which leaves as a bare jump', () => { - // A route whose source is not in the container: nothing walks it, so it - // prints in the closing sweep as a jump, and a jump carries the route and - // nothing else. + it('reports it on a route the walk never reaches, which leaves as a bare jump', async () => { + // Nothing walks a route whose source is outside the container, so the + // closing sweep prints it as a jump, which carries nothing but the route. const ir = minimalProcess( [ { kind: 'startEvent', id: 'S' }, @@ -6233,22 +5881,7 @@ describe('warnings: a condition the script has nowhere to write', () => { expect(source).toContain('goto A'); expect(bodyOf(source)).not.toContain('approved'); expectReports(warnings, ['droppedFlowCondition', 'Detached']); - }); - - it('says nothing where the route carries no condition', () => { - expect( - printDsl( - minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - { kind: 'exclusiveGateway', id: 'G' }, - { kind: 'userTask', id: 'A' }, - { kind: 'endEvent', id: 'E' }, - ], - flowChain('S', 'G', 'A', 'E'), - ), - ).warnings, - ).toEqual([]); + await printed(ir, 'orphanStep'); }); }); @@ -6261,39 +5894,16 @@ describe('warnings: source the compiler turns down, drawn from the model', () => expectReports(warnings, ['refusedStatement', 'Catch_Order_Paid']); await printed(ir, 'reservedId'); }); - - it('says nothing where the reserved name never prints', () => { - // A synthesized end with nothing to carry is dropped whole, so no name - // reaches the source to be turned down. - expect( - printDsl( - minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - { kind: 'endEvent', id: 'EndEvent_p' }, - ], - [edge('S', 'EndEvent_p')], - ), - ).warnings, - ).toEqual([]); - }); }); -// Hand-built: these IR shapes are what the desugarer emits for -// `parallel { if (c) { } ... }` and for `await { ... }`. - const DEFAULT_FLOW_ID = 'Flow_Gateway_p_1_fork_default'; /** - * A fork whose first branch is conditioned. `fallback` places the flow the - * fork names as its default: a third branch, the merge itself, the second - * branch, or nowhere. `all-conditioned` names none either and puts the second - * branch under a condition too, so the fork has nothing left to take when - * neither holds. - * - * `fallbackCondition` weighs the default flow itself. That is legal BPMN the - * fork never reads: the fallback is taken when no other branch was, whatever - * the condition on it says. + * Desugared `parallel { if (amount > 10000) { user Audit } { user Record } }` + * with the fork's fallback placed on a third branch, the merge itself, the + * second branch, or nowhere; `all-conditioned` names none and weighs the + * second branch too. `fallbackCondition` weighs the fallback itself, which is + * legal BPMN the fork never reads. */ function inclusiveIr( fallback: 'branch' | 'join' | 'none' | 'all-conditioned' | 'second-branch', @@ -6348,8 +5958,7 @@ function inclusiveIr( } describe('irToDsl: conditioned parallel branches', () => { - /** One conditioned branch and one plain one, the fork and the merge elided. */ - const twoBranchSource = (declared = ' var amount: any\n'): string => + const block = (declared: string, ...branches: string[]): string => 'process p {\n' + declared + ' start S\n' + @@ -6357,185 +5966,130 @@ describe('irToDsl: conditioned parallel branches', () => { ' if (amount > 10000) {\n' + ' user Audit\n' + ' }\n' + - ' {\n' + - ' user Record\n' + - ' }\n' + + branches.join('') + ' }\n' + ' end E\n' + '}\n'; + const plainRecord = ' {\n user Record\n }\n'; + const elseRecord = ' else {\n user Record\n }\n'; + const elseTriage = ' else {\n user Triage\n }\n'; + const AMOUNT = ' var amount: any\n'; + + // `Record` runs whatever the conditions do, so a fallback behind `Triage` can + // never fire: the print keeps the `else` (dropping it would move `Triage` off + // the run) and the compiler refuses it. A weighed fallback is legal BPMN the + // fork never reads, so it prints as the fallback and its condition is + // reported. + it.each< + [ + title: string, + ir: BpmnProcess, + source: string, + reports: (readonly [keyof typeof REPORT, string])[], + lowersTo: BpmnProcess, + refused: (keyof typeof MODEL_REFUSAL)[], + ] + >([ + [ + 'prints the conditioned branch, the plain one and the fallback, and reports the fallback as one that can never fire', + inclusiveIr('branch'), + block(AMOUNT, plainRecord, elseTriage), + [['deadFallback', 'Gateway_p_1_fork']], + inclusiveIr('branch'), + ['deadElse'], + ], + [ + 'leaves out the fallback branch when it runs straight into the merge, and reports nothing', + inclusiveIr('join'), + block(AMOUNT, plainRecord), + [], + inclusiveIr('none'), + [], + ], + [ + 'prints one weighed branch beside a bare fallback as the block with an else, and reports nothing', + inclusiveIr('second-branch'), + block(AMOUNT, elseRecord), + [], + inclusiveIr('second-branch'), + [], + ], + [ + 'says nothing about a fallback while one branch is unconditioned, that branch being taken whatever the conditions do', + inclusiveIr('none'), + block(AMOUNT, plainRecord), + [], + inclusiveIr('none'), + [], + ], + [ + 'reports the fallback it had to invent when the model names none, which the source lowers to', + inclusiveIr('all-conditioned'), + block( + ' var amount: any\n var urgent: any\n', + ' if (urgent) {\n user Record\n }\n', + ), + [['inventedFallback', 'Gateway_p_1_fork']], + { + ...inclusiveIr('all-conditioned'), + sequenceFlows: [ + ...inclusiveIr('all-conditioned').sequenceFlows, + edge('Gateway_p_1_fork', 'Gateway_p_1_join'), + ], + }, + [], + ], + [ + 'writes a fallback the model weighs as the fallback, keeping it off a run of its own, and reports the condition it leaves out', + inclusiveIr('second-branch', '${urgent}'), + block(' var amount: any\n var urgent: any\n', elseRecord), + [['forkFallbackCondition', 'Gateway_p_1_fork']], + inclusiveIr('second-branch'), + [], + ], + [ + 'leaves out a weighed fallback that runs straight into the merge, and reports the condition all the same', + inclusiveIr('join', '${late}'), + block(' var amount: any\n var late: any\n', plainRecord), + [['forkFallbackCondition', 'Gateway_p_1_fork']], + inclusiveIr('none'), + [], + ], + [ + 'reports a weighed fallback that nothing can reach as one that can never fire, beside the condition it leaves out', + inclusiveIr('branch', '${late}'), + block(' var amount: any\n var late: any\n', plainRecord, elseTriage), + [ + ['forkFallbackCondition', 'Gateway_p_1_fork'], + ['deadFallback', 'Gateway_p_1_fork'], + ], + inclusiveIr('branch'), + ['deadElse'], + ], + ])('%s', async (_title, ir, source, reports, lowersTo, refused) => { + const print = printDsl(ir); + expect(print.source).toBe(source); + expectReports(print.warnings, ...reports); - it('prints the conditioned branch, the plain one and the fallback, and reports the fallback as one that can never fire', async () => { - // `Record` carries no condition, so it runs whatever the conditions do and - // the fallback behind `Triage` is left nothing to pick up. The model says - // so and the print keeps it: dropping the `else` would move `Triage` off - // the run, and the report is what stops the author meeting the validator's - // refusal with no explanation. - const ir = inclusiveIr('branch'); - const { source, warnings } = printDsl(ir); - - expect(source).toBe( - 'process p {\n' + - ' var amount: any\n' + - ' start S\n' + - ' parallel {\n' + - ' if (amount > 10000) {\n' + - ' user Audit\n' + - ' }\n' + - ' {\n' + - ' user Record\n' + - ' }\n' + - ' else {\n' + - ' user Triage\n' + - ' }\n' + - ' }\n' + - ' end E\n' + - '}\n', - ); - expectReports(warnings, ['deadFallback', 'Gateway_p_1_fork']); - // The refusal the report warns about: the model is where the dead fallback - // comes from, so the print writes it out and the compiler turns it down. - await expectIdempotent(ir, 'deadElse'); - }); - - it('leaves out the fallback branch when it runs straight into the merge, and reports nothing', async () => { - // The same dead fallback as the case above, beside the same unconditioned - // branch, but it goes nowhere the merge does not, so it is left out and the - // printed source holds no `else` to report. The report is read off the - // branches that print, not off the model's edges. The compiler reserves no - // fallback beside an unconditioned branch either, so the source comes back - // as the model without that edge. - const ir = inclusiveIr('join'); - const { source, warnings } = printDsl(ir); - - expect(source).toBe(twoBranchSource()); - expect(warnings).toEqual([]); - const ir2 = await reDesugar(source); - expect(elementMultiset(ir2)).toEqual(elementMultiset(inclusiveIr('none'))); - expect(edgeMultiset(ir2)).toEqual(edgeMultiset(inclusiveIr('none'))); - }); - - it('prints one weighed branch beside a bare fallback as the block with an else, and reports nothing', async () => { - const ir = inclusiveIr('second-branch'); - const { source, warnings } = printDsl(ir); - - expect(source).toBe( - 'process p {\n' + - ' var amount: any\n' + - ' start S\n' + - ' parallel {\n' + - ' if (amount > 10000) {\n' + - ' user Audit\n' + - ' }\n' + - ' else {\n' + - ' user Record\n' + - ' }\n' + - ' }\n' + - ' end E\n' + - '}\n', - ); - expect(warnings).toEqual([]); - await expectIdempotent(ir); - }); - - it('says nothing about a fallback while one branch is unconditioned, that branch being taken whatever the conditions do', () => { - const { source, warnings } = printDsl(inclusiveIr('none')); - - // The same two branches as the case above, reached without a default flow. - expect(source).toBe(twoBranchSource()); - expect(warnings).toEqual([]); - }); - - it('reports the fallback it had to invent when the model names none', () => { - const { source, warnings } = printDsl(inclusiveIr('all-conditioned')); - - expect(source).toContain('if (amount > 10000) {'); - expectReports(warnings, ['inventedFallback', 'Gateway_p_1_fork']); + const back = await reDesugar(await printed(ir, ...refused)); + expect(elementMultiset(back)).toEqual(elementMultiset(lowersTo)); + expect(edgeMultiset(back)).toEqual(edgeMultiset(lowersTo)); }); - it('writes a fallback the model weighs as the fallback, keeping it off a run of its own, and reports the condition it leaves out', async () => { - // Legal BPMN whose condition a fork never weighs: it takes the fallback - // when it took no other branch, whatever that condition says. Head the - // branch with the condition instead and it joins the run whenever the - // condition holds, beside its sibling rather than in place of it. - const ir = inclusiveIr('second-branch', '${urgent}'); - const { source, warnings } = printDsl(ir); - - expect(source).toBe( - 'process p {\n' + - ' var amount: any\n' + - ' var urgent: any\n' + - ' start S\n' + - ' parallel {\n' + - ' if (amount > 10000) {\n' + - ' user Audit\n' + - ' }\n' + - ' else {\n' + - ' user Record\n' + - ' }\n' + - ' }\n' + - ' end E\n' + - '}\n', - ); - expect(bodyOf(source)).not.toContain('urgent'); - expectReports(warnings, ['forkFallbackCondition', 'Gateway_p_1_fork']); - - // What the round trip has to hold on to: the branch is still the fork's - // fallback and still carries no condition, so it runs where it ran before. - const relowered = await reDesugar(source); - const fork = relowered.flowElements.find( - (e): e is Extract => - e.kind === 'inclusiveGateway' && e.defaultFlowId !== undefined, - ); - const fallback = relowered.sequenceFlows.find( - (f) => f.id === fork?.defaultFlowId, + it('reports the weighed fallback of a fork a loop has left one route to print', async () => { + // The loop prints the fork's route back as its closing brace, so the fork + // reaches its position with its weighed fallback as the plain route on. + const ir = loopIntoSplitIr( + { kind: 'inclusiveGateway', id: 'Fork', defaultFlowId: 'Flow_settled' }, + {}, + { id: 'Flow_settled', condition: '${settled}' }, ); - expect(fallback?.targetRef).toBe('Record'); - expect(fallback?.conditionExpression).toBeUndefined(); - }); - - it('leaves out a weighed fallback that runs straight into the merge, and reports the condition all the same', async () => { - // The fallback goes nowhere the merge does not, so it stays implicit and - // the condition on it is the only thing there is to report. - const ir = inclusiveIr('join', '${late}'); const { source, warnings } = printDsl(ir); - expect(source).toBe( - twoBranchSource(' var amount: any\n var late: any\n'), - ); - expectReports(warnings, ['forkFallbackCondition', 'Gateway_p_1_fork']); - await reDesugar(source); - }); - - it('reports the weighed fallback of a fork a loop has left one route to print', () => { - // The loop prints the fork's route back into it as its closing brace, so - // the fork reaches its position with its own weighed fallback left and - // prints that as the plain route on. The fork weighs the fallback nowhere - // whichever way it prints, so the drop reads as the fallback it is. - const { source, warnings } = printDsl( - loopIntoSplitIr( - { kind: 'inclusiveGateway', id: 'Fork', defaultFlowId: 'Flow_settled' }, - {}, - { id: 'Flow_settled', condition: '${settled}' }, - ), - ); - expect(source).toContain('while (again) {'); expect(bodyOf(source)).not.toContain('settled'); expectReports(warnings, ['forkFallbackCondition', 'Fork']); - }); - - it('reports a weighed fallback that nothing can reach as one that can never fire, beside the condition it leaves out', () => { - // `Record` runs whatever the conditions do, so the fallback behind `Triage` - // is left nothing to pick up whether it is weighed or not. - const { source, warnings } = printDsl(inclusiveIr('branch', '${late}')); - - expect(source).toContain(' else {\n user Triage\n'); - expect(bodyOf(source)).not.toContain('late'); - expectReports( - warnings, - ['forkFallbackCondition', 'Gateway_p_1_fork'], - ['deadFallback', 'Gateway_p_1_fork'], - ); + await printed(ir); }); it('keeps a conditioned branch that runs straight into the merge as an empty block', async () => { @@ -6580,45 +6134,38 @@ describe('irToDsl: conditioned parallel branches', () => { ); }); - it('degrades to jumps when no merge of its own kind closes the fork, inventing no fallback on the way', () => { - // The merge is an XOR one, so the fork has no matching join and every - // branch keeps its edge as a jump instead. No block is printed, so no - // fallback is invented either, though both branches are conditioned. - const { source, warnings } = printDsl( - minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - { kind: 'inclusiveGateway', id: 'Gateway_p_1_fork' }, - { kind: 'userTask', id: 'Audit' }, - { kind: 'userTask', id: 'Record' }, - gateway('Gateway_p_1_join'), - { kind: 'endEvent', id: 'E' }, - ], - [ - edge('S', 'Gateway_p_1_fork'), - edge('Gateway_p_1_fork', 'Audit', { condition: '${ok}' }), - edge('Gateway_p_1_fork', 'Record', { condition: '${urgent}' }), - edge('Audit', 'Gateway_p_1_join'), - edge('Record', 'Gateway_p_1_join'), - edge('Gateway_p_1_join', 'E'), - ], - ), + it('degrades to jumps when no merge of its own kind closes the fork, inventing no fallback on the way', async () => { + // The merge is an XOR one, so no block is printed and no fallback is + // invented, though both branches are conditioned. + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'inclusiveGateway', id: 'Gateway_p_1_fork' }, + { kind: 'userTask', id: 'Audit' }, + { kind: 'userTask', id: 'Record' }, + gateway('Gateway_p_1_join'), + { kind: 'endEvent', id: 'E' }, + ], + [ + edge('S', 'Gateway_p_1_fork'), + edge('Gateway_p_1_fork', 'Audit', { condition: '${ok}' }), + edge('Gateway_p_1_fork', 'Record', { condition: '${urgent}' }), + edge('Audit', 'Gateway_p_1_join'), + edge('Record', 'Gateway_p_1_join'), + edge('Gateway_p_1_join', 'E'), + ], ); + const { source, warnings } = printDsl(ir); expect(source).not.toContain('parallel {'); expect(source).toContain('goto Audit'); expect(source).toContain('goto Record'); expectReports(warnings, ['degradedSplit', 'Gateway_p_1_fork']); + await printed(ir); }); }); describe('irToDsl: a split left with nowhere to go when no condition holds', () => { - /** - * The `if` chain's shapes, which the fork block's counterparts have their own - * block above: a choice, a loop's exits and a step's own routes all print as - * one chain, so the fall-through past it is the same in all three. - */ - /** `Pick` weighs both its routes and names none to take when neither holds. */ const allConditionedChoice = (defaultFlowId?: string): BpmnProcess => minimalProcess( @@ -6677,10 +6224,9 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () }); it('reads the fallback an imported step carries, and reports only the condition on it that is weighed nowhere', async () => { - // The two hops speak about the same step in the same run of the CLI: the - // import carries the fallback the model named and says nothing, and the - // print writes it as the `else`, which is the route the engine takes when - // the weighed one fails, whatever the condition on the fallback says. + // The import carries the fallback the model named and says nothing; the + // print writes it as the route the engine takes when the weighed one + // fails, whatever the condition on the fallback says. const condition = (body: string): string => `${body}`; const { ir, warnings: imported } = await xmlToIr(bpmnDoc` @@ -6715,47 +6261,45 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () '}\n', ); expectReports(warnings, ['forkFallbackCondition', 'Triage']); + await printed(ir); }); - it('says nothing about an invented fallback at a step whose route back into the loop the loop already printed', () => { - // The route back carries no condition, so `Review` always has it and can - // never be left with nowhere to go. The loop prints it as the closing - // brace, which leaves the weighed escapes at the step's position with no - // merge to close them, so they leave as jumps. - const { source, warnings } = printDsl( - minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - gateway('Loop'), - { kind: 'userTask', id: 'Review' }, - { kind: 'userTask', id: 'Escalate' }, - { kind: 'userTask', id: 'Reject' }, - { kind: 'userTask', id: 'Settle' }, - { kind: 'endEvent', id: 'E' }, - ], - [ - edge('S', 'Loop'), - edge('Loop', 'Review', { condition: '${again}' }), - edge('Loop', 'Settle'), - edge('Review', 'Loop'), - edge('Review', 'Escalate', { condition: '${overdue}' }), - edge('Review', 'Reject', { condition: '${abandoned}' }), - edge('Escalate', 'E'), - edge('Reject', 'E'), - edge('Settle', 'E'), - ], - ), + it('says nothing about an invented fallback at a step whose route back into the loop the loop already printed', async () => { + // The unconditioned route back means `Review` is never left with nowhere + // to go; the loop prints it as the closing brace, so the weighed escapes + // leave the step as jumps. + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + gateway('Loop'), + { kind: 'userTask', id: 'Review' }, + { kind: 'userTask', id: 'Escalate' }, + { kind: 'userTask', id: 'Reject' }, + { kind: 'userTask', id: 'Settle' }, + { kind: 'endEvent', id: 'E' }, + ], + [ + edge('S', 'Loop'), + edge('Loop', 'Review', { condition: '${again}' }), + edge('Loop', 'Settle'), + edge('Review', 'Loop'), + edge('Review', 'Escalate', { condition: '${overdue}' }), + edge('Review', 'Reject', { condition: '${abandoned}' }), + edge('Escalate', 'E'), + edge('Reject', 'E'), + edge('Settle', 'E'), + ], ); + const { source, warnings } = printDsl(ir); expect(source).toContain('while (again) {'); expectReports(warnings, ['degradedSplit', 'Review']); + await printed(ir); }); it('keeps the guard-clause continuation when the route the split names carries a condition', async () => { - // No clean join here: one branch throws while the route the split takes - // when nothing holds carries the main flow. That route is the continuation - // whether it carries a condition or not, so the guard clause still prints - // as one instead of degrading to a pair of jumps. + // The route the split takes when nothing holds is the continuation whether + // it carries a condition or not. const ir = await reDesugar(`process p { error BOOM start S @@ -6781,13 +6325,13 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () expect(source).toContain('service Post'); expect(source).not.toContain('goto '); expectReports(warnings, ['choiceFallbackCondition', split.id]); + await printed(ir); }); it('says nothing about an invented fallback at a fork that opens every branch', () => { - // Every branch is weighed and the fork names no fallback, but it opens all - // of them whatever the conditions say, so it is never left with nowhere to - // go and the block after it invents nothing. The conditions it reads - // nowhere are the only thing there is to report. + // The fork opens every branch whatever the conditions say, so it is never + // left with nowhere to go; the conditions it reads nowhere are all there + // is to report. const { warnings } = printDsl( minimalProcess( [ @@ -6813,12 +6357,8 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () }); it('writes a weighed fallback of a choice as the plain else, and reports the model the engine will not deploy', async () => { - // A choice weighs its fallback like any other route, so the engine refuses - // the model at deployment for carrying a condition there and there is no - // run to carry it into. Heading the branch with it would put it on a run of - // its own and leave the choice falling through where the model never did. - // The fallback lands among the weighed routes on a plain reading, so the - // model still has somewhere to go and nothing is invented for it. + // Heading the branch with the condition would put it on a run of its own + // and leave the choice falling through where the model never did. const ir = allConditionedChoice('F_record'); const { source, warnings } = printDsl(ir); @@ -6852,10 +6392,9 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () expect(fallback?.conditionExpression).toBeUndefined(); }); - // The loop spends the route round it, printing it as the `while` condition, - // the `do` closing condition, or the plain route on where one route is left, - // so it is gone from the routes still to print at the head. The report is - // asked of the routes the model gives the head, not of those. + // The loop spends the route round it (the `while` condition, the `do` + // closing condition, or plain flow where one route is left), so the report + // is asked of the routes the model gives the head, not of those left to print. const ESCAPES = [ ['Escalate', '${overdue}'], ['Settle', '${paid}'], @@ -6914,16 +6453,19 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () ], '} while (again)', ], - ] as const)('reports a loop head that %s', (_title, ir, reports, has) => { - const { source, warnings } = printDsl(ir); - expect(source).toContain(has); - expectReports(warnings, ...reports); - }); + ] as const)( + 'reports a loop head that %s', + async (_title, ir, reports, has) => { + const { source, warnings } = printDsl(ir); + expect(source).toContain(has); + expectReports(warnings, ...reports); + await printed(ir); + }, + ); it('reports the refusal alone when the one route the loop leaves is the weighed fallback itself', () => { - // The route left to print is the fallback, so the condition the plain - // route on leaves out is the one the refusal is about. Saying the engine - // reads it beside that would name a run the model never reaches. + // The condition plain flow leaves out is the one the refusal is about; + // saying the engine reads it too would name a run the model never reaches. const { warnings } = printDsl( loopIntoSplitIr( gateway('Pick', 'Flow_settled'), @@ -6939,10 +6481,9 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () }); it('gives the else to the route the split names, over a plain route beside it', async () => { - // `Triage` carries no condition and is not the named fallback, so the model - // takes it whenever the weighed route fails and never reaches `Record`. The - // `else` has to be `Record` for the chain to read that way: give it to - // `Triage` instead and the plain route becomes the one nothing reaches. + // The model takes the unconditioned `Triage` whenever the weighed route + // fails and never reaches `Record`; an `else` on `Triage` would make the + // plain route the one nothing reaches. const ir = minimalProcess( [ { kind: 'startEvent', id: 'S' }, @@ -6987,12 +6528,8 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () }); describe('irToDsl: a condition on a branch of a fork that weighs none', () => { - /** - * A fork that opens every branch, with a condition on one of them. Legal - * BPMN the engine never reads, and content the block form has no head to - * carry: a head written here would read back as the fork that weighs its - * branches, which is a different fork. - */ + // Legal BPMN the engine never reads, and a head written for it would read + // back as the fork that weighs its branches, which is a different fork. const CONDITIONED_AND_FORK: BpmnProcess = minimalProcess( [ { kind: 'startEvent', id: 'S' }, @@ -7012,29 +6549,25 @@ describe('irToDsl: a condition on a branch of a fork that weighs none', () => { ], ); - it('leaves the condition out, keeping the fork the fork it was', async () => { - const dsl = await printed(CONDITIONED_AND_FORK); - - expect(dsl).toContain('parallel {'); - expect(bodyOf(dsl)).not.toContain('urgent'); - // Both forks print as `parallel`, so a head is all that tells them apart. - expect(dsl).not.toContain('if ('); - }); - - it('reports the condition it left out, naming the fork it belongs to', () => { - const { warnings } = printDsl(CONDITIONED_AND_FORK); - - expectReports(warnings, ['unweighedBranch', 'Gateway_p_1_fork']); - }); - - it('says nothing when no branch of the fork is weighed', () => { - const plain: BpmnProcess = { - ...CONDITIONED_AND_FORK, - sequenceFlows: CONDITIONED_AND_FORK.sequenceFlows.map( - ({ conditionExpression: _drop, ...rest }) => rest, - ), - }; - expect(printDsl(plain).warnings).toEqual([]); + it('leaves the condition out, keeping the fork the fork it was, and reports it naming the fork', async () => { + expect(bodyOf(await printed(CONDITIONED_AND_FORK))).toBe( + 'process p {\n' + + ' start S\n' + + ' parallel {\n' + + ' {\n' + + ' user Audit\n' + + ' }\n' + + ' {\n' + + ' user Record\n' + + ' }\n' + + ' }\n' + + ' end E\n' + + '}\n', + ); + expectReports(printDsl(CONDITIONED_AND_FORK).warnings, [ + 'unweighedBranch', + 'Gateway_p_1_fork', + ]); }); }); @@ -7088,14 +6621,28 @@ describe('irToDsl: race', () => { await expectIdempotent(RACE_IR); }); - it('reports a condition weighing a race branch, which the block form has nowhere to put', () => { - // Legal BPMN a race never weighs: it opens every branch at once and takes - // the first to resolve, so the condition decides nothing either way. The - // print is the same source as without it, and the report is the only trace. + // A race opens every branch at once and takes the first to resolve, so a + // condition on a branch decides nothing; one between the wait and the step + // it opens on is read, and the block form writes the body straight under + // the wait, so it has no place to go either way. + it.each([ + [ + 'a condition weighing a race branch, which the block form has nowhere to put', + 'Gateway_p_1_race', + 'Catch_p_1_b1', + 'raceCondition', + ], + [ + 'a condition on the route from a wait into its own body, which the engine reads', + 'Catch_p_1_b0', + 'Ship', + 'droppedFlowCondition', + ], + ] as const)('reports %s', async (_title, sourceRef, targetRef, report) => { const ir: BpmnProcess = { ...RACE_IR, sequenceFlows: RACE_IR.sequenceFlows.map((f) => - f.sourceRef === 'Gateway_p_1_race' && f.targetRef === 'Catch_p_1_b1' + f.sourceRef === sourceRef && f.targetRef === targetRef ? { ...f, conditionExpression: '${overdue}' } : f, ), @@ -7103,29 +6650,8 @@ describe('irToDsl: race', () => { const { source, warnings } = printDsl(ir); expect(bodyOf(source)).toBe(bodyOf(irToDsl(RACE_IR))); - expect(bodyOf(source)).not.toContain('overdue'); - expectReports(warnings, ['raceCondition', 'Gateway_p_1_race']); - }); - - it('reports a condition on the route from a wait into its own body, which the engine reads', async () => { - // Not the condition on the branch above, which the wait weighs nowhere: - // this one sits between the wait and the step it opens on, where the - // engine takes the route only when it holds and refuses the run when it - // does not. The block form writes the body straight under the wait, so - // the condition has no place to go. - const ir: BpmnProcess = { - ...RACE_IR, - sequenceFlows: RACE_IR.sequenceFlows.map((f) => - f.sourceRef === 'Catch_p_1_b0' && f.targetRef === 'Ship' - ? { ...f, conditionExpression: '${ok}' } - : f, - ), - }; - const { source, warnings } = printDsl(ir); - - expect(bodyOf(source)).toBe(bodyOf(irToDsl(RACE_IR))); - expect(bodyOf(source)).not.toContain('ok'); - expectReports(warnings, ['droppedFlowCondition', 'Catch_p_1_b0']); + expectReports(warnings, [report, sourceRef]); + await printed(ir); }); it('writes the branch settings in the header parens, and an empty body for a branch that only waits', async () => { @@ -7268,42 +6794,52 @@ describe('irToDsl: race', () => { await printed(ir); }); - it('degrades when a branch does not open on a wait, and loses no edge doing it', () => { - const { source, warnings } = printDsl( - minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - { kind: 'eventBasedGateway', id: 'Gateway_p_1_race' }, - typedEvent( - 'intermediateCatchEvent', - 'Catch_p_1_b0', - messageDef('Paid'), - ), - { kind: 'userTask', id: 'Chase' }, - { kind: 'endEvent', id: 'E' }, - ], - [ - edge('S', 'Gateway_p_1_race'), - edge('Gateway_p_1_race', 'Catch_p_1_b0'), - edge('Catch_p_1_b0', 'E'), - edge('Gateway_p_1_race', 'Chase'), - edge('Chase', 'E'), - ], - ), + it('degrades when a branch does not open on a wait, and loses no edge doing it', async () => { + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'eventBasedGateway', id: 'Gateway_p_1_race' }, + typedEvent( + 'intermediateCatchEvent', + 'Catch_p_1_b0', + messageDef('Paid'), + ), + { kind: 'userTask', id: 'Chase' }, + { kind: 'endEvent', id: 'E' }, + ], + [ + edge('S', 'Gateway_p_1_race'), + edge('Gateway_p_1_race', 'Catch_p_1_b0'), + edge('Catch_p_1_b0', 'E'), + edge('Gateway_p_1_race', 'Chase'), + edge('Chase', 'E'), + ], ); + const { source, warnings } = printDsl(ir); - expect(source).not.toContain('await {'); // One edge takes a jump; the other lands on a wait, which has no name to - // jump to, so it leaves the marker and its report instead. - expect(source).toContain('goto Chase'); - expect(source).toContain(`${UNSTRUCTURED_MARKER} (dropped edge into Catch`); + // jump to, so it leaves the marker and the wait prints on its own. + expect(source).toBe( + 'process p {\n' + + ' start S\n' + + ` ${UNSTRUCTURED_MARKER} (split Gateway_p_1_race degraded to jumps; was event-based)\n` + + ' if (true) {\n' + + ` ${UNSTRUCTURED_MARKER} (dropped edge into Catch_p_1_b0)\n` + + ' } else {\n' + + ' goto Chase\n' + + ' }\n' + + ' await message("Paid")\n' + + ' end E\n' + + ' user Chase\n' + + ' goto E\n' + + '}\n', + ); expectReports( warnings, ['degradedSplit', 'Gateway_p_1_race'], ['droppedEdge', 'Catch_p_1_b0'], ); - // The wait itself is still printed, so its own chain survives. - expect(source).toContain('await message("Paid")'); + await printed(ir); }); }); @@ -7328,41 +6864,18 @@ describe('irToDsl: a split with one way out is transparent', () => { }, ); - /** - * One route of a real node lands on a one-way split, and the routes end - * apart, so the branch keeps its edge as a jump. The jump has to forward - * through the split to the successor: naming the split is impossible, and - * giving up on it would drop an edge the model has. - */ - const passThroughIr = (kind: 'inclusiveGateway' | 'eventBasedGateway') => - minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - { kind: 'userTask', id: 'A' }, - { kind, id: 'G' }, - { kind: 'userTask', id: 'R' }, - { kind: 'endEvent', id: 'E' }, - { kind: 'endEvent', id: 'E2' }, - ], - [ - edge('S', 'A'), - edge('A', 'E'), - edge('A', 'G'), - edge('G', 'R'), - edge('R', 'E2'), - ], - ); - - it.each(['inclusiveGateway', 'eventBasedGateway'] as const)( - 'forwards a jump through a one-way %s to the real successor', - (kind) => { - const { source, warnings } = printDsl(passThroughIr(kind)); + it.each([ + ['a one-way merge under a synthesized id', PASS_THROUGH_IR], + ['a one-way inclusive split', passThroughIr('inclusiveGateway')], + ['a one-way wait', passThroughIr('eventBasedGateway')], + ])('forwards a jump through %s to the real successor', async (_title, ir) => { + const { source, warnings } = printDsl(ir); - expect(source).toContain('goto R'); - expect(source).not.toContain('dropped edge'); - expectReports(warnings, ['degradedSplit', 'A']); - }, - ); + expect(source).toContain('goto R'); + expect(source).not.toContain('dropped edge'); + expectReports(warnings, ['degradedSplit', 'A']); + await printed(ir); + }); }); /** `ir` with each listed element carrying the job settings named for it. */ @@ -7378,11 +6891,7 @@ function withJobSettings( }; } -/** - * The parens of every gateway statement in `dsl`, in source order, as - * `[statement type, ['key: value', ...]]`: which head the printer put the keys - * on. Which gateway each lands on is the compiler's business. - */ +/** The parens of every gateway statement in `dsl`, in source order: which head the printer put the keys on. */ async function headParens(dsl: string): Promise<[string, string[]][]> { const doc = await parse(dsl); return AstUtils.streamAllContents(doc.parseResult.value) @@ -7397,10 +6906,9 @@ async function headParens(dsl: string): Promise<[string, string[]][]> { } /** - * Every gateway of `ir` carrying a setting, as `[kind, settings]` in an order - * the ids take no part in: the compiler mints its own ids on re-parse, and a - * merge that splits again comes back as a split beside a join the model never - * had, so only the settings-bearing gateways can be compared. + * Every gateway of `ir` carrying a setting, as `[kind, settings]` sorted + * without the ids: the compiler mints its own on re-parse, and a merge that + * splits again comes back as a split beside a join the model never had. */ function gatewaySettings(ir: BpmnProcess): [string, JobSettings][] { return ir.flowElements @@ -7502,12 +7010,10 @@ describe('irToDsl: gateway settings', () => { ], ); - // Revert symptoms: drop the join items from the head -> every `join*` row - // red; take the join's settings whatever its shape -> the merge-that-splits - // row prints them as `join*` on the first `if`; read the loop's settings - // again for its leftover routes -> the loop rows print them twice; read a - // join's keys onto the split in the compiler -> the printed text is - // unchanged and the re-parse pin alone goes red. + // Revert symptoms: `takeJoinSettings` reading a merge whatever its shape + // prints the merge-that-splits row's settings as `join*` on the first `if`; + // a compiler reading a join's keys onto the split leaves the text unchanged + // and only the re-parse pin goes red. it.each([ [ 'an if prints the split settings and, join-prefixed, the pass-through merge settings', @@ -7770,12 +7276,12 @@ describe('irToDsl: gateway settings', () => { ], ] as const)( 'reports the settings of %s once and writes none of them', - (_title, ir, id, reports) => { + async (_title, ir, id, reports) => { const { source, warnings } = printDsl( withJobSettings(ir, { [id]: { asyncBefore: true, jobPriority: '7' } }), ); - expect(source).toBe(irToDsl(ir)); + expect(source).toBe(await printed(ir)); expectReports(warnings, ...reports); }, ); diff --git a/packages/transform/test/ir-to-xml.layout-guard.test.ts b/packages/transform/test/ir-to-xml.layout-guard.test.ts index 6e53951..3eed815 100644 --- a/packages/transform/test/ir-to-xml.layout-guard.test.ts +++ b/packages/transform/test/ir-to-xml.layout-guard.test.ts @@ -14,7 +14,7 @@ vi.mock('bpmn-auto-layout', () => ({ import { irToXml } from '../src/ir-to-xml.js'; import { LayoutError } from '../src/errors.js'; -import { minimalProcess } from './helpers/ir-fixtures.js'; +import { chainedSub, minimalProcess } from './helpers/ir-fixtures.js'; describe('irToXml: layoutProcess guard', () => { it('wraps a layoutProcess throw in LayoutError carrying the DI-less document', async () => { @@ -28,4 +28,19 @@ describe('irToXml: layoutProcess guard', () => { expect(layoutError.xml).toContain(' { + const err = await irToXml( + minimalProcess([ + { kind: 'task', id: 'X' }, + chainedSub('Sub', [{ kind: 'task', id: 'Sub_X' }]), + ]), + ).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(LayoutError); + expect((err as LayoutError).xml).not.toContain('bpmndi:'); + expect((err as LayoutError).xml).toContain(' astToIr -> irToXml` pipeline. + * Every test runs the full serializer, `bpmn-auto-layout` included. Fixtures + * are hand-built IR, so nothing here depends on the parser or the desugarer + * except the golden diff, which runs the whole `parse -> astToIr -> irToXml` + * pipeline. */ import { describe, it, expect, beforeAll } from 'vitest'; @@ -16,7 +16,12 @@ import { parseHelper } from 'langium/test'; import { createBpmnScriptServices } from '@bpmn-script/language'; import type { Model } from '@bpmn-script/language'; -import { irToXml, HISTORY_TIME_TO_LIVE } from '../src/ir-to-xml.js'; +import { + createModdle, + irToXml, + HISTORY_TIME_TO_LIVE, + SERVICE_TASK_LIKE_TAG, +} from '../src/ir-to-xml.js'; import { astToIr } from '../src/ast-to-ir.js'; import { around, @@ -50,7 +55,6 @@ import { } from './helpers/ir-fixtures.js'; import type { BpmnProcess, - CallVariableMapper, CatchEventDefinition, CodeBinding, EndEventDefinition, @@ -60,6 +64,7 @@ import type { FormField, Gateway, LoopCharacteristics, + ServiceTask, ServiceTaskBinding, VersionBinding, } from '../src/ir/types.js'; @@ -74,7 +79,6 @@ const EXAMPLE_BPMNSCRIPT_PATH = resolve( '../../../examples/spring-boot/processes/invoice-approval.bpmnscript', ); -/** The handwritten golden as imported, whose name import derives and drops. */ const importShapedIr: BpmnProcess = { ...HANDWRITTEN_IMPORT_IR, name: 'Invoice Approval', @@ -88,18 +92,32 @@ beforeAll(async () => { xml = await irToXml(importShapedIr); }); -describe('irToXml: bpmn-moddle round-trip', () => { +describe('irToXml: import-shaped IR', () => { it('irToXml(importShapedIr) parses cleanly via bpmn-moddle.fromXML', async () => { await expectNoModdleWarnings(xml); }); - it('labels the conditioned flow with its bare condition text', () => { - // Viewers render a flow's `name`, not its `conditionExpression`, so the - // condition (minus the `${...}` delimiters) is mirrored as the edge label. - // `>` may serialize as the numeric (`>`) or named (`>`) entity - // depending on the writer; decode both so the assertion is encoding-robust. - const decoded = xml.replace(/(>|>)/g, '>'); - expect(decoded).toContain('name="amount > 1000"'); + it('gives every node the incoming/outgoing children its IR edges call for', () => { + // MIWG requires the children and bpmn-moddle does not derive them, so the + // degree of every node is checked rather than the document-wide total. + const degrees = Object.fromEntries( + [ + 'ReviewStart', + 'ReviewInvoice', + 'AmountCheck', + 'SeniorApproval', + 'AutoApprove', + 'Done', + ].map((id) => [id, degreeOf(xml, id)]), + ); + expect(degrees).toEqual({ + ReviewStart: { in: 0, out: 1 }, + ReviewInvoice: { in: 1, out: 1 }, + AmountCheck: { in: 1, out: 2 }, + SeniorApproval: { in: 1, out: 1 }, + AutoApprove: { in: 1, out: 1 }, + Done: { in: 2, out: 0 }, + }); }); }); @@ -129,47 +147,6 @@ describe('irToXml: bpmn:Definitions id', () => { }); }); -describe('irToXml: Operaton extension attributes', () => { - it.each([ - 'operaton:assignee="demo"', - 'operaton:assignee="manager"', - 'operaton:class="com.example.invoice.AutoApproveDelegate"', - 'operaton:historyTimeToLive="P30D"', - ])('contains %s', (attribute) => { - expect(xml).toContain(attribute); - }); - - it('emits the bpmndi:BPMNDiagram block', () => { - expect(xml).toMatch(/ { - it('gives every node the incoming/outgoing children its IR edges call for', () => { - // MIWG requires the children and bpmn-moddle does not derive them, so the - // degree of every node is checked rather than the document-wide total. - const degrees = Object.fromEntries( - [ - 'ReviewStart', - 'ReviewInvoice', - 'AmountCheck', - 'SeniorApproval', - 'AutoApprove', - 'Done', - ].map((id) => [id, degreeOf(xml, id)]), - ); - expect(degrees).toEqual({ - ReviewStart: { in: 0, out: 1 }, - ReviewInvoice: { in: 1, out: 1 }, - // The gateway's two branches, converging on the one end event. - AmountCheck: { in: 1, out: 2 }, - SeniorApproval: { in: 1, out: 1 }, - AutoApprove: { in: 1, out: 1 }, - Done: { in: 2, out: 0 }, - }); - }); -}); - describe('irToXml: full-pipeline golden diff', () => { let pipelineXml: string; @@ -192,9 +169,8 @@ describe('irToXml: full-pipeline golden diff', () => { it('irToXml(astToIr(parse(example))) matches the generated golden byte-for-byte', () => { // The golden is what the engine E2E deploys, so this pins the whole engine - // contract at once: process id, task ids, delegate, assignees, condition. - // The example holds no sub-process, so it also pins that the DI expansion - // hint is attached only when one is actually present. + // contract at once. The example holds no sub-process, so it also pins that + // the DI expansion hint is attached only when one is present. const goldenXml = readFileSync(GOLDEN_GENERATED_PATH, 'utf-8'); expect(pipelineXml).toBe(goldenXml); }); @@ -297,21 +273,14 @@ describe('irToXml: inclusive and event-based gateway serialization', () => { }); it('emits both tags under their own ids and names, with the default only where the IR carries one', () => { - expect(gatewaysXml).toMatch(/]*id="Fork"/); - expect(gatewaysXml).toMatch(/]*id="Merge"/); - expect(gatewaysXml).toMatch(/]*id="Race"/); - - const fork = extractNodeBlock(gatewaysXml, 'Fork'); - const merge = extractNodeBlock(gatewaysXml, 'Merge'); - const race = extractNodeBlock(gatewaysXml, 'Race'); - - expect(fork).toContain('name="Any that apply"'); - expect(race).toContain('name="First of"'); - expect(fork).toContain('default="F_Fork_C"'); + const openingTag = (id: string): string => + extractNodeBlock(gatewaysXml, id).split('\n')[0]!; // A synthesized id must not be humanized into a label. - expect(merge).not.toContain('name='); - expect(merge).not.toContain('default='); - expect(race).not.toContain('default='); + expect(['Fork', 'Merge', 'Race'].map(openingTag)).toEqual([ + '', + '', + '', + ]); expect(degreeOf(gatewaysXml, 'Fork').out).toBe(3); expect(degreeOf(gatewaysXml, 'Merge').in).toBe(3); @@ -362,42 +331,13 @@ describe('irToXml: inclusive and event-based gateway serialization', () => { expect(bounds.width).toBeGreaterThan(0); expect(bounds.height).toBeGreaterThan(0); } - // Every sequence flow keeps its edge: the two new kinds route like a - // parallel gateway as far as the layout is concerned. expect((gatewaysXml.match(/ { - it.each([ - [ - 'expression binding emits operaton:expression', - exprBinding('${bean.method(execution)}'), - ['operaton:expression="${bean.method(execution)}"'], - ], - [ - 'delegateExpression binding emits operaton:delegateExpression', - delegateBinding('${myDelegate}'), - ['operaton:delegateExpression="${myDelegate}"'], - ], - [ - 'external binding emits operaton:type="external" and operaton:topic', - externalBinding('shipping'), - ['operaton:type="external"', 'operaton:topic="shipping"'], - ], - ] as const)('%s', async (_title, binding, expected) => { - const out = await irToXml( - around({ kind: 'serviceTask', id: 'Task', binding }), - ); - for (const attribute of expected) { - expect(out).toContain(attribute); - } - }); -}); - -/** The `operaton:field` children a builtin binding's fields serialize to, one literal and one expression. */ +/** The `operaton:field` children `fields` serialize to, under a builtin binding. */ function builtinFieldsBlock(fields: FieldInjection[]): string { return fields .map((f) => @@ -408,50 +348,124 @@ function builtinFieldsBlock(fields: FieldInjection[]): string { .join(''); } -/** The serialized tag for each `ServiceTask.element`; lower-cased, unlike the moddle `$type` name. */ -const SERVICE_TASK_LIKE_XML_TAG = { - service: 'bpmn:serviceTask', - send: 'bpmn:sendTask', - businessRule: 'bpmn:businessRuleTask', -} as const; +/** The serialized tag of a `ServiceTask.element`: moddle writes the `$type` name with its first letter lowered. */ +function serviceTaskLikeXmlTag(element: ServiceTask['element']): string { + return SERVICE_TASK_LIKE_TAG[element ?? 'service'].replace( + /:[A-Z]/, + (prefixed) => prefixed.toLowerCase(), + ); +} -describe('irToXml: builtin (mail/shell) service task binding', () => { - it.each([ - ['service', 'mail'], - ['businessRule', 'shell'], - ] as const)( - 'a %s task with a builtin %s binding serializes operaton:type and its fields, under its own tag', - async (element, type) => { - const fields: FieldInjection[] = - type === 'mail' - ? [ - { name: 'to', value: 'ops@example.com' }, - { name: 'text', value: '${body}' }, - ] - : [ - { name: 'command', value: 'echo hi' }, - { name: 'arg1', value: '${input}' }, - ]; - const tag = SERVICE_TASK_LIKE_XML_TAG[element]; - const xml = await irToXml( - around({ - kind: 'serviceTask', - id: 'Task', - ...(element === 'service' ? {} : { element }), - binding: builtinBinding(type, fields), - }), - ); - expect(extractNodeBlock(xml, 'Task')).toBe( - `<${tag} id="Task" name="Task" operaton:type="${type}">\n` + - ' \n' + - builtinFieldsBlock(fields) + - ' \n' + - ' F1\n' + - ' F2\n' + - ` `, - ); - }, +/** The whole block of the task `T` of {@link around}, with the given attributes and extension children. */ +function taskBlock( + element: ServiceTask['element'], + attributes: string, + extension = '', +): string { + const tag = serviceTaskLikeXmlTag(element); + return ( + `<${tag} id="T" name="T" ${attributes}>\n` + + extension + + ' F1\n' + + ' F2\n' + + ` ` ); +} + +const MAIL_FIELDS: FieldInjection[] = [ + { name: 'to', value: 'ops@example.com' }, + { name: 'text', value: '${body}' }, +]; +const SHELL_FIELDS: FieldInjection[] = [ + { name: 'command', value: 'echo hi' }, + { name: 'arg1', value: '${input}' }, +]; + +describe('irToXml: service-task-like tags and their bindings', () => { + it.each<[string, Omit, string, string?]>([ + [ + 'a class binding writes operaton:class', + { binding: classBinding('com.example.Run') }, + 'operaton:class="com.example.Run"', + ], + [ + 'an expression binding writes operaton:expression', + { binding: exprBinding('${bean.method(execution)}') }, + 'operaton:expression="${bean.method(execution)}"', + ], + [ + 'a delegateExpression binding writes operaton:delegateExpression', + { binding: delegateBinding('${myDelegate}') }, + 'operaton:delegateExpression="${myDelegate}"', + ], + [ + 'an external binding with a topic alone writes operaton:type and operaton:topic, no taskPriority and no extension block', + { binding: externalBinding('shipping') }, + 'operaton:type="external" operaton:topic="shipping"', + ], + [ + 'a send task carries its binding under bpmn:sendTask', + { element: 'send', binding: classBinding('com.example.Run') }, + 'operaton:class="com.example.Run"', + ], + [ + 'a business rule task carries its binding under bpmn:businessRuleTask', + { element: 'businessRule', binding: classBinding('com.example.Run') }, + 'operaton:class="com.example.Run"', + ], + [ + 'a builtin mail binding writes operaton:type and its fields as extension children', + { binding: builtinBinding('mail', MAIL_FIELDS) }, + 'operaton:type="mail"', + builtinFieldsBlock(MAIL_FIELDS), + ], + [ + 'a builtin shell binding does the same under bpmn:businessRuleTask', + { + element: 'businessRule', + binding: builtinBinding('shell', SHELL_FIELDS), + }, + 'operaton:type="shell"', + builtinFieldsBlock(SHELL_FIELDS), + ], + [ + 'a decision binding with no modifier writes decisionRef alone', + { + element: 'businessRule', + binding: { kind: 'decision', decisionRef: 'riskRating' }, + }, + 'operaton:decisionRef="riskRating"', + ], + [ + 'a decision binding with a pinned version and a result mapping writes all four DMN attributes beside resultVariable', + { + element: 'businessRule', + binding: { + kind: 'decision', + decisionRef: 'riskRating', + binding: { kind: 'version', version: '3' }, + mapDecisionResult: 'singleEntry', + }, + resultVariable: 'risk', + }, + 'operaton:resultVariable="risk" operaton:decisionRef="riskRating" operaton:decisionRefBinding="version" operaton:decisionRefVersion="3" operaton:mapDecisionResult="singleEntry"', + ], + ])('%s', async (_title, task, attributes, fields) => { + const xml = await irToXml( + around({ kind: 'serviceTask', id: 'T', ...task }), + ); + expect(extractNodeBlock(xml, 'T')).toBe( + taskBlock( + task.element, + attributes, + fields === undefined + ? '' + : ' \n' + + fields + + ' \n', + ), + ); + }); }); /** @@ -508,26 +522,9 @@ describe('irToXml: external task extras', () => { ]); }); - it('an external binding with topic alone serializes exactly as before: no taskPriority, no extensionElements', async () => { - const xml = await irToXml( - around({ - kind: 'serviceTask', - id: 'Task', - binding: externalBinding('shipping'), - }), - ); - expect(extractNodeBlock(xml, 'Task')).not.toContain( - 'operaton:taskPriority', - ); - expect(xml).not.toContain(' { + it.each([['send'], ['businessRule']] as const)( + 'a %s task carries the same properties and mapping children under its own tag', + async (element) => { const xml = await irToXml( around({ kind: 'serviceTask', @@ -536,36 +533,32 @@ describe('irToXml: external task extras', () => { binding: externalWithExtras, }), ); - expect(extractNodeBlock(xml, 'Step').startsWith(`<${tag} `)).toBe(true); + expect(extractNodeBlock(xml, 'Step').split(' ')[0]).toBe( + `<${serviceTaskLikeXmlTag(element)}`, + ); expect(extensionBlock(xml)).toBe(FROZEN_EXTERNAL_EXTRAS_BLOCK); }, ); }); describe('irToXml: scriptTask serialization', () => { - const scriptIr: BpmnProcess = around( - scriptTask( - 'Compute', - 'javascript', - 'var total = amount * 2;\nreturn total;', - ), - ); - - let scriptXml: string; - - beforeAll(async () => { - scriptXml = await irToXml(scriptIr); - }); - - it('emits a bpmn:scriptTask carrying its format, the body text surviving inside it', () => { - expect(scriptXml).toMatch( - /]*id="Compute"[^>]*scriptFormat="javascript"/, + it('emits a bpmn:scriptTask carrying its format, the body text verbatim inside it, and re-reads clean', async () => { + const scriptXml = await irToXml( + around( + scriptTask( + 'Compute', + 'javascript', + 'var total = amount * 2;\nreturn total;', + ), + ), + ); + expect(extractNodeBlock(scriptXml, 'Compute')).toBe( + '\n' + + ' F1\n' + + ' F2\n' + + ' var total = amount * 2;\nreturn total;\n' + + ' ', ); - expect(scriptXml).toContain('var total = amount * 2;'); - expect(scriptXml).toContain('return total;'); - }); - - it('parses cleanly via bpmn-moddle', async () => { await expectNoModdleWarnings(scriptXml); }); }); @@ -596,9 +589,6 @@ describe('irToXml: documentation', () => { expect(documentationOf(childById(proc, 'End'))).toEqual(['Order handled.']); expect(childById(proc, 'Start').documentation).toBeUndefined(); - // A bare opening tag, with no `textFormat` attribute, for all three: an - // absent attribute is what `text/plain` means, and the parsed tree's - // `.textFormat` getter would answer that default either way. expect(documentedXml.match(/]*>/g)).toEqual([ '', '', @@ -629,8 +619,6 @@ const twoLevelIr: BpmnProcess = chained([ ]); describe('irToXml: sub-process containment', () => { - // Inspected as the semantic element tree, not the DI, which the layout - // regenerates: the output is parsed back with raw `bpmn-moddle`. const nestedIr: BpmnProcess = chained([ { kind: 'startEvent', id: 'PStart' }, chainedSub('sub', [ @@ -641,15 +629,11 @@ describe('irToXml: sub-process containment', () => { { kind: 'endEvent', id: 'PEnd' }, ]); - let proc: Moddle; - let nestedXml: string; - - beforeAll(async () => { - nestedXml = await irToXml(nestedIr); - proc = await parseProcessTree(nestedXml); - }); + it('emits a bpmn:SubProcess holding its own children and flows, wired to them, the parent holding neither', async () => { + const nestedXml = await irToXml(nestedIr); + await expectNoModdleWarnings(nestedXml); + const proc = await parseProcessTree(nestedXml); - it('emits a bpmn:SubProcess holding its own children and flows, the parent holding neither', () => { const sub = childById(proc, 'sub'); expect(sub.$type).toBe('bpmn:SubProcess'); expect(structureOf(sub)).toEqual([ @@ -666,27 +650,18 @@ describe('irToXml: sub-process containment', () => { 'bpmn:SequenceFlow SF_PStart_sub', 'bpmn:SequenceFlow SF_sub_PEnd', ]); - }); - it('wires nested children incoming/outgoing to the nested flows', () => { - const review = childById(childById(proc, 'sub'), 'Review'); + const review = childById(sub, 'Review'); expect((review.incoming ?? []).map((f) => f.id)).toEqual([ 'SF_SubStart_Review', ]); expect((review.outgoing ?? []).map((f) => f.id)).toEqual([ 'SF_Review_SubEnd', ]); - }); - - it('routes the parent-level flows to the sub-process element itself', () => { expect(childById(proc, 'SF_PStart_sub').targetRef?.id).toBe('sub'); expect(childById(proc, 'SF_sub_PEnd').sourceRef?.id).toBe('sub'); }); - it('parses cleanly via bpmn-moddle', async () => { - await expectNoModdleWarnings(nestedXml); - }); - it('wires a nested exclusive gateway default to the nested flow', async () => { const gatewayIr: BpmnProcess = chained([ { kind: 'startEvent', id: 'PStart' }, @@ -718,23 +693,35 @@ describe('irToXml: sub-process containment', () => { expect(gw.default?.id).toBe('SF_Gw_B'); }); - it('serializes two-level nesting recursively', async () => { + it('serializes two-level nesting recursively, each level holding its own children only', async () => { const tree = await parseProcessTree(await irToXml(twoLevelIr)); const outer = childById(tree, 'Outer'); - expect(outer.$type).toBe('bpmn:SubProcess'); const inner = childById(outer, 'Inner'); - expect(inner.$type).toBe('bpmn:SubProcess'); - const deep = childById(inner, 'Deep'); - expect(deep.$type).toBe('bpmn:UserTask'); - const outerIds = (outer.flowElements ?? []).map((e) => e.id); - expect(outerIds).not.toContain('SF_IStart_Deep'); + expect(structureOf(tree)).toEqual([ + 'bpmn:StartEvent PStart', + 'bpmn:SubProcess Outer', + 'bpmn:EndEvent PEnd', + 'bpmn:SequenceFlow SF_PStart_Outer', + 'bpmn:SequenceFlow SF_Outer_PEnd', + ]); + expect(structureOf(outer)).toEqual([ + 'bpmn:StartEvent OStart', + 'bpmn:SubProcess Inner', + 'bpmn:EndEvent OEnd', + 'bpmn:SequenceFlow SF_OStart_Inner', + 'bpmn:SequenceFlow SF_Inner_OEnd', + ]); + expect(structureOf(inner)).toEqual([ + 'bpmn:StartEvent IStart', + 'bpmn:UserTask Deep', + 'bpmn:EndEvent IEnd', + 'bpmn:SequenceFlow SF_IStart_Deep', + 'bpmn:SequenceFlow SF_Deep_IEnd', + ]); }); }); describe('irToXml: DI expansion hint for sub-processes', () => { - // Fed DI-less XML holding a sub-process, `bpmn-auto-layout` renders it - // collapsed and scatters the children across the root plane, so `irToXml` - // pre-seeds a `bpmndi:BPMNShape isExpanded="true"` per sub-process. const twoChildrenIr: BpmnProcess = chained([ { kind: 'startEvent', id: 'PStart' }, chainedSub('sub', [ @@ -748,9 +735,7 @@ describe('irToXml: DI expansion hint for sub-processes', () => { it('lays every nested child strictly inside its parent, under one diagram', async () => { const xml = await irToXml(twoChildrenIr); - // The layout-generated diagram replaces the seeded stub rather than - // joining it. - expect((xml.match(/ { let call: Moddle; beforeAll(async () => { - // `irToXml` runs bpmn-auto-layout, so a throw here fails the suite and - // doubles as the "layout handles a call activity" check. callXml = await irToXml(richCallIr); const proc = await parseProcessTreeWithOperaton(callXml); call = childById(proc, 'CallSub'); }); - it('emits a bpmn:CallActivity carrying its name, calledElement and the deployment binding', () => { + it('emits a bpmn:CallActivity carrying its name and calledElement, re-read clean with the extension', async () => { expect(call.$type).toBe('bpmn:CallActivity'); expect(call.name).toBe('Call sub'); expect(call.calledElement).toBe('sub-process'); - expect(call.calledElementBinding).toBe('deployment'); - // A non-version binding never emits a version attribute. - expect(call.calledElementVersion).toBeUndefined(); + await expectNoModdleWarnings(callXml); }); it('emits the business key, then the in-mappings, then the out-mappings, each with its own attributes', () => { - // Whole-mapping equality rather than per-field checks: `local` has to be - // absent everywhere but the one mapping that sets it, and only an exact - // projection catches it appearing anywhere else. expect( (call.extensionElements?.values ?? []).map((v) => [ v.$type, - mappingAttrs(v), + pick(v, [ + 'businessKey', + 'variables', + 'source', + 'sourceExpression', + 'target', + 'local', + ]), ]), ).toEqual([ ['operaton:In', { businessKey: '${execution.processBusinessKey}' }], @@ -856,44 +841,40 @@ describe('irToXml: callActivity serialization', () => { ]); }); - it('wires the call activity with incoming/outgoing like any activity', () => { - // Assert on the parsed graph rather than the raw block: a call activity - // with self-closing `operaton:in` children defeats the string-scanning - // block extractor, but the wired references are unambiguous. - expect((call.incoming ?? []).map((f) => f.id)).toEqual(['F_Start_Call']); - expect((call.outgoing ?? []).map((f) => f.id)).toEqual(['F_Call_End']); - }); - - it('parses cleanly via bpmn-moddle carrying the extension', async () => { - await expectNoModdleWarnings(callXml); - }); - - it('emits both calledElementBinding and calledElementVersion for a version binding', async () => { + it.each<[string, VersionBinding | undefined, Record]>([ + [ + 'a deployment binding writes calledElementBinding alone', + { kind: 'deployment' }, + { calledElementBinding: 'deployment' }, + ], + [ + 'a version binding writes calledElementBinding and calledElementVersion', + { kind: 'version', version: '7' }, + { calledElementBinding: 'version', calledElementVersion: '7' }, + ], + [ + 'no binding writes neither, and no extensionElements wrapper', + undefined, + {}, + ], + ])('%s', async (_title, binding, expected) => { const ir = minimalCallIr({ - kind: 'callActivity', - id: 'CallSub', - calledElement: 'sub', - binding: { kind: 'version', version: '7' }, + ...callActivity('CallSub', 'sub'), + ...(binding === undefined ? {} : { binding }), }); const proc = await parseProcessTreeWithOperaton(await irToXml(ir)); - const c = childById(proc, 'CallSub'); - expect(c.calledElementBinding).toBe('version'); - expect(c.calledElementVersion).toBe('7'); - }); - - it('emits neither binding attribute nor an extensionElements wrapper for a minimal call', async () => { - const ir = minimalCallIr(callActivity('CallSub', 'sub')); - const proc = await parseProcessTreeWithOperaton(await irToXml(ir)); - const c = childById(proc, 'CallSub'); - expect(c.calledElementBinding).toBeUndefined(); - expect(c.calledElementVersion).toBeUndefined(); - expect(c.extensionElements).toBeUndefined(); + expect( + pick(childById(proc, 'CallSub'), [ + 'calledElementBinding', + 'calledElementVersion', + 'extensionElements', + ]), + ).toEqual(expected); }); it('derives a humanized name for an unnamed call activity', async () => { const ir = minimalCallIr(callActivity('ProcessPayment', 'sub')); const proc = await parseProcessTreeWithOperaton(await irToXml(ir)); - // The excluded-name path does not apply to activities: the id humanizes. expect(childById(proc, 'ProcessPayment').name).toBe('Process Payment'); }); @@ -901,35 +882,23 @@ describe('irToXml: callActivity serialization', () => { [ 'a class mapper writes operaton:variableMappingClass and no delegate attribute', { kind: 'class', className: 'com.acme.Mapper' } as const, - ['variableMappingClass', 'com.acme.Mapper'] as const, - 'variableMappingDelegateExpression' as const, + { variableMappingClass: 'com.acme.Mapper' }, ], [ 'a delegate mapper writes operaton:variableMappingDelegateExpression and no class attribute', { kind: 'delegateExpression', expression: '${mapperBean}' } as const, - ['variableMappingDelegateExpression', '${mapperBean}'] as const, - 'variableMappingClass' as const, + { variableMappingDelegateExpression: '${mapperBean}' }, ], - ])( - '%s', - async ( - _title: string, - mapper: CallVariableMapper, - [presentAttr, presentValue]: readonly [string, string], - absentAttr: string, - ) => { - const ir = minimalCallIr({ - kind: 'callActivity', - id: 'CallSub', - calledElement: 'sub', - mapper, - }); - const proc = await parseProcessTreeWithOperaton(await irToXml(ir)); - const c = childById(proc, 'CallSub'); - expect(c[presentAttr as keyof Moddle]).toBe(presentValue); - expect(c[absentAttr as keyof Moddle]).toBeUndefined(); - }, - ); + ])('%s', async (_title, mapper, expected) => { + const ir = minimalCallIr({ ...callActivity('CallSub', 'sub'), mapper }); + const proc = await parseProcessTreeWithOperaton(await irToXml(ir)); + expect( + pick(childById(proc, 'CallSub'), [ + 'variableMappingClass', + 'variableMappingDelegateExpression', + ]), + ).toEqual(expected); + }); }); describe('irToXml: event layer (errors + escalations)', () => { @@ -937,8 +906,7 @@ describe('irToXml: event layer (errors + escalations)', () => { * The whole error/escalation surface at once: a declared error message, an * interrupting error handler inside a sub-process, an `alongside` escalation * handler beside the main chain, and a throw and an emit of the same two - * codes. Handlers carry no incoming or outgoing flow: an event sub-process is - * triggered, not flow-connected. + * codes. */ const eventIr: BpmnProcess = { ...chained( @@ -1019,11 +987,6 @@ describe('irToXml: event layer (errors + escalations)', () => { expect(soleDef(emit).escalationRef?.id).toBe('Escalation_LS'); }); - it('orders rootElements as [process, ...errors, ...escalations]', () => { - const types = defs.rootElements.map((r) => r.$type); - expect(types).toEqual(['bpmn:Process', 'bpmn:Error', 'bpmn:Escalation']); - }); - it('emits a root per declaration, named by the declaration and keyed by its code, for codes nothing raises', async () => { const declaredIr: BpmnProcess = { ...minimalProcess( @@ -1089,17 +1052,6 @@ describe('irToXml: event layer (errors + escalations)', () => { expect(def.escalationCodeVariable).toBe('v'); }); - it('emits the escalation intermediate throw wired into the chain with incoming/outgoing', () => { - const emit = requireDeep(defs, 'Emit1'); - expect(emit.$type).toBe('bpmn:IntermediateThrowEvent'); - expect((emit.incoming ?? []).map((f) => f.id)).toEqual([ - 'SF_OuterSub_Emit1', - ]); - expect((emit.outgoing ?? []).map((f) => f.id)).toEqual([ - 'SF_Emit1_ThrowPF', - ]); - }); - it('catch-all handler emits no errorRef and no root when the code is unused elsewhere', async () => { const catchAllIr: BpmnProcess = minimalProcess( [ @@ -1118,71 +1070,9 @@ describe('irToXml: event layer (errors + escalations)', () => { expect(soleDef(requireDeep(d, 'AnyStart')).errorRef).toBeUndefined(); }); - it('shares one root across two handlers and a throw of the same code', async () => { - const handler = (id: string): FlowElement => - triggeredSub(id, [ - typedEvent('startEvent', `${id}_S`, errorDef('DUP')), - { kind: 'endEvent', id: `${id}_E` }, - ]); - const sharedIr: BpmnProcess = minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - typedEvent('endEvent', 'T', errorDef('DUP')), - handler('H1'), - handler('H2'), - ], - [{ id: 'SF_S_T', sourceRef: 'S', targetRef: 'T' }], - ); - const d = await parseDefinitionsWithOperaton(await irToXml(sharedIr)); - const errors = rootsOfType(d, 'bpmn:Error'); - expect(errors).toHaveLength(1); - expect(errors[0]!.id).toBe('Error_DUP'); - }); - - /** The one `bpmn:Error` root of `S -> [occupied ->] T`, where T throws `def`. */ - const soleErrorRoot = async ( - def: EndEventDefinition, - occupied?: string, - ): Promise => { - const thrown = typedEvent('endEvent', 'T', def); - const ir = - occupied === undefined - ? minimalProcess( - [{ kind: 'startEvent', id: 'S' }, thrown], - [edge('S', 'T', { id: 'SF_S_T' })], - ) - : minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - { kind: 'userTask', id: occupied }, - thrown, - ], - [ - edge('S', occupied, { id: 'SF_S_X' }), - edge(occupied, 'T', { id: 'SF_X_T' }), - ], - ); - const errors = rootsOfType(await defsOf(ir), 'bpmn:Error'); - expect(errors).toHaveLength(1); - return errors[0]!; - }; - - it('sanitizes a root id from a code with non-id characters, keeping the code verbatim', async () => { - const root = await soleErrorRoot(errorDef('NEEDS REVIEW!')); - expect(root.id).toBe('Error_NEEDS_REVIEW_'); - expect(root.errorCode).toBe('NEEDS REVIEW!'); - }); - - it('suffixes a root id that a flow element already holds', async () => { - expect((await soleErrorRoot(errorDef('Boom'), 'Error_Boom')).id).toBe( - 'Error_Boom_2', - ); - }); - it('lays event sub-processes out with children strictly inside their handler box', async () => { const xml = await irToXml(eventIr); - // Exactly one diagram: the layout-generated one replaces the stubs. - expect((xml.match(/ { }); }); +describe('irToXml: synthesized root ids', () => { + it.each< + [ + string, + EndEventDefinition, + string, + string | undefined, + Record, + ] + >([ + [ + 'an error code with non-id characters is sanitized into the id and kept verbatim as the code', + errorDef('NEEDS REVIEW!'), + 'bpmn:Error', + undefined, + { + id: 'Error_NEEDS_REVIEW_', + name: 'NEEDS REVIEW!', + errorCode: 'NEEDS REVIEW!', + }, + ], + [ + 'an error root id a task already holds is suffixed', + errorDef('Boom'), + 'bpmn:Error', + 'Error_Boom', + { id: 'Error_Boom_2', name: 'Boom', errorCode: 'Boom' }, + ], + [ + 'a signal root id a task already holds is suffixed', + signalDef('Ping'), + 'bpmn:Signal', + 'Signal_Ping', + { id: 'Signal_Ping_2', name: 'Ping' }, + ], + [ + 'a message name with non-id characters is sanitized into the id and kept verbatim as the name', + messageDef('Order received!'), + 'bpmn:Message', + undefined, + { id: 'Message_Order_received_', name: 'Order received!' }, + ], + ])('%s', async (_title, def, type, occupied, expected) => { + const occupant: FlowElement[] = + occupied === undefined ? [] : [{ kind: 'userTask', id: occupied }]; + const ir = chained([ + { kind: 'startEvent', id: 'S' }, + ...occupant, + typedEvent('endEvent', 'T', def), + ]); + expect( + rootsOfType(await defsOf(ir), type).map((r) => + pick(r, ['id', 'name', 'errorCode']), + ), + ).toEqual([expected]); + }); +}); + describe('irToXml: event layer (message + signal + timer + conditional)', () => { /** A message handler, an alongside signal handler, and both signal throws. */ const signalIr: BpmnProcess = processIr( @@ -1225,50 +1173,46 @@ describe('irToXml: event layer (message + signal + timer + conditional)', () => expect(def.messageRef?.id).toBe('Message_PaymentReceived'); }); - it.each([ + // The engine reads a thrown message's implementation off the definition and + // ignores the same attribute on the event, so the whole event block is + // frozen: the binding on the definition and nothing `operaton:` on the tag. + it.each< + [string, 'endEvent' | 'intermediateThrowEvent', ServiceTaskBinding, string] + >([ [ - 'a class binding', + 'a class binding on a thrown message', + 'endEvent', classBinding('com.example.Send'), - ['operaton:class="com.example.Send"'], + 'operaton:class="com.example.Send"', ], [ - 'an external binding', + 'an external binding on a thrown message', + 'endEvent', externalBinding('send-ack'), - ['operaton:type="external"', 'operaton:topic="send-ack"'], + 'operaton:type="external" operaton:topic="send-ack"', + ], + [ + 'a delegate binding on an emitted message', + 'intermediateThrowEvent', + delegateBinding('${senderBean}'), + 'operaton:delegateExpression="${senderBean}"', ], - ] as const)( - 'writes %s on a thrown message onto the definition, where the engine reads it', - async (_title, binding, expected) => { + ])( + 'writes %s onto the message definition', + async (_title, kind, binding, attribute) => { const xml = await irToXml( - minimalProcess( - [ - { kind: 'startEvent', id: 'S' }, - { ...typedEvent('endEvent', 'Sent', messageDef('Ack')), binding }, - ], - [{ id: 'F', sourceRef: 'S', targetRef: 'Sent' }], - ), + around({ ...typedEvent(kind, 'Sent', messageDef('Ack')), binding }), + ); + expect(extractNodeBlock(xml, 'Sent')).toBe( + `\n` + + ' F1\n' + + ' F2\n' + + ` \n` + + ` `, ); - const definition = /]*>/.exec(xml)![0]; - for (const attribute of expected) { - expect(definition).toContain(attribute); - } - // The engine ignores the same setting written on the event itself. - expect(/]*>/.exec(xml)![0]).not.toContain('operaton:'); }, ); - it('writes an emitted message implementation onto its definition too', async () => { - const xml = await irToXml( - around({ - ...typedEvent('intermediateThrowEvent', 'Ping', messageDef('Ack')), - binding: delegateBinding('${senderBean}'), - }), - ); - expect(/]*>/.exec(xml)![0]).toContain( - 'operaton:delegateExpression="${senderBean}"', - ); - }); - it('synthesizes one bpmn:Signal root shared by the handler, the emit, and the throw', () => { const signals = rootsOfType(defs, 'bpmn:Signal'); expect(signals).toHaveLength(1); @@ -1281,22 +1225,15 @@ describe('irToXml: event layer (message + signal + timer + conditional)', () => expect(handlerStart.$type).toBe('bpmn:SignalEventDefinition'); expect(emit.$type).toBe('bpmn:SignalEventDefinition'); expect(throwEnd.$type).toBe('bpmn:SignalEventDefinition'); - // All three resolve to the single shared root. expect(handlerStart.signalRef?.id).toBe('Signal_Cancelled'); expect(emit.signalRef?.id).toBe('Signal_Cancelled'); expect(throwEnd.signalRef?.id).toBe('Signal_Cancelled'); - // The handler is an `alongside` one, so its start does not interrupt. expect(requireDeep(defs, 'SigStart').isInterrupting).toBe(false); }); - it('orders rootElements as [process, ...messages, ...signals] with no error/escalation roots', () => { - const types = defs.rootElements.map((r) => r.$type); - expect(types).toEqual(['bpmn:Process', 'bpmn:Message', 'bpmn:Signal']); - }); - it('lays the message and signal handler bodies out inside their handler boxes', async () => { const xml = await irToXml(signalIr); - expect((xml.match(/ ]); }); - it('emits one FormalExpression child per timer kind with the verbatim body, and no roots', async () => { - const timerIr: BpmnProcess = processIr( - 'proc', - [ - { kind: 'startEvent', id: 'PStart' }, - { kind: 'endEvent', id: 'PEnd' }, - eventHandler('AfterH', 'AfterStart', timerDef('duration', 'PT1H')), - eventHandler('AtH', 'AtStart', timerDef('date', '${dueDate}')), - eventHandler('EveryH', 'EveryStart', timerDef('cycle', 'R/PT10M')), - eventHandler('CondH', 'CondStart', conditionDef('${amount > 100}')), - ], - [{ id: 'SF_PStart_PEnd', sourceRef: 'PStart', targetRef: 'PEnd' }], - ); - const d = await parseDefinitionsWithOperaton(await irToXml(timerIr)); - - const after = soleDef(requireDeep(d, 'AfterStart')); - expect(after.$type).toBe('bpmn:TimerEventDefinition'); - expect(after.timeDuration?.body).toBe('PT1H'); - expect(after.timeDate).toBeUndefined(); - expect(after.timeCycle).toBeUndefined(); - - const at = soleDef(requireDeep(d, 'AtStart')); - expect(at.timeDate?.body).toBe('${dueDate}'); - expect(at.timeDuration).toBeUndefined(); - - const every = soleDef(requireDeep(d, 'EveryStart')); - expect(every.timeCycle?.body).toBe('R/PT10M'); - expect(every.timeDuration).toBeUndefined(); - - const cond = soleDef(requireDeep(d, 'CondStart')); - expect(cond.$type).toBe('bpmn:ConditionalEventDefinition'); - expect(cond.condition?.body).toBe('${amount > 100}'); - - // Timer and conditional contribute nothing at bpmn:Definitions level. - expect(d.rootElements.map((r) => r.$type)).toEqual(['bpmn:Process']); - }); - - it('orders mixed roots [process, ...errors, ...escalations, ...messages, ...signals] and sanitizes the message name', async () => { + it('orders mixed roots [process, ...errors, ...escalations, ...messages, ...signals]', async () => { const mixedIr: BpmnProcess = processIr( 'proc', [ @@ -1355,7 +1255,7 @@ describe('irToXml: event layer (message + signal + timer + conditional)', () => typedEvent('intermediateThrowEvent', 'EmitEsc', escalationDef('LS')), typedEvent('intermediateThrowEvent', 'EmitSig', signalDef('Cancelled')), typedEvent('endEvent', 'ThrowErr', errorDef('PF')), - eventHandler('MsgHandler', 'MsgStart', messageDef('Order received!')), + eventHandler('MsgHandler', 'MsgStart', messageDef('OrderReceived')), ], [ { id: 'SF_S_EmitEsc', sourceRef: 'S', targetRef: 'EmitEsc' }, @@ -1366,32 +1266,11 @@ describe('irToXml: event layer (message + signal + timer + conditional)', () => const d = await parseDefinitionsWithOperaton(await irToXml(mixedIr)); expect(d.rootElements.map((r) => r.$type)).toEqual([ 'bpmn:Process', - 'bpmn:Error', - 'bpmn:Escalation', - 'bpmn:Message', - 'bpmn:Signal', - ]); - const message = rootsOfType(d, 'bpmn:Message')[0]!; - expect(message.id).toBe('Message_Order_received_'); - // The name is the DSL string verbatim (unsanitized). - expect(message.name).toBe('Order received!'); - }); - - it('suffixes a synthesized signal root id that collides with an existing element id', async () => { - // A user task literally named `Signal_Ping` occupies that id, so the root - // for signal `Ping` must move to `Signal_Ping_2`. - const d = await defsOf( - chained([ - { kind: 'startEvent', id: 'S' }, - { kind: 'userTask', id: 'Signal_Ping' }, - typedEvent('intermediateThrowEvent', 'Emit', signalDef('Ping')), - { kind: 'endEvent', id: 'E' }, - ]), - ); - const signals = rootsOfType(d, 'bpmn:Signal'); - expect(signals).toHaveLength(1); - expect(signals[0]!.id).toBe('Signal_Ping_2'); - expect(signals[0]!.name).toBe('Ping'); + 'bpmn:Error', + 'bpmn:Escalation', + 'bpmn:Message', + 'bpmn:Signal', + ]); }); }); @@ -1438,48 +1317,24 @@ describe('irToXml: event layer (compensation)', () => { defs = await parseDefinitionsWithOperaton(xml); }); - it('emits the handler start with a bare CompensateEventDefinition and no isInterrupting attribute', () => { - // Raw-XML assertion rather than the parsed moddle object: bpmn-moddle - // applies the schema default when reading `waitForCompletion` and - // `isInterrupting` back, so the parsed tree shows `true` either way. - const startBlock = extractNodeBlock(xml, 'CompStart'); - expect(startBlock).not.toContain('isInterrupting'); - expect(startBlock).toContain(''); - expect(startBlock).not.toContain('waitForCompletion'); - expect(startBlock).not.toContain('activityRef'); - }); - - it('carries exactly one compensate definition on the intermediate throw and the end event', () => { + it('emits a bare CompensateEventDefinition on the handler start, the emit and the throw, and no root', () => { + expect(extractNodeBlock(xml, 'CompStart')).toBe( + '\n' + + ' SF_CompStart_CompHandler_Work\n' + + ' \n' + + ' ', + ); expect(soleDef(requireDeep(defs, 'EmitComp')).$type).toBe( 'bpmn:CompensateEventDefinition', ); expect(soleDef(requireDeep(defs, 'ThrowComp')).$type).toBe( 'bpmn:CompensateEventDefinition', ); - }); - - it('synthesizes zero roots: rootElements contains only the process', () => { expect(defs.rootElements.map((r) => r.$type)).toEqual(['bpmn:Process']); }); - it('adds an error handler alongside compensation: one bpmn:Error root, still nothing for compensation, order unchanged', async () => { - const mixedIr: BpmnProcess = { - ...compensationIr, - flowElements: [ - ...compensationIr.flowElements, - eventHandler('ErrHandler', 'ErrStart', errorDef('PF')), - ], - }; - const d = await parseDefinitionsWithOperaton(await irToXml(mixedIr)); - expect(d.rootElements.map((r) => r.$type)).toEqual([ - 'bpmn:Process', - 'bpmn:Error', - ]); - expect(rootsOfType(d, 'bpmn:Error')).toHaveLength(1); - }); - it('lays the compensation handler out inside its host sub-process, children inside the handler', async () => { - expect((xml.match(/ { - it('emits a bpmn:BoundaryEvent attached to its host with no cancelActivity attribute when interrupting', async () => { - const xml = await irToXml(hostedBoundaryIr(messageDef('Ping'))); - const defs = await parseDefinitionsWithOperaton(xml); - const boundary = requireDeep(defs, 'Boundary_Host_x'); - expect(boundary.$type).toBe('bpmn:BoundaryEvent'); - expect(boundary.attachedToRef?.id).toBe('Host'); - // Raw-XML assertion: bpmn-moddle applies the schema default for - // `cancelActivity` when reading the attribute back, so a parsed check - // cannot distinguish "absent" from "explicitly true". - expect(extractNodeBlock(xml, 'Boundary_Host_x')).not.toContain( - 'cancelActivity', - ); - // A boundary event carries no humanized name: its id is synthesized, so a - // derived label would be noise in the diagram and churn in the goldens. - expect(boundary.name).toBeUndefined(); - // It is reached by being attached, never by a flow. - expect(boundary.incoming ?? []).toHaveLength(0); - expect((boundary.outgoing ?? []).map((flow) => flow.id)).toEqual([ - 'SF_Boundary_BoundaryEnd', - ]); - }); - - it('emits cancelActivity="false" for a non-interrupting (alongside) boundary', async () => { - const xml = await irToXml(hostedBoundaryIr(messageDef('Ping'), false)); - const defs = await parseDefinitionsWithOperaton(xml); - expect(requireDeep(defs, 'Boundary_Host_x').cancelActivity).toBe(false); - expect(extractNodeBlock(xml, 'Boundary_Host_x')).toContain( - 'cancelActivity="false"', - ); - }); - - it.each<[string, EventDefinition, string]>([ - ['error', errorDef('PF'), 'bpmn:ErrorEventDefinition'], - ['escalation', escalationDef('LS'), 'bpmn:EscalationEventDefinition'], - ['message', messageDef('Ping'), 'bpmn:MessageEventDefinition'], - ['signal', signalDef('Cancelled'), 'bpmn:SignalEventDefinition'], - ['timer', timerDef('duration', 'PT1H'), 'bpmn:TimerEventDefinition'], + it.each<[string, false | undefined, string]>([ + [ + 'an interrupting boundary writes no cancelActivity', + undefined, + '', + ], [ - 'conditional', - conditionDef('${amount > 100}'), - 'bpmn:ConditionalEventDefinition', + 'a non-interrupting (alongside) boundary writes cancelActivity="false"', + false, + '', ], ])( - 'carries the %s event definition child', - async (_label, def, expectedType) => { - const defs = await defsOf(hostedBoundaryIr(def)); - expect(soleDef(requireDeep(defs, 'Boundary_Host_x')).$type).toBe( - expectedType, + '%s, attached to its host, with no name, no incoming and its definition', + async (_title, cancelActivity, openingTag) => { + const xml = await irToXml( + hostedBoundaryIr(messageDef('Ping'), cancelActivity), + ); + expect(extractNodeBlock(xml, 'Boundary_Host_x')).toBe( + `${openingTag}\n` + + ' SF_Boundary_BoundaryEnd\n' + + ' \n' + + ' ', ); }, ); @@ -1606,42 +1435,8 @@ describe('irToXml: boundary events', () => { } }); - it('serializes a boundary event on a sub-process host with exactly one bpmndi:BPMNDiagram', async () => { - const ir: BpmnProcess = processIr( - 'proc', - [ - { kind: 'startEvent', id: 'PStart' }, - chainedSub('HostSub', [ - { kind: 'startEvent', id: 'SubStart' }, - { kind: 'userTask', id: 'SubWork' }, - { kind: 'endEvent', id: 'SubEnd' }, - ]), - { kind: 'endEvent', id: 'PEnd' }, - boundaryEvent( - 'Boundary_HostSub_escalation', - 'HostSub', - escalationDef('LS'), - ), - { kind: 'endEvent', id: 'EscEnd' }, - ], - [ - { id: 'SF_PStart_HostSub', sourceRef: 'PStart', targetRef: 'HostSub' }, - { id: 'SF_HostSub_PEnd', sourceRef: 'HostSub', targetRef: 'PEnd' }, - edge('Boundary_HostSub_escalation', 'EscEnd', { - id: 'SF_Boundary_EscEnd', - }), - ], - ); - const xml = await irToXml(ir); - expect((xml.match(/ { `BoundaryEvent "Boundary_Host_x" is attached to "${host}", which is not a flow element of this container.`, ); }); - - it('shares one root across a message caught by a boundary event and by a host-less handler', async () => { - const ir: BpmnProcess = processIr( - 'proc', - [ - { kind: 'startEvent', id: 'PStart' }, - { kind: 'userTask', id: 'Host' }, - { kind: 'endEvent', id: 'PEnd' }, - boundaryEvent('Boundary_Host_message', 'Host', messageDef('Shared')), - { kind: 'endEvent', id: 'BoundaryEnd' }, - eventHandler('MsgHandler', 'MsgStart', messageDef('Shared')), - ], - [ - { id: 'SF_PStart_Host', sourceRef: 'PStart', targetRef: 'Host' }, - { id: 'SF_Host_PEnd', sourceRef: 'Host', targetRef: 'PEnd' }, - edge('Boundary_Host_message', 'BoundaryEnd', { - id: 'SF_Boundary_BoundaryEnd', - }), - ], - ); - const defs = await parseDefinitionsWithOperaton(await irToXml(ir)); - const messages = rootsOfType(defs, 'bpmn:Message'); - expect(messages).toHaveLength(1); - expect( - soleDef(requireDeep(defs, 'Boundary_Host_message')).messageRef?.id, - ).toBe(messages[0]!.id); - expect(soleDef(requireDeep(defs, 'MsgStart')).messageRef?.id).toBe( - messages[0]!.id, - ); - }); }); /** {@link hostedBoundaryIr} with the attachment pointing at an absent node. */ @@ -1730,27 +1495,19 @@ function mainFlowCatchIr( } describe('irToXml: intermediate catch events', () => { - it('emits a bpmn:IntermediateCatchEvent with a MessageEventDefinition referencing a derived Message root, wired with incoming and outgoing', async () => { - const ir = mainFlowCatchIr(messageDef('Invoice Received')); - const defs = await parseDefinitionsWithOperaton(await irToXml(ir)); - const catchNode = requireDeep(defs, 'Catch_x'); - expect(catchNode.$type).toBe('bpmn:IntermediateCatchEvent'); - - const def = soleDef(catchNode); - expect(def.$type).toBe('bpmn:MessageEventDefinition'); - - const messages = rootsOfType(defs, 'bpmn:Message'); - expect(messages).toHaveLength(1); - expect(messages[0]!.id).toBe('Message_Invoice_Received'); - expect(messages[0]!.name).toBe('Invoice Received'); - expect(def.messageRef?.id).toBe(messages[0]!.id); - - expect((catchNode.incoming ?? []).map((f) => f.id)).toEqual([ - 'SF_PStart_Catch', - ]); - expect((catchNode.outgoing ?? []).map((f) => f.id)).toEqual([ - 'SF_Catch_PEnd', - ]); + it('emits a nameless bpmn:IntermediateCatchEvent wired with incoming and outgoing, its MessageEventDefinition referencing a derived Message root', async () => { + const xml = await irToXml(mainFlowCatchIr(messageDef('Invoice Received'))); + expect(extractNodeBlock(xml, 'Catch_x')).toBe( + '\n' + + ' SF_PStart_Catch\n' + + ' SF_Catch_PEnd\n' + + ' \n' + + ' ', + ); + const defs = await parseDefinitionsWithOperaton(xml); + expect( + rootsOfType(defs, 'bpmn:Message').map((r) => pick(r, ['id', 'name'])), + ).toEqual([{ id: 'Message_Invoice_Received', name: 'Invoice Received' }]); }); it.each< @@ -1796,11 +1553,6 @@ describe('irToXml: intermediate catch events', () => { expect(def.$type).toBe('bpmn:ConditionalEventDefinition'); expect(def.condition?.body).toBe('${amount > 100}'); }); - - it('stamps no name attribute on the catch element: the await surface carries no label slot', async () => { - const defs = await defsOf(mainFlowCatchIr(messageDef('Ping'))); - expect(requireDeep(defs, 'Catch_x').name).toBeUndefined(); - }); }); describe('irToXml: link events', () => { @@ -1874,10 +1626,7 @@ describe('irToXml: link events', () => { }); }); -/** - * Parse a document with the Operaton extension registered and return one flow - * node of its process, with the Operaton settings resolved as typed properties. - */ +/** One flow node of the process, Operaton settings resolved as typed properties. */ async function engineNode(xmlStr: string, id: string): Promise { const proc = await parseProcessTreeWithOperaton(xmlStr); return childById(proc, id); @@ -1942,9 +1691,6 @@ describe('irToXml: flat engine attributes', () => { engineProc = await parseProcessTreeWithOperaton(engineXml); }); - /** One flow node of the engine-settings process, with its Operaton props. */ - const node = (id: string): Moddle => childById(engineProc, id); - it('writes the whole set of Operaton attributes the process IR carries, and nothing else', () => { expect({ versionTag: engineProc.versionTag, @@ -1962,14 +1708,6 @@ describe('irToXml: flat engine attributes', () => { expect(engineProc.$attrs).toEqual({}); }); - it('writes the initiator on the start event, and nothing undeclared beside it', () => { - const start = node('Start'); - expect({ initiator: start.initiator, $attrs: start.$attrs }).toEqual({ - initiator: 'claimant', - $attrs: {}, - }); - }); - it('a process authoring no historyTimeToLive still writes the exported default', async () => { const xml = await irToXml({ id: 'no-history', @@ -1981,93 +1719,48 @@ describe('irToXml: flat engine attributes', () => { expect(proc.historyTimeToLive).toBe(HISTORY_TIME_TO_LIVE); }); - it('writes the async continuation settings the IR carries, and nothing else', () => { - const start = extractNodeBlock(engineXml, 'Start'); - expect(start).toContain('operaton:asyncAfter="true"'); - expect(start).toContain('operaton:jobPriority="50"'); - // Omitted in the IR, so absent from the XML: the engine default applies. - expect(start).not.toContain('operaton:asyncBefore'); - expect(start).not.toContain('operaton:exclusive'); - - const review = extractNodeBlock(engineXml, 'Review'); - expect(review).toContain('operaton:asyncBefore="true"'); - expect(review).toContain('operaton:exclusive="false"'); - expect(review).not.toContain('operaton:asyncAfter'); - }); - - it('writes every user-task assignment attribute under the operaton prefix', () => { - const review = extractNodeBlock(engineXml, 'Review'); - expect(review).toContain('operaton:assignee="demo"'); - expect(review).toContain( - 'operaton:formKey="embedded:app:forms/review.html"', - ); - expect(review).toContain('operaton:candidateUsers="ann,bob"'); - expect(review).toContain('operaton:candidateGroups="reviewers"'); - expect(review).toContain('operaton:followUpDate="2026-01-31T12:00:00"'); - expect(review).toContain('operaton:priority="75"'); - expect(review).toMatch( - /operaton:dueDate="\$\{dateTime\(\)\.plusDays\(2\)\}"/, - ); - }); - - it('writes operaton:resultVariable on both the service task and the script task', () => { - expect(extractNodeBlock(engineXml, 'Auto')).toContain( - 'operaton:resultVariable="outcome"', - ); - expect(extractNodeBlock(engineXml, 'Calc')).toContain( - 'operaton:resultVariable="total"', - ); - expect(node('Auto').resultVariable).toBe('outcome'); - expect(node('Calc').resultVariable).toBe('total'); - }); - - it('emits the retry cycle as an extension element, never as an attribute', () => { - expect(engineXml).toMatch( - /\s*R3\/PT10M\s*<\/operaton:failedJobRetryTimeCycle>/, - ); - expect(engineXml).not.toMatch(/failedJobRetryTimeCycle="/); - expect(node('Calc').extensionElements?.values).toEqual([ - expect.objectContaining({ - $type: 'operaton:FailedJobRetryTimeCycle', - body: 'R3/PT10M', - }), - ]); - }); - - it("keeps a user task's form data and its retry cycle under one wrapper", () => { - expect( - node('Review').extensionElements?.values.map((v) => v.$type), - ).toEqual(['operaton:FormData', 'operaton:FailedJobRetryTimeCycle']); - }); - - it('emits no extensionElements wrapper for a node that contributes no children', () => { - expect(node('End').extensionElements).toBeUndefined(); - expect(node('Start').extensionElements).toBeUndefined(); - expect(node('Auto').extensionElements).toBeUndefined(); - // Exactly two wrappers in the whole document: the user task and the script - // task. Every other node contributes nothing and so opens none. - expect(engineXml.match(/ { - const callXml = await irToXml( - minimalCallIr({ - kind: 'callActivity', - id: 'CallSub', - calledElement: 'sub-process', - businessKey: '${execution.processBusinessKey}', - inMappings: [{ kind: 'variable', source: 'amount', target: 'amount' }], - outMappings: [{ kind: 'all' }], - retryCycle: 'R5/PT1M', - }), + it("writes each node's settings as its own operaton: attributes and extension children, the retry cycle as a child, and nothing on a node carrying none", () => { + const blocks = Object.fromEntries( + ['Start', 'Review', 'Auto', 'Calc', 'End'].map((id) => [ + id, + extractNodeBlock(engineXml, id), + ]), ); - const call = await engineNode(callXml, 'CallSub'); - expect(call.extensionElements?.values.map((v) => v.$type)).toEqual([ - 'operaton:In', - 'operaton:In', - 'operaton:Out', - 'operaton:FailedJobRetryTimeCycle', - ]); + expect(blocks).toEqual({ + Start: + '\n' + + ' F1\n' + + ' ', + Review: + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' R3/PT5M\n' + + ' \n' + + ' F1\n' + + ' F2\n' + + ' ', + Auto: + '\n' + + ' F2\n' + + ' F3\n' + + ' ', + Calc: + '\n' + + ' \n' + + ' R3/PT10M\n' + + ' \n' + + ' F3\n' + + ' F4\n' + + ' total = 1;\n' + + ' ', + End: + '\n' + + ' F4\n' + + ' ', + }); }); it('carries the settings on the structural kinds too: a sub-process and a boundary event', async () => { @@ -2106,14 +1799,29 @@ describe('irToXml: flat engine attributes', () => { }), ], }); - expect(extractNodeBlock(nestedXml, 'Sub')).toContain( - 'operaton:asyncBefore="true"', - ); - expect(nestedXml).toMatch( - /\s*R2\/PT30S\s*<\/operaton:failedJobRetryTimeCycle>/, - ); - expect(extractNodeBlock(nestedXml, 'Boundary_Sub_timer')).toContain( - 'operaton:asyncAfter="true"', + expect(extractNodeBlock(nestedXml, 'Sub')).toBe( + '\n' + + ' \n' + + ' R2/PT30S\n' + + ' \n' + + ' SF_PStart_Sub\n' + + ' SF_Sub_PEnd\n' + + ' \n' + + ' SF_SubStart_SubEnd\n' + + ' \n' + + ' \n' + + ' SF_SubStart_SubEnd\n' + + ' \n' + + ' \n' + + ' ', + ); + expect(extractNodeBlock(nestedXml, 'Boundary_Sub_timer')).toBe( + '\n' + + ' SF_Boundary_BoundaryEnd\n' + + ' \n' + + ' PT1H\n' + + ' \n' + + ' ', ); }); @@ -2150,9 +1858,8 @@ describe('irToXml: flat engine attributes', () => { expect(bareBlock).not.toContain('operaton:'); expect(bareBlock).not.toContain('extensionElements'); - // No carrier for a listener on a gateway (ADR 0010: a synthesized - // gateway has no textual identity to author one against), so the cast - // supplies the shape the type forbids to observe the guard working. + // The type forbids a listener on a gateway (no carrier to author one + // against, ADR 0010), so the cast supplies the shape to see the guard. const listenersXml = await irToXml( around({ kind, @@ -2283,10 +1990,6 @@ describe("irToXml: a timer job's lock is written where BpmnParse.parseTimer read const timer = timerDef('duration', 'PT1H'); - // The tag keeps the attribute for the async continuation job, which - // `parseAsynchronousContinuation` reads there; the definition gains it for - // the timer job. Revert: drop the definition attribute and the third column - // finds the tag alone. it.each<[string, FlowElement, number]>([ [ 'a non-exclusive boundary timer writes it on the definition and the tag', @@ -2335,15 +2038,10 @@ describe('irToXml: input/output parameters', () => { ].map((m) => m[1]), ).toEqual(['plain', 'scripted', 'nested', 'result']); - // A text value is body text and no child element. expect(parameterContent(nestedGroupsBlock, 'plain').trim()).toBe('hello'); - - // A script value is an operaton:script child carrying its format. expect(parameterContent(nestedGroupsBlock, 'scripted')).toMatch( /^\s*\s*a \+ b\s*<\/operaton:script>\s*$/, ); - - // A list of a text and a map, with a list nested inside that map. expect(parameterContent(nestedGroupsBlock, 'nested')).toMatch( new RegExp( [ @@ -2361,8 +2059,6 @@ describe('irToXml: input/output parameters', () => { ].join('\\s*'), ), ); - - // A map value is operaton:entry children keyed by their IR key. expect(parameterContent(nestedGroupsBlock, 'result')).toMatch( /^\s*\s*\s*200\s*<\/operaton:entry>\s*<\/operaton:map>\s*$/, ); @@ -2371,9 +2067,8 @@ describe('irToXml: input/output parameters', () => { describe('irToXml: listeners', () => { it('writes every binding form with its attributes unprefixed on the namespaced element', () => { - // The element itself is `operaton:`-qualified, so its own attributes carry - // no prefix. A prefixed one here parses as a foreign attribute the engine - // ignores, which is why this asserts on the serialized text. + // A prefixed attribute here would be one the engine ignores, and the + // parsed tree would report the property either way, so this reads the text. for (const listener of listenerTags(nestedGroupsBlock)) { expect(listener).not.toMatch(/\soperaton:/); } @@ -2389,17 +2084,12 @@ describe('irToXml: listeners', () => { expect(nestedGroupsBlock).toMatch( /\s*\s*log\(1\);\s*<\/operaton:script>\s*<\/operaton:executionListener>/, ); - - // A timeout task listener also carries its timer as a bpmn child. expect(nestedGroupsBlock).toMatch( /]*>\s*\s*]*>\s*PT2H\s*<\/bpmn:timeDuration>\s*<\/bpmn:timerEventDefinition>\s*<\/operaton:taskListener>/, ); }); it('every timeout listener gets its own id, stepping around an id the document already holds', async () => { - // `BpmnParse.parseTimeoutTaskListener` refuses a timeout listener with no - // id, and `TaskDefinition.addTimeoutTaskListener` keys the listeners by - // it, so two on one task need two. The second base is taken by a task. const timeout = (className: string, duration: string) => ({ event: 'timeout' as const, binding: classBinding(className), @@ -2566,7 +2256,9 @@ describe('irToXml: user task formRef', () => { }), ); const node = await engineNode(xml, 'Task'); - expect(formRefAttrs(node)).toEqual(expected); + expect(pick(node, ['formRef', 'formRefBinding', 'formRefVersion'])).toEqual( + expected, + ); }); }); @@ -2699,7 +2391,7 @@ describe('irToXml: extension-element assembly order', () => { }); }); -/** One of each new kind, wired `Start -> Step -> Wait -> Notify -> Rate -> End`. */ +/** One of each task kind, wired `Start -> Step -> Wait -> Notify -> Rate -> End`. */ const taskKindsIr: BpmnProcess = chained([ { kind: 'startEvent', id: 'Start' }, { kind: 'task', id: 'Step' }, @@ -2738,63 +2430,13 @@ describe('irToXml: task kinds', () => { expect(taskKindsXml).toContain(''); }); - it('points a receive task at the bpmn:Message root synthesized from its name', () => { + it('points a receive task at the bpmn:Message root synthesized from its name, and re-reads clean', async () => { const messages = rootsOfType(defs, 'bpmn:Message'); expect(messages).toHaveLength(1); expect(messages[0]!.name).toBe('OrderPaid'); const wait = requireDeep(defs, 'Wait'); expect(wait.$type).toBe('bpmn:ReceiveTask'); expect(wait.messageRef?.id).toBe('Message_OrderPaid'); - }); - - it.each([ - ['send', ' { - const xml = await irToXml( - around({ - kind: 'serviceTask', - id: 'T', - element, - binding: classBinding('com.example.Run'), - }), - ); - const block = extractNodeBlock(xml, 'T'); - expect(block).toContain(tag); - expect(block).toContain('operaton:class="com.example.Run"'); - }, - ); - - it('emits a decision binding as four DMN attributes on a bpmn:businessRuleTask', () => { - const rate = extractNodeBlock(taskKindsXml, 'Rate'); - expect(rate).toContain(' { - const xml = await irToXml( - around({ - kind: 'serviceTask', - id: 'Rate', - element: 'businessRule', - binding: { kind: 'decision', decisionRef: 'riskRating' }, - }), - ); - expect(extractNodeBlock(xml, 'Rate')).toBe( - '\n' + - ' F1\n' + - ' F2\n' + - ' ', - ); - }); - - it('re-reads through the Operaton descriptor with no moddle warnings', async () => { await expectNoModdleWarnings(taskKindsXml); }); @@ -2836,6 +2478,10 @@ const OVER_LINES: LoopCharacteristics = { elementVariable: 'line', }; +/** What {@link OVER_LINES} serializes to when the loop carries nothing else. */ +const OVER_LINES_TAG = + ''; + /** One repeated element of every kind that can carry a loop, wired head to tail. */ const repeatedKindsIr: BpmnProcess = chained([ { kind: 'startEvent', id: 'Start' }, @@ -2897,81 +2543,51 @@ describe('irToXml: multi-instance loop characteristics', () => { ['Fulfil', ' { - const node = extractNodeBlock(repeatedXml, id); - expect(node).toContain(tag); - expect(node).toContain(' { await expectNoModdleWarnings(repeatedXml); }); - it('writes a collection and its element variable, and no isSequential', async () => { - const xml = await irToXml( - around({ kind: 'userTask', id: 'Approve', loop: OVER_LINES }), - ); - const node = extractNodeBlock(xml, 'Approve'); - expect(node).toContain('operaton:collection="lines"'); - expect(node).toContain('operaton:elementVariable="line"'); - expect(node).not.toContain('isSequential'); - }); - - it('writes isSequential only for a sequential loop', async () => { - const xml = await irToXml( - around({ - kind: 'userTask', - id: 'Approve', - loop: { ...OVER_LINES, sequential: true }, - }), - ); - expect(extractNodeBlock(xml, 'Approve')).toContain('isSequential="true"'); - }); - - it.each([['3'], ['${n}']])( - 'writes the cardinality %s as the loopCardinality body', - async (cardinality) => { - const xml = await irToXml( - around({ kind: 'userTask', id: 'Approve', loop: { cardinality } }), - ); - expect(extractNodeBlock(xml, 'Approve')).toMatch( - new RegExp( - `]*>${cardinality.replace(/[${}]/g, '\\$&')}`, - ), - ); - }, - ); - - it('writes no loop child when neither a count nor a collection is set', async () => { - const xml = await irToXml( - around({ - kind: 'userTask', - id: 'Approve', - loop: { sequential: true, completionCondition: '${done}' }, - }), - ); - expect(extractNodeBlock(xml, 'Approve')).not.toContain( - 'multiInstanceLoopCharacteristics', - ); - }); - - it('writes the completion condition body verbatim', async () => { + it.each<[string, LoopCharacteristics, string | undefined]>([ + [ + 'a sequential loop writes isSequential before the collection', + { ...OVER_LINES, sequential: true }, + '', + ], + [ + 'a literal count is the loopCardinality body', + { cardinality: '3' }, + '\n' + + ' 3\n' + + ' ', + ], + [ + 'an expression count is the loopCardinality body', + { cardinality: '${n}' }, + '\n' + + ' ${n}\n' + + ' ', + ], + [ + 'a completion condition is the completionCondition body, escaped by the writer', + { ...OVER_LINES, completionCondition: '${nrOfCompletedInstances >= 2}' }, + '\n' + + ' ${nrOfCompletedInstances >= 2}\n' + + ' ', + ], + [ + 'neither a count nor a collection writes no loop child', + { sequential: true, completionCondition: '${done}' }, + undefined, + ], + ])('%s', async (_title, loop, expected) => { const xml = await irToXml( - around({ - kind: 'userTask', - id: 'Approve', - loop: { - ...OVER_LINES, - completionCondition: '${nrOfCompletedInstances >= 2}', - }, - }), - ); - const decoded = extractNodeBlock(xml, 'Approve').replace( - /(>|>)/g, - '>', - ); - expect(decoded).toMatch( - /]*>\$\{nrOfCompletedInstances >= 2\}<\/bpmn:completionCondition>/, + around({ kind: 'userTask', id: 'Approve', loop }), ); + expect(loopBlock(xml, 'Approve')).toBe(expected); }); it("a repetition carrying per-run settings writes them on the loop element, beside the step's own", async () => { @@ -3010,8 +2626,6 @@ describe('irToXml: multi-instance loop characteristics', () => { ' ', ); - // No run setting on this loop, so no `operaton:` attribute and no child - // extension wrapper: the run keys are opt-in, never a default write. const bareXml = await irToXml( around({ kind: 'userTask', id: 'Approve', loop: OVER_LINES }), ); @@ -3019,7 +2633,7 @@ describe('irToXml: multi-instance loop characteristics', () => { '\n' + ' F1\n' + ' F2\n' + - ' \n' + + ` ${OVER_LINES_TAG}\n` + ' ', ); }); @@ -3067,7 +2681,7 @@ describe('irToXml: blocks that can be given up', () => { giveUpXml = await irToXml(giveUpIr('transaction')); }); - it('writes the block under bpmn:transaction and a plain one under bpmn:subProcess, children alike', async () => { + it('writes the block under bpmn:transaction and a plain one under bpmn:subProcess, children alike, re-read clean', async () => { const transaction = childById(await parseProcessTree(giveUpXml), 'Book'); const plain = childById( await parseProcessTree(await irToXml(giveUpIr())), @@ -3078,6 +2692,7 @@ describe('irToXml: blocks that can be given up', () => { expect(giveUpXml).toContain(' { @@ -3090,10 +2705,6 @@ describe('irToXml: blocks that can be given up', () => { expect(boundary.attachedToRef?.id).toBe('Book'); }); - it('re-reads through the Operaton descriptor with no moddle warnings', async () => { - await expectNoModdleWarnings(giveUpXml); - }); - it('lays every child of the block out inside the block, nested block included', async () => { const shapes = await parseDiShapesById(giveUpXml); expectInside(shapes, 'Book', ['TxStart', 'Charge', 'Settle', 'GiveUp']); @@ -3112,44 +2723,40 @@ describe('irToXml: blocks that can be given up', () => { inputParameters: [ioParam('seed', textValue('1'))], loop: { collection: 'lines', elementVariable: 'line' }, }; - const node = extractNodeBlock(await irToXml(repeated), 'Book'); - expect(node).toContain(''); - expect(node).toContain('', + ); + expect(extensionBlock(node)).toBe( + '\n' + + ' \n' + + ' 1\n' + + ' \n' + + ' ', + ); + expect(loopBlock(xml, 'Book')).toBe( + '', + ); }); }); // ── Helpers ────────────────────────────────────────────────────────────────── /** - * Re-read a document with the Operaton extension registered and assert it came - * back clean. Registering the extension is the stricter read: an `operaton:` - * name the descriptor does not declare warns instead of falling into `$attrs`. + * Re-read with the Operaton extension registered, the stricter read: an + * `operaton:` name the descriptor does not declare draws a warning. */ async function expectNoModdleWarnings(xmlStr: string): Promise { - const { warnings } = await operatonModdle().fromXML(xmlStr); + const { warnings } = await createModdle().fromXML(xmlStr); expect(warnings).toEqual([]); } -/** Every attribute an `operaton:in`/`operaton:out` mapping can carry, if set. */ -function mappingAttrs(mapping: Moddle): Record { - const keys = [ - 'businessKey', - 'variables', - 'source', - 'sourceExpression', - 'target', - 'local', - ] as const; - return Object.fromEntries( - keys.filter((k) => mapping[k] !== undefined).map((k) => [k, mapping[k]]), - ); -} - -/** Every `formRef*` property a parsed user task carries, if set. */ -function formRefAttrs(node: Moddle): Record { - const keys = ['formRef', 'formRefBinding', 'formRefVersion'] as const; +/** The listed properties of a parsed node, the `undefined` ones left out. */ +function pick( + node: Moddle, + keys: readonly (keyof Moddle)[], +): Record { return Object.fromEntries( keys.filter((k) => node[k] !== undefined).map((k) => [k, node[k]]), ); @@ -3174,6 +2781,11 @@ function degreeOf(xmlStr: string, id: string): { in: number; out: number } { }; } +/** How many `bpmndi:BPMNDiagram` blocks: one, since the layouter's diagram replaces the seeded expansion stub. */ +function diagramCount(xmlStr: string): number { + return (xmlStr.match(/` block of a document, as raw serialized * text. Assertions on namespace prefixes have to read the text: the parsed @@ -3210,15 +2822,10 @@ function listenerTags(block: string): string[] { ); } -/** - * Parse a BPMN XML string with the Operaton extension registered and return the - * `bpmn:Definitions` root, so both the semantic tree (`rootElements`, nested - * `flowElements`) and the Operaton-namespaced event attributes resolve to typed - * properties. - */ +/** The `bpmn:Definitions` root, Operaton settings resolved as typed properties. */ async function parseDefinitionsWithOperaton(xmlStr: string): Promise { - const { rootElement } = await operatonModdle().fromXML(xmlStr); - return rootElement; + const { rootElement } = await createModdle().fromXML(xmlStr); + return rootElement as unknown as Moddle; } /** Serialize an IR and parse the definitions back, Operaton registered. */ @@ -3273,24 +2880,9 @@ function minimalCallIr(call: BpmnProcess['flowElements'][number]): BpmnProcess { ) satisfies BpmnProcess; } -/** The Operaton moddle extension descriptor, read from source (as `irToXml` does). */ -const OPERATON_EXTENSION: Record = JSON.parse( - readFileSync(resolve(here, '../src/operaton-moddle.json'), 'utf-8'), -); - -/** A raw `BpmnModdle` carrying the Operaton extension, for reading operaton:* nodes. */ -function operatonModdle(): InstanceType { - return new BpmnModdle({ operaton: OPERATON_EXTENSION }); -} - -/** - * Like {@link parseProcessTree}, but with the Operaton extension registered so - * `operaton:in`/`operaton:out` children and `operaton:calledElement*` - * attributes resolve to typed properties rather than raw XML. - */ +/** The root `bpmn:Process`, Operaton settings resolved as typed properties. */ async function parseProcessTreeWithOperaton(xmlStr: string): Promise { - const { rootElement } = await operatonModdle().fromXML(xmlStr); - return processOf(rootElement); + return processOf(await parseDefinitionsWithOperaton(xmlStr)); } /** A DI shape's bounds, as parsed from `dc:Bounds`. */ @@ -3307,12 +2899,7 @@ interface DiShape { bounds: DiBounds; } -/** - * Parse a BPMN XML string's `bpmndi:BPMNDiagram` and return every - * `bpmndi:BPMNShape` it contains, keyed by the id of the BPMN element it - * represents (`shape.bpmnElement`). Used to assert on the generated layout, - * unlike {@link parseProcessTree}, which inspects the semantic element tree. - */ +/** Every `bpmndi:BPMNShape` of the diagram, keyed by the id of the element it lays out. */ async function parseDiShapesById( xmlStr: string, ): Promise> { @@ -3381,10 +2968,8 @@ function boundsStrictlyInside(inner: DiBounds, outer: DiBounds): boolean { } /** - * A parsed moddle node: the `bpmn:Definitions` root, a flow node, an event - * definition, or an extension-element child. One façade over the untyped - * moddle graph, so a test navigates references and reads Operaton-namespaced - * settings as typed properties instead of casting at every hop. + * One loose type over every parsed moddle node, so a test reads references and + * Operaton settings as typed properties instead of casting at every hop. */ interface Moddle { $type: string; @@ -3420,28 +3005,14 @@ interface Moddle { escalationCode?: string; escalationCodeVariable?: string; isInterrupting?: boolean; - cancelActivity?: boolean; triggeredByEvent?: boolean; versionTag?: string; historyTimeToLive?: string; candidateStarterUsers?: string; candidateStarterGroups?: string; - initiator?: string; - asyncBefore?: boolean; - asyncAfter?: boolean; - exclusive?: boolean; - jobPriority?: string; - assignee?: string; - formKey?: string; formRef?: string; formRefBinding?: string; formRefVersion?: string; - candidateUsers?: string; - candidateGroups?: string; - dueDate?: string; - followUpDate?: string; - priority?: string; - resultVariable?: string; calledElement?: string; calledElementBinding?: string; calledElementVersion?: string; @@ -3465,11 +3036,7 @@ function processOf(rootElement: unknown): Moddle { return proc; } -/** - * Parse a BPMN XML string with raw `bpmn-moddle` and return the root - * `bpmn:Process` element as a navigable tree. Used to inspect the semantic - * element structure (nesting, references) without asserting on DI shapes. - */ +/** The root `bpmn:Process` as raw `bpmn-moddle` reads it, no extension registered. */ async function parseProcessTree(xmlStr: string): Promise { const { rootElement } = await new BpmnModdle({}).fromXML(xmlStr); return processOf(rootElement); @@ -3485,51 +3052,41 @@ function childById(container: Moddle, id: string): Moddle { } /** - * The serialized text of one flow node, from its opening tag to its closing tag - * or self-close, so a child count never picks up a sibling's. Found by scanning - * for `id=""` and walking out to the tag boundaries, which holds for - * the formatted output the writer produces. + * The serialized text of one flow node, found by its `id` attribute. Reading + * the text is how a test sees an absent attribute: on read, moddle fills the + * schema default (`isInterrupting`, `cancelActivity`, `textFormat`) either way. */ function extractNodeBlock(xml: string, nodeId: string): string { - // Find the start of the tag that carries `id=""`. - const idAttr = `id="${nodeId}"`; - const idPos = xml.indexOf(idAttr); + const idPos = xml.indexOf(`id="${nodeId}"`); if (idPos === -1) { throw new Error(`Node id="${nodeId}" not found in XML output.`); } + return elementAt(xml, xml.lastIndexOf('<', idPos)); +} - // Walk backwards to find the opening `<` of the tag. - let tagStart = idPos; - while (tagStart > 0 && xml[tagStart] !== '<') { - tagStart--; - } - - // Determine the element name (e.g. `bpmn:startEvent`). - const tagNameMatch = xml.slice(tagStart + 1).match(/^([^\s/>]+)/); - if (!tagNameMatch) { - throw new Error( - `Could not determine element name at position ${tagStart}.`, - ); - } - const tagName = tagNameMatch[1]!; +/** The `bpmn:multiInstanceLoopCharacteristics` child of one flow node, or `undefined` for none. */ +function loopBlock(xml: string, nodeId: string): string | undefined { + const node = extractNodeBlock(xml, nodeId); + const start = node.indexOf('` of - // its own opening tag, never by scanning ahead: a repeated activity nests a - // self-closing `multiInstanceLoopCharacteristics` before its own close tag, - // which a bare "first `/>` after tagStart" scan would mistake for its own. +/** The element whose opening tag starts at `tagStart`, through its close tag or self-close. */ +function elementAt(xml: string, tagStart: number): string { + const tagName = /^<([^\s/>]+)/.exec(xml.slice(tagStart))?.[1]; const openTagEnd = xml.indexOf('>', tagStart); - if (openTagEnd === -1) { - throw new Error(`Unterminated opening tag for id="${nodeId}".`); + if (tagName === undefined || openTagEnd === -1) { + throw new Error(`No element opens at position ${tagStart}.`); } + // Decided from the opening tag alone: scanning ahead for the first `/>` + // would stop at a self-closing child, such as a repeated activity's loop. if (xml[openTagEnd - 1] === '/') { return xml.slice(tagStart, openTagEnd + 1); } - const closeTagStr = ``; - const closeTagPos = xml.indexOf(closeTagStr, openTagEnd); + const closeTag = ``; + const closeTagPos = xml.indexOf(closeTag, openTagEnd); if (closeTagPos === -1) { - throw new Error( - `Could not find end of element "${tagName}" with id="${nodeId}".`, - ); + throw new Error(`Unterminated <${tagName}> at position ${tagStart}.`); } - return xml.slice(tagStart, closeTagPos + closeTagStr.length); + return xml.slice(tagStart, closeTagPos + closeTag.length); } diff --git a/packages/transform/test/juel.test.ts b/packages/transform/test/juel.test.ts index b354c53..aa1d5c5 100644 --- a/packages/transform/test/juel.test.ts +++ b/packages/transform/test/juel.test.ts @@ -1,24 +1,9 @@ /** - * Unit tests for the standalone JUEL-subset expression parser / serializer - * (`src/juel.ts`). - * - * This module is the import-side mirror of the language package's - * expression sub-grammar. On import, `xmlToIr` reads raw `${...}` - * bodies out of BPMN XML and hands them to `parseJuel`; `irToDsl` - * then renders the result back to DSL surface syntax. The contract: - * - * - A body inside the JUEL subset -> a structured result, rendered as a - * bare unquoted expression (`amount > 1000`). - * - A body outside the subset (method/bean calls, JUEL functions, or anything - * unparseable) -> a `{ kind: 'raw' }` result, rendered as the quoted - * `"${...}"` or `"#{...}"` fallback with the opener it came with and the - * string escapes the grammar reads back. `parseJuel` never throws. - * - * The subset boundary is fixed by the grammar; the final two suites - * (`idempotence with the grammar renderer` and `subset parity with the real - * grammar`) cross-check that this hand-rolled parser accepts/rejects exactly - * what the Langium grammar does, so a divergence cannot silently break - * round-trip. + * The JUEL-subset parser and serializer in `src/juel.ts`: a body inside the + * subset is structured and prints bare, anything else is raw and prints as + * the quoted template with the opener it came with, and `parseJuel` never + * throws. The last two suites cross-check the hand-rolled parser against the + * real Langium grammar, so the two cannot drift apart. */ import { beforeAll, describe, expect, it } from 'vitest'; @@ -34,13 +19,11 @@ import type { Expr, Model } from '@bpmn-script/language'; import { parseJuel, renderRawFallback } from '../src/juel.js'; -// --------------------------------------------------------------------------- -// Structured-vs-raw classification -// --------------------------------------------------------------------------- - describe('parseJuel: structured classification', () => { it.each([ ['${amount > 1000}', 'amount > 1000'], + // Operaton accepts either delimiter; a structured body prints bare either way. + ['#{lineCount}', 'lineCount'], ['${order.total}', 'order.total'], ['${items[0]}', 'items[0]'], // String literals canonicalize to double quotes (matches renderExpression). @@ -73,46 +56,19 @@ describe('parseJuel: structured classification', () => { }); }); -describe('parseJuel: raw fallback classification', () => { - it('renders a raw result as the quoted "${...}" fallback', () => { - const r = parseJuel('${myBean.check()}'); - expect(renderRawFallback(r)).toBe('"${myBean.check()}"'); - }); - +describe('parseJuel: raw classification keeps the verbatim body and its opener', () => { it.each([ - '${execution.getVariable("x")}', - '${obj.method().chained()}', - '${a.b.c()}', - '${size(list)}', - '${fn:size(list)}', - '${ns:fn(x, y)}', - ])('classifies %s as raw', (body) => { - expect(parseJuel(body).kind).toBe('raw'); - }); - - it('preserves the verbatim inner body on a raw result', () => { - const r = parseJuel('${myBean.check()}'); - expect(r.kind).toBe('raw'); - if (r.kind === 'raw') { - expect(r.text).toBe('myBean.check()'); - } - }); -}); - -// --------------------------------------------------------------------------- -// The `#{...}` spelling: Operaton accepts either delimiter. A structured body -// prints bare and rebuilds inside `${`; a raw one keeps the opener it came with. -// --------------------------------------------------------------------------- - -describe('parseJuel: the #{...} delimiter', () => { - it('classifies an in-subset #{...} body as structured and renders it bare', () => { - const result = parseJuel('#{lineCount}'); - expect(result.kind).toBe('structured'); - expect(renderRawFallback(result)).toBe('lineCount'); - }); - - it('tags a wrapperless body as $ even when its text starts with #', () => { - expect(parseJuel('#foo')).toEqual({ kind: 'raw', text: '#foo', open: '$' }); + ['${execution.getVariable("x")}', 'execution.getVariable("x")', '$'], + ['${obj.method().chained()}', 'obj.method().chained()', '$'], + ['${a.b.c()}', 'a.b.c()', '$'], + ['${size(list)}', 'size(list)', '$'], + ['${fn:size(list)}', 'fn:size(list)', '$'], + ['${ns:fn(x, y)}', 'ns:fn(x, y)', '$'], + ['#{myBean.check()}', 'myBean.check()', '#'], + // A wrapperless body is tagged $ even when its text starts with #. + ['#foo', '#foo', '$'], + ])('%s', (body, text, open) => { + expect(parseJuel(body)).toEqual({ kind: 'raw', text, open }); }); }); @@ -164,11 +120,7 @@ describe('a string literal prints with the escapes the grammar reads back to the }); }); -// --------------------------------------------------------------------------- -// Totality: parseJuel must never throw, malformed input always yields raw -// --------------------------------------------------------------------------- - -describe('parseJuel: totality (never throws)', () => { +describe('parseJuel: a malformed body is raw rather than a throw', () => { const malformed = [ '${', '${}', @@ -196,28 +148,11 @@ describe('parseJuel: totality (never throws)', () => { '${a[0}', ]; - it.each(malformed)('never throws on %j', (body) => { - expect(() => parseJuel(body)).not.toThrow(); - }); - it.each(malformed)('yields a raw result on %j', (body) => { expect(parseJuel(body).kind).toBe('raw'); }); - - it('renderRawFallback never throws on malformed input', () => { - for (const body of malformed) { - const r = parseJuel(body); - expect(() => renderRawFallback(r)).not.toThrow(); - } - }); }); -// --------------------------------------------------------------------------- -// Cross-check infrastructure: parse a body through the REAL Langium grammar by -// wrapping it in `if () { }`. Returns the condition Expr if it parses to -// a NON-raw subset node, undefined if it does not parse or parses to a RawExpr. -// --------------------------------------------------------------------------- - let parse: ReturnType>; beforeAll(() => { @@ -226,12 +161,10 @@ beforeAll(() => { }); /** - * Classify a `${...}` body via the real grammar: returns 'structured' if the - * inner body parses cleanly to a non-RawExpr condition node, else 'raw'. + * The real grammar's verdict on a `${...}` body, read off an `if` condition, + * the one statement position where a bare expression appears. */ async function grammarClassify(body: string): Promise<'structured' | 'raw'> { - // Strip the ${...} wrapper to get the inner expression, then embed it as an - // `if` condition: the only statement position where a bare Expr appears. const inner = body.replace(/^\$\{/, '').replace(/\}$/, ''); const doc = await parse(`process P { if (${inner}) { } }`); if (doc.parseResult.parserErrors.length > 0) { @@ -246,13 +179,6 @@ async function grammarClassify(body: string): Promise<'structured' | 'raw'> { return cond.$type === 'RawExpr' ? 'raw' : 'structured'; } -// --------------------------------------------------------------------------- -// Idempotence with the grammar renderer: a structured AST -> renderExpression -> -// parseJuel must round-trip back to the same surface form (shared canonical -// notion). We drive the structured AST from the real grammar so the inputs are -// guaranteed to be in-subset. -// --------------------------------------------------------------------------- - describe('idempotence with the grammar renderExpression', () => { const structuredInputs = [ 'amount > 1000', @@ -266,10 +192,8 @@ describe('idempotence with the grammar renderExpression', () => { '!done', '-balance', 'order.items[0].price', - // String literals: verifies the grammar renderer (expression-render.ts) and - // the transform renderer (juel.ts) agree on the canonical quoted form, - // including re-escaping an embedded double quote and an embedded backslash. - // `greeting` avoids the `label` keyword. + // Both renderers must agree on the quoted form, an embedded quote and + // backslash included. `greeting` avoids the `label` keyword. 'x == "hello"', 'greeting == "say \\"hi\\""', 'x == "a\\\\b"', @@ -284,33 +208,19 @@ describe('idempotence with the grammar renderExpression', () => { expect(stmt.$type).toBe('IfStatement'); const cond = (stmt as { condition: Expr }).condition; - // Grammar's canonical ${...} body for this AST. const canonical = renderExpression(cond); - - // parseJuel must accept the grammar's canonical body as structured and - // render the same bare surface form that renderExpression's inner emits. const result = parseJuel(canonical); expect(result.kind).toBe('structured'); - // Idempotent: parsing the parser's own surface output yields the same - // surface output again. const surface = renderRawFallback(result); const reparsed = parseJuel(`\${${surface}}`); expect(reparsed.kind).toBe('structured'); expect(renderRawFallback(reparsed)).toBe(surface); - - // And the parser's surface matches the grammar renderer's canonical body - // (both share the same canonical-form notion). expect(`\${${surface}}`).toBe(canonical); }, ); }); -// --------------------------------------------------------------------------- -// Subset-parity cross-check: parseJuel and the real grammar must classify the -// same representative input list identically (structured vs raw). -// --------------------------------------------------------------------------- - describe('subset parity with the real grammar', () => { const cases = [ // structured diff --git a/packages/transform/test/operaton-moddle.test.ts b/packages/transform/test/operaton-moddle.test.ts index 727ef9d..c8779d9 100644 --- a/packages/transform/test/operaton-moddle.test.ts +++ b/packages/transform/test/operaton-moddle.test.ts @@ -1,21 +1,12 @@ /** - * Pins the behavior of `operaton-moddle.json` directly against a real - * `BpmnModdle` instance, with no `xmlToIr`/`irToXml` involved. Three traps - * this descriptor exists to close: - * - * - `extensionElements` splits its children three ways: a declared type - * becomes a typed entry in `values`, an unregistered namespace becomes a - * generic value with no warning, and a registered namespace carrying an - * undeclared type is dropped with only a document-level warning and no - * back-pointer to the element that lost it. A type has to be declared - * here before it can be attributed to the step that carries it. - * - `InputParameter`/`OutputParameter`/`Entry` declare their nested value - * as an `isMany` `definitions` array. A single-valued property would - * silently keep only the last of two co-present nested values, with zero - * warnings and no way for a reader to detect the loss. - * - `default` on `exclusive`/`asyncBefore`/`asyncAfter` makes moddle omit - * those attributes on write when they hold the default value, matching - * the IR convention of storing only non-default booleans. + * Pins `operaton-moddle.json` against a real `BpmnModdle`, with no + * `xmlToIr`/`irToXml` involved. Three moddle behaviours the descriptor exists + * to close: `extensionElements` drops a registered-namespace child of an + * undeclared type with only a document-level warning and no back-pointer to + * the element that lost it; a single-valued nested-value property would keep + * only the last of two co-present values, with no warning; and `default` on a + * boolean attribute makes moddle omit it on write at the default value, which + * is the IR's own convention for the job settings. */ import { describe, expect, it } from 'vitest'; @@ -43,11 +34,9 @@ const XML_HEADER = ` targetNamespace="http://test">`; /** - * Parse a full document and return its single `bpmn:Process` root element, - * alongside the `bpmn:Definitions` root that holds it. A re-serialize has to - * start from the definitions root: `toXML` writes its own XML declaration, so - * serializing the process alone and pasting the result under a header would put - * a declaration in the middle of the document. + * The single `bpmn:Process` with the `bpmn:Definitions` root that holds it; a + * re-serialize starts from the root, since `toXML` writes its own XML + * declaration. */ async function parseProcess( moddle: InstanceType, @@ -75,8 +64,20 @@ function extensionValues(el: ModdleElement): ModdleElement[] { : (extensionElements.get('values') as ModdleElement[]); } -describe('every new type parses and re-serializes with nested values intact', () => { - const fixture = `${XML_HEADER} +const elementsOf = (process: ModdleElement): ModdleElement[] => + process.get('flowElements') as ModdleElement[]; + +const definitionsOf = (parameter: ModdleElement): ModdleElement[] => + parameter.get('definitions') as ModdleElement[]; + +/** The `operaton:inputParameter` children of the process's first element. */ +function inputParameters(process: ModdleElement): ModdleElement[] { + const [io] = extensionValues(elementsOf(process)[0]); + return io.get('inputParameters') as ModdleElement[]; +} + +describe('every declared type parses with zero warnings, every child at its expected path, and round-trips byte for byte', () => { + const EVERY_TYPE_FIXTURE = `${XML_HEADER} @@ -120,20 +121,8 @@ describe('every new type parses and re-serializes with nested values intact', () `; - /** Read every value this test cares about out of a parsed process tree. */ - function snapshot(process: ModdleElement): unknown { - const start = (process.get('flowElements') as ModdleElement[]).find( - (e) => e.id === 'Start', - )!; - const review = (process.get('flowElements') as ModdleElement[]).find( - (e) => e.id === 'Review', - )!; - const compute = (process.get('flowElements') as ModdleElement[]).find( - (e) => e.id === 'Compute', - )!; - const notify = (process.get('flowElements') as ModdleElement[]).find( - (e) => e.id === 'Notify', - )!; + function everyTypeSnapshot(process: ModdleElement): unknown { + const [start, review, compute, notify] = elementsOf(process); const [io, listener, failedJob] = extensionValues(review); const param = (io.get('inputParameters') as ModdleElement[])[0]; @@ -196,7 +185,7 @@ describe('every new type parses and re-serializes with nested values intact', () }; } - const expected = { + const EVERY_TYPE_EXPECTED = { versionTag: '1.2.3', candidateStarterUsers: 'demo,manager', candidateStarterGroups: 'adjusters', @@ -242,27 +231,7 @@ describe('every new type parses and re-serializes with nested values intact', () }, }; - it('parses with zero warnings and every nested value at its expected path', async () => { - const { process, warnings } = await parseProcess(operatonModdle(), fixture); - expect(warnings).toHaveLength(0); - expect(snapshot(process)).toEqual(expected); - }); - - it('round-trips through toXML unchanged', async () => { - const first = operatonModdle(); - const { definitions } = await parseProcess(first, fixture); - const { xml } = await first.toXML(definitions, { format: false }); - - const second = operatonModdle(); - const { process: reparsed, warnings } = await parseProcess(second, xml); - expect(warnings).toHaveLength(0); - expect(snapshot(reparsed)).toEqual(expected); - }); -}); - -describe('InputOutputParameterDefinition is isMany, not a single-valued definition', () => { - it('a parameter carrying a list and a map keeps both, in document order', async () => { - const fixture = `${XML_HEADER} + const LIST_AND_MAP_FIXTURE = `${XML_HEADER} @@ -274,27 +243,7 @@ describe('InputOutputParameterDefinition is isMany, not a single-valued definiti `; - const { process, warnings } = await parseProcess(operatonModdle(), fixture); - expect(warnings).toHaveLength(0); - - const task = (process.get('flowElements') as ModdleElement[])[0]; - const [io] = extensionValues(task); - const param = (io.get('inputParameters') as ModdleElement[])[0]; - const definitions = param.get('definitions') as ModdleElement[]; - - // The trap: a single-valued `definition` property would keep only the - // last of these two (the map), with zero warnings and no signal that - // the list was ever there. - expect(definitions.map((d) => d.$type)).toEqual([ - 'operaton:List', - 'operaton:Map', - ]); - }); -}); - -describe('operaton:entry is accepted wherever a definition is accepted', () => { - it('parses warning-free inside a list and directly under an input parameter', async () => { - const fixture = `${XML_HEADER} + const ENTRY_FIXTURE = `${XML_HEADER} @@ -307,38 +256,7 @@ describe('operaton:entry is accepted wherever a definition is accepted', () => { `; - const { process, warnings } = await parseProcess(operatonModdle(), fixture); - expect(warnings).toHaveLength(0); - - const task = (process.get('flowElements') as ModdleElement[])[0]; - const [io] = extensionValues(task); - const [inList, bare] = io.get('inputParameters') as ModdleElement[]; - - // `Entry` subclasses `InputOutputParameterDefinition`, so it satisfies both - // `List.items` and `InputParameter.definitions`. The descriptor accepts a - // stray entry outside a map; rejecting that shape is the importer's job, - // and this pins that the tolerance is where the rejection can see it. - const inListEntry = ( - (inList.get('definitions') as ModdleElement[])[0].get( - 'items', - ) as ModdleElement[] - )[0]; - expect([inListEntry.$type, inListEntry.get('key')]).toEqual([ - 'operaton:Entry', - 'a', - ]); - - const bareEntry = (bare.get('definitions') as ModdleElement[])[0]; - expect([bareEntry.$type, bareEntry.get('key')]).toEqual([ - 'operaton:Entry', - 'b', - ]); - }); -}); - -describe('a parameter carrying both body text and a nested definition keeps both', () => { - it('yields both `value` and a `definitions` entry', async () => { - const fixture = `${XML_HEADER} + const TEXT_AND_SCRIPT_FIXTURE = `${XML_HEADER} @@ -350,103 +268,6 @@ describe('a parameter carrying both body text and a nested definition keeps both `; - const { process, warnings } = await parseProcess(operatonModdle(), fixture); - expect(warnings).toHaveLength(0); - - const task = (process.get('flowElements') as ModdleElement[])[0]; - const [io] = extensionValues(task); - const param = (io.get('inputParameters') as ModdleElement[])[0]; - - // A reader must check both forms rather than returning on the first hit. - expect(param.get('value')).toBe('text'); - const definitions = param.get('definitions') as ModdleElement[]; - expect(definitions).toHaveLength(1); - expect(definitions[0].$type).toBe('operaton:Script'); - }); -}); - -describe('per-element attribution of a dropped extension child', () => { - it('a declared child materializes, an undeclared one produces exactly one warning naming it', async () => { - const fixture = `${XML_HEADER} - - - - R3/PT10M - - - - -`; - - const { process, warnings } = await parseProcess(operatonModdle(), fixture); - - const task = (process.get('flowElements') as ModdleElement[])[0]; - const values = extensionValues(task); - - // The declared element survives as a typed value; the undeclared one is - // dropped from `values` entirely (no back-pointer to `task`). The surviving - // child is one this descriptor introduces, so the assertion measures the - // declaration rather than what `bpmn-moddle` already knew. - expect(values.map((v) => v.$type)).toEqual([ - 'operaton:FailedJobRetryTimeCycle', - ]); - - // Never infer a per-element drop from a document-level boolean: assert - // the exact warning count and that its message names the dropped type. - expect(warnings).toHaveLength(1); - expect(warnings[0].message).toContain('operaton:formProperty'); - }); -}); - -describe('a potential starter declares the formal expression BpmnParse.parseStartAuthorization reads off it', () => { - it('parses the resourceAssignmentExpression as a typed bpmn child and writes it back', async () => { - const fixture = `${XML_HEADER} - - - - - user(a), group(g) - - - - -`; - - const moddle = operatonModdle(); - const { definitions, process, warnings } = await parseProcess( - moddle, - fixture, - ); - expect(warnings).toHaveLength(0); - - // Revert: drop the PotentialStarter type, and the element is dropped - // from `values` with one document-level warning naming it. - const [starter] = extensionValues(process); - const rae = starter.get('resourceAssignmentExpression') as ModdleElement; - expect([ - starter.$type, - rae.$type, - rae.get('expression').get('body'), - ]).toEqual([ - 'operaton:PotentialStarter', - 'bpmn:ResourceAssignmentExpression', - 'user(a), group(g)', - ]); - - // moddle writes the subtype through `xsi:type`, which - // `Element.element("formalExpression")` does not find by tag, which is - // why the import respells the starter into the two attributes rather - // than writing it back. - const { xml } = await moddle.toXML(definitions, { format: false }); - expect(xml).toContain( - '' + - 'user(a), group(g)' + - '', - ); - }); -}); - -describe('the form field extras and the external task extras parse and re-serialize', () => { const FORM_FIELD_FIXTURE = `${XML_HEADER} @@ -472,8 +293,7 @@ describe('the form field extras and the external task extras parse and re-serial /** Every child of the form field, at the path each direction reads it from. */ function formFieldSnapshot(process: ModdleElement): unknown { - const task = (process.get('flowElements') as ModdleElement[])[0]; - const [formData] = extensionValues(task); + const [formData] = extensionValues(elementsOf(process)[0]); const [field] = formData.get('fields') as ModdleElement[]; const properties = field.get('properties') as ModdleElement; const [property] = properties.get('values') as ModdleElement[]; @@ -512,7 +332,7 @@ describe('the form field extras and the external task extras parse and re-serial /** Every child `xmlToIr` reads, at the path it reads it from. */ function externalTaskSnapshot(process: ModdleElement): unknown { - const task = (process.get('flowElements') as ModdleElement[])[0]; + const task = elementsOf(process)[0]; const [propertiesEl, definitionEl] = extensionValues(task); const property = (propertiesEl.get('values') as ModdleElement[])[0]; const errorRoot = definitionEl.get('errorRef') as ModdleElement; @@ -530,7 +350,83 @@ describe('the form field extras and the external task extras parse and re-serial }; } + const LOOP_SETTINGS_FIXTURE = `${XML_HEADER} + + + + + R3/PT10M + + 3 + + + +`; + + const TIMEOUT_LISTENER_FIXTURE = `${XML_HEADER} + + + + + + PT1H + + + + + +`; + it.each([ + [ + 'every declared type, nested values included', + EVERY_TYPE_FIXTURE, + everyTypeSnapshot, + EVERY_TYPE_EXPECTED, + ], + [ + // A single-valued `definition` property would keep only the map. + 'InputOutputParameterDefinition is isMany: a parameter carrying a list and a map keeps both, in document order', + LIST_AND_MAP_FIXTURE, + (process: ModdleElement) => + definitionsOf(inputParameters(process)[0]).map((d) => d.$type), + ['operaton:List', 'operaton:Map'], + ], + [ + // `Entry` subclasses `InputOutputParameterDefinition`, so it satisfies + // both `List.items` and `InputParameter.definitions`; refusing a stray + // entry outside a map is the importer's job, so the descriptor keeps it + // where the importer can see it. + 'operaton:entry is accepted inside a list and directly under an input parameter', + ENTRY_FIXTURE, + (process: ModdleElement) => { + const [inList, bare] = inputParameters(process); + const listed = ( + definitionsOf(inList)[0].get('items') as ModdleElement[] + )[0]; + const direct = definitionsOf(bare)[0]; + return [ + [listed.$type, listed.get('key')], + [direct.$type, direct.get('key')], + ]; + }, + [ + ['operaton:Entry', 'a'], + ['operaton:Entry', 'b'], + ], + ], + [ + 'a parameter carrying body text beside a nested definition keeps both, so a reader must check both forms', + TEXT_AND_SCRIPT_FIXTURE, + (process: ModdleElement) => { + const [param] = inputParameters(process); + return { + value: param.get('value'), + definitions: definitionsOf(param).map((d) => d.$type), + }; + }, + { value: 'text', definitions: ['operaton:Script'] }, + ], [ 'a full formField declares datePattern, properties, validation and values', FORM_FIELD_FIXTURE, @@ -561,73 +457,108 @@ describe('the form field extras and the external task extras parse and re-serial errorRoot: { type: 'bpmn:Error', errorCode: 'DECLINED' }, }, ], - ])( - '%s: parses with zero warnings, every child at its expected path, and round-trips byte for byte', - async (_title, fixture, snapshot, expected) => { - const first = operatonModdle(); - const { definitions, process, warnings } = await parseProcess( - first, - fixture, - ); - expect(warnings).toHaveLength(0); - expect(snapshot(process)).toEqual(expected); - - const { xml } = await first.toXML(definitions, { format: false }); - const second = operatonModdle(); - const { - definitions: reparsedDefs, - process: reparsed, - warnings: warnings2, - } = await parseProcess(second, xml); - expect(warnings2).toHaveLength(0); - expect(snapshot(reparsed)).toEqual(expected); - - const { xml: xml2 } = await second.toXML(reparsedDefs, { format: false }); - expect(xml2).toBe(xml); - }, - ); + [ + // Drop the `extends` entry for the loop element and these typed reads + // answer `undefined`, the raw attributes sitting in `$attrs` instead. + 'AsyncCapable extends the multi-instance element, so a repetition carries per-run job settings and its retry child', + LOOP_SETTINGS_FIXTURE, + (process: ModdleElement) => { + const loop = elementsOf(process)[0].get( + 'loopCharacteristics', + ) as ModdleElement; + const [retryCycle] = extensionValues(loop); + return { + asyncBefore: loop.get('asyncBefore'), + asyncAfter: loop.get('asyncAfter'), + exclusive: loop.get('exclusive'), + retryCycle: retryCycle.get('body'), + }; + }, + { + asyncBefore: true, + asyncAfter: true, + exclusive: false, + retryCycle: 'R3/PT10M', + }, + ], + [ + 'a timeout task listener parses its bpmn:timerEventDefinition child into eventDefinitions', + TIMEOUT_LISTENER_FIXTURE, + (process: ModdleElement) => { + const [listener] = extensionValues(elementsOf(process)[0]); + const [definition] = listener.get( + 'eventDefinitions', + ) as ModdleElement[]; + return { + type: definition.$type, + timeDuration: (definition.get('timeDuration') as ModdleElement).body, + }; + }, + { type: 'bpmn:TimerEventDefinition', timeDuration: 'PT1H' }, + ], + ])('%s', async (_title, fixture, snapshot, expected) => { + const first = operatonModdle(); + const { definitions, process, warnings } = await parseProcess( + first, + fixture, + ); + expect(warnings).toHaveLength(0); + expect(snapshot(process)).toEqual(expected); + + const { xml } = await first.toXML(definitions, { format: false }); + const second = operatonModdle(); + const { + definitions: reparsedDefs, + process: reparsed, + warnings: warnings2, + } = await parseProcess(second, xml); + expect(warnings2).toHaveLength(0); + expect(snapshot(reparsed)).toEqual(expected); + + const { xml: xml2 } = await second.toXML(reparsedDefs, { format: false }); + expect(xml2).toBe(xml); + }); }); -describe('AsyncCapable defaults are omitted on write', () => { - it('serializes only the settings written away from their engine default', async () => { - const moddle = operatonModdle(); - const atDefault = await moddle.toXML( - moddle.create('bpmn:ServiceTask', { - id: 'Task1', - 'operaton:exclusive': true, - 'operaton:asyncBefore': false, - }), - ); - expect(atDefault.xml).not.toContain('operaton:exclusive'); - expect(atDefault.xml).not.toContain('operaton:asyncBefore'); +describe('per-element attribution of a dropped extension child', () => { + it('a declared child materializes, an undeclared one produces exactly one warning naming it', async () => { + const fixture = `${XML_HEADER} + + + + R3/PT10M + + + + +`; - const offDefault = await moddle.toXML( - moddle.create('bpmn:ServiceTask', { - id: 'Task2', - 'operaton:exclusive': false, - 'operaton:jobPriority': '30', - }), + const { process, warnings } = await parseProcess(operatonModdle(), fixture); + + // The surviving child is one this descriptor introduces, so the assertion + // measures the declaration rather than what `bpmn-moddle` already knew. + expect(extensionValues(elementsOf(process)[0]).map((v) => v.$type)).toEqual( + ['operaton:FailedJobRetryTimeCycle'], ); - expect(offDefault.xml).toContain('operaton:exclusive="false"'); - expect(offDefault.xml).toContain('operaton:jobPriority="30"'); + expect(warnings).toHaveLength(1); + expect(warnings[0].message).toContain('operaton:formProperty'); }); }); -describe('AsyncCapable extends the multi-instance element, so a repetition can carry per-run job settings', () => { - const fixture = `${XML_HEADER} +describe('a potential starter declares the formal expression BpmnParse.parseStartAuthorization reads off it', () => { + it('parses the resourceAssignmentExpression as a typed bpmn child and writes it back', async () => { + const fixture = `${XML_HEADER} - - - - R3/PT10M - - 3 - - + + + + user(a), group(g) + + + `; - it('parses the four settings and the retry child as typed values and writes them back onto the loop tag', async () => { const moddle = operatonModdle(); const { definitions, process, warnings } = await parseProcess( moddle, @@ -635,59 +566,52 @@ describe('AsyncCapable extends the multi-instance element, so a repetition can c ); expect(warnings).toHaveLength(0); - const task = (process.get('flowElements') as ModdleElement[])[0]; - const loop = task.get('loopCharacteristics') as ModdleElement; - const [retryCycle] = extensionValues(loop); - - // Revert: drop the `extends` entry for the loop element and these typed - // reads see no declared property on it at all, so `get` answers - // `undefined` rather than the schema default or the parsed value. - expect(loop.get('asyncBefore')).toBe(true); - expect(loop.get('asyncAfter')).toBe(true); - expect(loop.get('exclusive')).toBe(false); - expect(retryCycle.get('body')).toBe('R3/PT10M'); + const [starter] = extensionValues(process); + const rae = starter.get('resourceAssignmentExpression') as ModdleElement; + expect([ + starter.$type, + rae.$type, + rae.get('expression').get('body'), + ]).toEqual([ + 'operaton:PotentialStarter', + 'bpmn:ResourceAssignmentExpression', + 'user(a), group(g)', + ]); + // moddle writes the subtype through `xsi:type`, which the engine's + // `Element.element("formalExpression")` does not find by tag; that is why + // the import respells the starter into the two attributes. const { xml } = await moddle.toXML(definitions, { format: false }); expect(xml).toContain( - '', + '' + + 'user(a), group(g)' + + '', ); }); }); -describe('TaskListener timer event definition', () => { - it('a bpmn:timerEventDefinition child parses into eventDefinitions and re-serializes', async () => { - const fixture = `${XML_HEADER} - - - - - - PT1H - - - - - -`; - - const first = operatonModdle(); - const { process, warnings } = await parseProcess(first, fixture); - expect(warnings).toHaveLength(0); +describe('AsyncCapable defaults are omitted on write', () => { + it('serializes only the settings written away from their engine default', async () => { + const moddle = operatonModdle(); + const atDefault = await moddle.toXML( + moddle.create('bpmn:ServiceTask', { + id: 'Task1', + 'operaton:exclusive': true, + 'operaton:asyncBefore': false, + }), + ); + expect(atDefault.xml).not.toContain('operaton:exclusive'); + expect(atDefault.xml).not.toContain('operaton:asyncBefore'); - const task = (process.get('flowElements') as ModdleElement[])[0]; - const [listener] = extensionValues(task); - const eventDefinitions = listener.get( - 'eventDefinitions', - ) as ModdleElement[]; - expect(eventDefinitions).toHaveLength(1); - expect(eventDefinitions[0].$type).toBe('bpmn:TimerEventDefinition'); - expect( - (eventDefinitions[0].get('timeDuration') as ModdleElement).body, - ).toBe('PT1H'); - - const { xml } = await first.toXML(process, { format: false }); - expect(xml).toContain('timerEventDefinition'); - expect(xml).toContain('PT1H'); + const offDefault = await moddle.toXML( + moddle.create('bpmn:ServiceTask', { + id: 'Task2', + 'operaton:exclusive': false, + 'operaton:jobPriority': '30', + }), + ); + expect(offDefault.xml).toContain('operaton:exclusive="false"'); + expect(offDefault.xml).toContain('operaton:jobPriority="30"'); }); }); @@ -715,11 +639,11 @@ describe('a timer definition declares the lock BpmnParse.parseTimer reads off it ); expect(warnings).toHaveLength(0); - const [start, wait] = process.get('flowElements') as ModdleElement[]; + const [start, wait] = elementsOf(process); const definitionOf = (el: ModdleElement): ModdleElement => (el.get('eventDefinitions') as ModdleElement[])[0]; - // Revert: drop the type extending the definition and `get` answers - // `undefined` for both, the raw attribute sitting in `$attrs` instead. + // Drop the type extending the definition and `get` answers `undefined` + // for both, the raw attribute sitting in `$attrs` instead. expect([ definitionOf(start).get('exclusive'), definitionOf(wait).get('exclusive'), diff --git a/packages/transform/test/synthesize-ids.test.ts b/packages/transform/test/synthesize-ids.test.ts index 083a71a..222daba 100644 --- a/packages/transform/test/synthesize-ids.test.ts +++ b/packages/transform/test/synthesize-ids.test.ts @@ -1,20 +1,8 @@ /** - * The id templates are shared by the desugarer, the emitter, and the round-trip - * normalizer, so changing one here requires a matching change there. - * - * Gateway__split XOR split gateway for an `if` statement with id X - * Gateway__join XOR join gateway for an `if` statement with id X - * Gateway__fork AND fork (parallel) gateway for a `parallel` block X - * Gateway__join AND join (parallel) gateway for a `parallel` block X - * Gateway__race Event-based gateway for a multi-branch `await` X - * Gateway__loop XOR loop-head gateway for a `while` statement X - * Flow__default Default (else-branch) flow out of a gateway - * Flow__ Sequence flow (plain); duplicate pairs get _2, _3, ... - * StartEvent_ Implicit start event of a container - * EndEvent_ Implicit end event of a container, or of a boundary escape - * Throw_ Unnamed `throw`/`emit` event at coordinate X - * EventSubProcess_ `on` handler (event sub-process) at coordinate X - * Boundary__ Hosted `on : ` handler; duplicates get _2, _3, ... + * The templates are frozen by ADR 0010, Use Deterministic Structural Ids for + * Synthesized BPMN Elements, and the validator reserves every form they mint + * from a pattern list it spells itself, so each row pins the exact id and + * that `isReservedName` refuses it. */ import { describe, expect, it } from 'vitest'; @@ -40,78 +28,39 @@ import { resolveCollision, } from '../src/synthesize-ids.js'; -// --------------------------------------------------------------------------- -// Determinism: a collision with an author-supplied id resolves to a stable -// suffixed id, and the resolved id is recorded in the taken set -// --------------------------------------------------------------------------- - -describe('determinism', () => { - it('makeStartEventId resolves a collision with an author id and records it', () => { - // A task literally named `StartEvent_P` in process `P` collides with the - // implicit start id; the synthesizer must suffix it deterministically. - const taken = new Set(['StartEvent_P']); - const id = makeStartEventId('P', taken); - expect(id).toBe('StartEvent_P_2'); - expect(taken.has('StartEvent_P_2')).toBe(true); - }); - - it('makeBoundaryEventId resolves a collision and records it in taken', () => { - const taken = new Set(['Boundary_Pack_error']); - const id = makeBoundaryEventId('Pack', 'error', taken); - expect(id).toBe('Boundary_Pack_error_2'); - expect(taken.has('Boundary_Pack_error_2')).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// Structural stability: templates match the exact documented forms, and every -// form is one the validator reserves; a start or end only inside the container -// that seeds it -// --------------------------------------------------------------------------- - -describe('structural stability', () => { +describe('every template mints its documented form, which the validator reserves', () => { it.each<[string, () => string, string?]>([ ['Gateway_AmountCheck_split', () => makeGatewaySplitId('AmountCheck')], - ['Gateway_Step1_split', () => makeGatewaySplitId('Step1')], ['Gateway_AmountCheck_join', () => makeGatewayJoinId('AmountCheck')], - ['Gateway_OuterIf_join', () => makeGatewayJoinId('OuterIf')], ['Gateway_Step1_fork', () => makeGatewayForkId('Step1')], - ['Gateway_ParallelBlock_fork', () => makeGatewayForkId('ParallelBlock')], - ['Gateway_p_3_race', () => makeGatewayRaceId('p_3')], ['Gateway_OrderWait_race', () => makeGatewayRaceId('OrderWait')], ['Gateway_MyWhile_loop', () => makeGatewayLoopId('MyWhile')], - ['Gateway_RetryLoop_loop', () => makeGatewayLoopId('RetryLoop')], [ 'Flow_Gateway_AmountCheck_split_default', () => makeDefaultFlowId('Gateway_AmountCheck_split'), ], - ['Flow_Gateway_X_join_default', () => makeDefaultFlowId('Gateway_X_join')], + [ + 'Flow_ReviewInvoice_AmountCheck', + () => makeSequenceFlowId('ReviewInvoice', 'AmountCheck', new Set()), + ], [ 'StartEvent_invoice-approval', () => makeStartEventId('invoice-approval', new Set()), 'invoice-approval', ], - [ - 'StartEvent_my-process', - () => makeStartEventId('my-process', new Set()), - 'my-process', - ], [ 'EndEvent_invoice-approval', () => makeEndEventId('invoice-approval', new Set()), 'invoice-approval', ], - ['Throw_p_1', () => makeThrowEventId('p_1')], [ 'Throw_invoice-approval_2_t_0', () => makeThrowEventId('invoice-approval_2_t_0'), ], - ['EventSubProcess_p_1', () => makeEventSubProcessId('p_1')], [ 'EventSubProcess_invoice-approval_0', () => makeEventSubProcessId('invoice-approval_0'), ], - ['Catch_p_2', () => makeIntermediateCatchEventId('p_2')], [ 'Catch_invoice-approval_1_c_0', () => makeIntermediateCatchEventId('invoice-approval_1_c_0'), @@ -120,10 +69,6 @@ describe('structural stability', () => { 'Boundary_Pack_error', () => makeBoundaryEventId('Pack', 'error', new Set()), ], - [ - 'Boundary_Review_timer', - () => makeBoundaryEventId('Review', 'timer', new Set()), - ], [ 'Boundary_Pack_timer_2', () => @@ -152,9 +97,8 @@ describe('structural stability', () => { ])('%s', (expected, make, container) => { const id = make(); expect(id).toBe(expected); - // An authored name matching a synthesized id is refused, from a pattern - // list the validator spells itself. Its package cannot import these - // templates, so this row is what keeps the two lists the same set. + // The validator's package cannot import these templates, so this row is + // what keeps its pattern list and the templates the same set. expect(isReservedName(id, container)).toBe(true); if (container !== undefined) expect(isReservedName(id)).toBe(false); }); @@ -189,28 +133,26 @@ describe('structural stability', () => { ['EndEvent_q', false, false], ]); }); +}); - it('the positional throw/handler/catch templates take no taken set (no collision resolution)', () => { - // The coordinate is unique by position, so these templates are pure - // functions of it and need no collision resolution. - expect(makeThrowEventId).toHaveLength(1); - expect(makeEventSubProcessId).toHaveLength(1); - expect(makeIntermediateCatchEventId).toHaveLength(1); - }); - - it('is host-derived, not positional: it takes a taken set and adds its result', () => { +describe('a template whose base an author can take claims its result and suffixes past a taken one', () => { + it.each([ + ['StartEvent_P', (taken: Set) => makeStartEventId('P', taken)], + ['EndEvent_P', (taken: Set) => makeEndEventId('P', taken)], + [ + 'Boundary_Pack_error', + (taken: Set) => makeBoundaryEventId('Pack', 'error', taken), + ], + ['Flow_A_B', (taken: Set) => makeSequenceFlowId('A', 'B', taken)], + ])('%s, then _2, then _3, each claimed', (base, make) => { const taken = new Set(); - const id = makeBoundaryEventId('Pack', 'error', taken); - expect(taken.has(id)).toBe(true); + const minted = [make(taken), make(taken), make(taken)]; + expect(minted).toEqual([base, `${base}_2`, `${base}_3`]); + expect([...taken]).toEqual(minted); }); }); -// --------------------------------------------------------------------------- -// Names for what the script cannot spell -// --------------------------------------------------------------------------- - describe('mintPrintableName', () => { - // Revert: drop the leading underscore for a keyword and `user` mints itself. it.each([ ['a dot becomes an underscore', 'Task.1', false, 'Task_1'], ['a keyword takes a leading underscore', 'user', false, '_user'], @@ -231,75 +173,16 @@ describe('mintPrintableName', () => { }); }); -// --------------------------------------------------------------------------- -// Collision resolver -// --------------------------------------------------------------------------- - describe('resolveCollision', () => { - // The _2/_3 increments themselves are proven through a real caller by the - // `makeSequenceFlowId` block below. it.each([ ['A', ['B', 'C'], 'A'], ['X', ['X', 'X_2', 'X_3', 'X_4'], 'X_5'], - ] as const)('%s against %j resolves to %s', (base, taken, expected) => { - expect(resolveCollision(base, new Set(taken))).toBe(expected); - }); - - it('does not mutate the taken set', () => { - const taken = new Set(['A']); - resolveCollision('A', taken); - expect(taken.size).toBe(1); - expect(taken.has('A')).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// Plain sequence-flow convention (regression guard for the collision rule) -// --------------------------------------------------------------------------- - -describe('makeSequenceFlowId: plain sequence-flow convention', () => { - it('first occurrence: Flow__', () => { - const taken = new Set(); - expect(makeSequenceFlowId('ReviewInvoice', 'AmountCheck', taken)).toBe( - 'Flow_ReviewInvoice_AmountCheck', - ); - }); - - it('adds id to taken set so subsequent callers see it', () => { - const taken = new Set(); - makeSequenceFlowId('A', 'B', taken); - expect(taken.has('Flow_A_B')).toBe(true); - }); - - it('second occurrence of same pair: Flow___2', () => { - const taken = new Set(['Flow_A_B']); - expect(makeSequenceFlowId('A', 'B', taken)).toBe('Flow_A_B_2'); - }); - - it('third occurrence of same pair: Flow___3', () => { - const taken = new Set(['Flow_A_B', 'Flow_A_B_2']); - expect(makeSequenceFlowId('A', 'B', taken)).toBe('Flow_A_B_3'); - }); - - it('different pairs do not collide with each other', () => { - const taken = new Set(); - const id1 = makeSequenceFlowId('A', 'B', taken); - const id2 = makeSequenceFlowId('A', 'C', taken); - const id3 = makeSequenceFlowId('D', 'B', taken); - expect(id1).toBe('Flow_A_B'); - expect(id2).toBe('Flow_A_C'); - expect(id3).toBe('Flow_D_B'); - }); - - it('applies the collision rule to sequential duplicates', () => { - // addFlow repeats the same source/target pair when a node has parallel - // edges to the same successor. - const taken = new Set(); - const id1 = makeSequenceFlowId('A', 'B', taken); - const id2 = makeSequenceFlowId('A', 'B', taken); - const id3 = makeSequenceFlowId('A', 'B', taken); - expect(id1).toBe('Flow_A_B'); - expect(id2).toBe('Flow_A_B_2'); - expect(id3).toBe('Flow_A_B_3'); - }); + ] as const)( + '%s against %j resolves to %s and leaves the set alone', + (base, taken, expected) => { + const set = new Set(taken); + expect(resolveCollision(base, set)).toBe(expected); + expect([...set]).toEqual([...taken]); + }, + ); }); diff --git a/packages/transform/test/xml-to-ir.test.ts b/packages/transform/test/xml-to-ir.test.ts index 155973d..edaaa2d 100644 --- a/packages/transform/test/xml-to-ir.test.ts +++ b/packages/transform/test/xml-to-ir.test.ts @@ -1,12 +1,7 @@ /** - * Integration-level tests: `xmlToIr` runs against real BPMN XML strings, - * including the golden fixture files under `tests/golden/`. - * - * `xmlToIr` returns `{ ir, warnings }`, where `warnings` reports non-semantic - * content dropped on import (extra Operaton/camunda extension attributes and - * elements, lanes, and documentation this surface has no slot for). Semantic - * content the IR cannot express is refused instead: an - * `UnsupportedConstructError` subclass is thrown before any IR is produced. + * `xmlToIr` against real BPMN XML. Content the IR has no slot for is dropped + * with a warning; content it cannot express without changing what runs is + * refused with an `UnsupportedConstructError` before any IR is produced. */ import { describe, it, expect, beforeAll } from 'vitest'; @@ -18,7 +13,15 @@ import { createBpmnScriptServices } from '@bpmn-script/language'; import type { Model } from '@bpmn-script/language'; import { fileURLToPath } from 'node:url'; -import { xmlToIr } from '../src/xml-to-ir.js'; +import { + asyncRespellingMessage, + CAMUNDA_ALIAS_MESSAGE, + COMPENSATION_BOUNDARY_DETAIL, + IS_FOR_COMPENSATION_DETAIL, + priorityRefusal, + unimportedSettingMessage, + xmlToIr, +} from '../src/xml-to-ir.js'; import type { ImportWarning } from '../src/xml-to-ir.js'; import { HISTORY_TIME_TO_LIVE, irToXml } from '../src/ir-to-xml.js'; import { irToDsl } from '../src/ir-to-dsl.js'; @@ -37,6 +40,7 @@ import { UnsupportedFormReferenceError, UnsupportedGatewayShapeError, UnsupportedLoopCharacteristicsError, + SUPPORTED_KINDS_MESSAGE, UnsupportedServiceTaskFormError, } from '../src/errors.js'; import type { @@ -113,6 +117,25 @@ beforeAll(() => { parse = parseHelper(services.BpmnScript); }); +const expectParses = async (source: string): Promise => { + const doc = await parse(source, { validation: true }); + expect(doc.parseResult.parserErrors).toEqual([]); + expect(doc.diagnostics ?? []).toEqual([]); +}; + +/** The rewrite a compensation refusal proposes: the whole text, and it re-parses. */ +const expectRewrite = async ( + e: UnsupportedEventFeatureError, + lines: readonly string[], +): Promise => { + const marker = 'Write it by hand instead:\n\n'; + const cut = e.detail.indexOf(marker); + expect(cut).toBeGreaterThan(-1); + const rewrite = e.detail.slice(cut + marker.length); + expect(rewrite).toBe(lines.join('\n')); + await expectParses(`process Preview {\n${rewrite}\n}\n`); +}; + /** Only the warnings reporting dropped Operaton/camunda extension content. */ const extensionWarnings = (warnings: ImportWarning[]): ImportWarning[] => warnings.filter((w) => w.category === 'extensionAttribute'); @@ -144,60 +167,19 @@ const unread = ( ): ImportWarning => ({ elementId: id, category: 'extensionAttribute', - message: - `The ${subject} on '${id}' was not imported: this tool reads no such ` + - `setting on ${tag}, ` + - (engine === undefined - ? 'and the document written back carries none.' - : `though Operaton ${engine}, so the document written back runs without it.`), + message: unimportedSettingMessage(subject, id, tag, engine), }); -/** - * The one warning `xmlToIr` pushes once a document's camunda: text is read as - * operaton:. Every fixture below built with `camundaDoc`/`dualDoc`/ - * `camundaDefs`/`dualDefs` draws it, since the swap runs on the raw namespace - * declaration regardless of which prefix the body actually spells. - */ -const CAMUNDA_ALIAS_MESSAGE = - 'The file declares the camunda namespace; it was read as the operaton ' + - 'namespace, since `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` falls back to ' + - 'the camunda URI wherever the operaton spelling is absent, and the ' + - 'document written back carries `operaton:` alone.'; const CAMUNDA_ALIAS_WARNING = { elementId: 'p', category: 'unmappedConstruct', message: CAMUNDA_ALIAS_MESSAGE, }; -/** The one warning the older `operaton:async` spelling draws on `subject` (`'T'`, `the repetition of 'T'`). */ -const asyncRespelling = (subject: string): string => - `The operaton:async="true" on ${subject} imports as asyncBefore: true: ` + - 'BpmnParse.isAsyncBefore reads the two spellings as one, and this tool ' + - 'writes it back as operaton:asyncBefore, which the engine reads the same.'; - -/** The refusal `BpmnParse.parsePriority` draws for a constant that is not an integer. */ -const priorityRefusal = (attr: string, value: string): string => - `operaton:${attr}="${value}", which BpmnParse.parsePriority refuses to ` + - `deploy ("Value '${value}' for attribute '${attr}' is not a valid number")`; - -/** The whole refusal an activity excluded from normal flow draws. */ -const IS_FOR_COMPENSATION_DETAIL = - 'isForCompensation="true" marks this activity as excluded from normal ' + - 'flow: the boundary-event compensation-handler pattern, which this tool ' + - 'cannot import; wrap the steps in their own subprocess and target it with ' + - '"on compensation" instead'; - -/** The whole refusal a compensation boundary event draws with no genuine pairing. */ -const COMPENSATION_BOUNDARY_DETAIL = - 'a compensation boundary event is not imported: BPMN attaches ' + - 'compensation through isForCompensation and a bpmn:association on ' + - 'the activity being compensated, not a boundary event; wrap the ' + - 'steps in their own subprocess and target it with "on compensation" instead'; - /** - * Assert that exactly one warning was raised, and that it names the element and - * says what was dropped. The count is half the contract: a drop reported twice, - * or fanned out across clean elements, is as wrong as one never reported. + * Exactly one warning, naming the element and the drop. The count is half the + * contract: a drop reported twice, or against a clean element, is as wrong as + * one never reported. */ const expectOneWarning = ( warnings: ImportWarning[], @@ -221,11 +203,7 @@ const expectOneWarning = ( return warning; }; -/** - * A process opening on ``, preceded by root-level - * declarations and continued by a body written verbatim: the shape of every - * fixture that pins a root (error, message, signal) against one event. - */ +/** Root declarations, then a process opening on `` and continuing with `body`. */ const rootedDoc = (roots: string, body: string, defs = bpmnDefs): string => defs`${roots} @@ -251,11 +229,7 @@ describe('xmlToIr: camunda: prefix alias', () => { expect(ir).toEqual(HANDWRITTEN_IMPORT_IR); }); - /** - * One userTask carrying an assignee, a task listener, an input/output block - * and a retry cycle, plus one callActivity carrying an in mapping: every - * element-form construct finding 13 dropped, in one document. - */ + /** A user task carrying an attribute and three extension elements, and a call activity carrying an in mapping. */ const constructsBody = ( prefix: 'operaton' | 'camunda', ): string => ` @@ -359,8 +333,6 @@ describe('xmlToIr: camunda: prefix alias', () => { expect(warnings).toEqual([]); }); - // Revert: swapping the URI throughout the text rewrites the documentation - // and draws the alias warning for a document declaring only operaton:. it('the camunda URI inside a documentation body is text, not a declaration: kept verbatim and no warning', async () => { const { ir, warnings } = await xmlToIr( operatonDoc` @@ -419,14 +391,6 @@ describe('xmlToIr: service task binding forms', () => { }, ); - it('the camunda: prefix is accepted for the expression form', async () => { - const { node } = await importServiceTask( - 'camunda:expression="${x}"', - camundaDoc, - ); - expect(node.binding.kind).toBe('expression'); - }); - it('operaton:type="external" WITHOUT a topic stays refused', async () => { await expect( xmlToIr(oneNodeDoc('serviceTask', { attrs: 'operaton:type="external"' })), @@ -585,14 +549,6 @@ describe('xmlToIr: service task binding forms', () => { }); describe('xmlToIr: parallel gateway support', () => { - it('a lone bpmn:parallelGateway imports carrying its id', async () => { - const { node } = await importOnly( - oneNodeDoc('parallelGateway', { id: 'PG', doc: bpmnDoc }), - 'parallelGateway', - ); - expect(node.id).toBe('PG'); - }); - it('a fork and a join import with their labels, and no condition is invented on the branches', async () => { const { ir, warnings } = await xmlToIr( bpmnDefs` @@ -646,24 +602,19 @@ describe('xmlToIr: script task support', () => { doc: bpmnDoc, }); - it('bpmn:scriptTask imports to a scriptTask IR carrying scriptFormat and body', async () => { + it('bpmn:scriptTask imports to a scriptTask IR carrying scriptFormat and the entity-decoded body', async () => { const { node } = await importOnly( - scriptTaskDoc('total = price * quantity;', 'name="Compute total"'), + scriptTaskDoc( + 'total = price < quantity && ok;', + 'name="Compute total"', + ), 'scriptTask', ); expect(node.format).toBe('javascript'); - expect(node.code).toBe('total = price * quantity;'); + expect(node.code).toBe('total = price < quantity && ok;'); expect(node.name).toBe('Compute total'); }); - it('decodes an entity-escaped bpmn:script body to the literal text', async () => { - const { node } = await importOnly( - scriptTaskDoc('a < b && c'), - 'scriptTask', - ); - expect(node.code).toBe('a < b && c'); - }); - it('a deployment resource refuses instead of importing an empty script, in the same words as the operaton:script spelling', async () => { const resource = 'deployment://check.groovy'; const e = await expectRefusal( @@ -757,8 +708,6 @@ describe('xmlToIr: script task support', () => { ); }); - // Revert: `checkScriptBody` warning on nothing leaves every row's - // warning list empty. it.each([ { title: 'a self-closing bpmn:script', @@ -865,14 +814,7 @@ describe('xmlToIr: unsupported element (still refused kinds)', () => { ); expect(e.qname).toBe(qname); expect(e.elementId).toBe('T'); - expect(e.message).toContain( - 'Only start/end events, throws, emits, boundary events, event ' + - 'handlers, plain tasks, user tasks, service tasks, send tasks, ' + - 'receive tasks, business rule tasks, script tasks, exclusive ' + - 'gateways, parallel gateways, inclusive gateways, event-based ' + - 'gateways, embedded subprocesses, attempt blocks, call activities, ' + - 'and sequence flows are supported.', - ); + expect(e.message).toContain(SUPPORTED_KINDS_MESSAGE); }); }); @@ -1105,8 +1047,6 @@ ${EXECUTABLE}`, /^signal roots 'Signal_1' and 'Signal_2' both declare the name "Go"; `BpmnParse\.parseSignals` fails the deployment/, }, ], - // Revert: `checkStartEventCount` returning before the no-start check on - // a process imports both rows, the second with no warning at all. [ 'an empty process refuses, since parseStartEvents fails the deployment without a start', bpmnDefs`${process(' isExecutable="true"', 'p', '')}`, @@ -1144,11 +1084,8 @@ describe('xmlToIr: start, end, and emit triggers', () => { ''; interface EventXmlOptions { - /** Extra attributes on the event's opening tag. */ attrs?: string; - /** Root-level declarations before the process. */ roots?: string; - /** The `` wrapper. */ defs?: typeof bpmnDefs; } @@ -1194,62 +1131,63 @@ describe('xmlToIr: start, end, and emit triggers', () => { `; describe('a start carries message, signal, timer, or condition', () => { + const timer = (child: string, expression: string): string => + ` + ${expression} + `; + it.each([ - ['timeDuration', 'PT1H', 'duration'], - ['timeDate', '2026-08-01T09:00:00Z', 'date'], - ['timeCycle', 'R/PT10M', 'cycle'], + [ + 'a timeDuration timer', + timer('timeDuration', 'PT1H'), + '', + timerDef('duration', 'PT1H'), + ], + [ + 'a timeDate timer', + timer('timeDate', '2026-08-01T09:00:00Z'), + '', + timerDef('date', '2026-08-01T09:00:00Z'), + ], + [ + 'a timeCycle timer', + timer('timeCycle', 'R/PT10M'), + '', + timerDef('cycle', 'R/PT10M'), + ], + [ + 'a message, named by the root it references', + MESSAGE_DEF, + MESSAGE_ROOT, + messageDef('OrderReceived'), + ], + [ + 'a signal, named by the root it references', + '', + SIGNAL_ROOT, + signalDef('StockLow'), + ], + [ + 'a condition, as the engine waits on it', + ` + \${stockLevel < 5} + `, + '', + conditionDef('${stockLevel < 5}'), + ], ] as const)( - 'a %s timer start imports as that timer kind, verbatim', - async (child, expression, timerKind) => { + 'a start on %s imports the trigger verbatim, reporting nothing', + async (_title, definition, roots, expected) => { const { node, warnings } = await importById( - startTriggerXml( - ` - ${expression} - `, - ), + startTriggerXml(definition, { roots }), 'TStart', 'startEvent', ); - expect(node.eventDefinition).toEqual(timerDef(timerKind, expression)); + expect(node.eventDefinition).toEqual(expected); expect(warnings).toEqual([]); }, ); - it('a message start imports the name of the message root it references', async () => { - const { node, warnings } = await importById( - startTriggerXml(MESSAGE_DEF, { roots: MESSAGE_ROOT }), - 'TStart', - 'startEvent', - ); - expect(node.eventDefinition).toEqual(messageDef('OrderReceived')); - expect(warnings).toEqual([]); - }); - - it('a signal start imports the name of the signal root it references', async () => { - const { node, warnings } = await importById( - startTriggerXml( - '', - { roots: SIGNAL_ROOT }, - ), - 'TStart', - 'startEvent', - ); - expect(node.eventDefinition).toEqual(signalDef('StockLow')); - expect(warnings).toEqual([]); - }); - - it('a conditional start imports as the condition the engine waits on', async () => { - const { node, warnings } = await importById( - startTriggerXml(` - \${stockLevel < 5} - `), - 'TStart', - 'startEvent', - ); - expect(node.eventDefinition).toEqual(conditionDef('${stockLevel < 5}')); - expect(warnings).toEqual([]); - }); - it('a triggered start keeps its form fields alongside the trigger', async () => { const { node } = await importById( startTriggerXml( @@ -1562,19 +1500,6 @@ describe('xmlToIr: start, end, and emit triggers', () => { }, ); - it('the deprecated camunda: prefix of the same attribute reads alike', async () => { - const { node, warnings } = await importById( - endTriggerXml( - implementedMessageDef('camunda:class="com.example.Send"'), - { roots: MESSAGE_ROOT, defs: camundaDefs }, - ), - 'Typed', - 'endEvent', - ); - expect(node.binding).toEqual(classBinding('com.example.Send')); - expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); - }); - it('a message emit carries the implementation too', async () => { const { node, warnings } = await importById( emitXml( @@ -1640,35 +1565,35 @@ describe('xmlToIr: start, end, and emit triggers', () => { }); }); - it('a connector on the definition refuses: the same send, in element form', async () => { - const e = await expectRefusal( - xmlToIr( - endTriggerXml(connectorMessageDef('operaton'), { - roots: MESSAGE_ROOT, - defs: operatonDefs, - }), - ), - UnsupportedEventFeatureError, - 'a thrown message carries a connector; that is what makes the ' + - 'engine really send it, and this surface has no place to keep it', - ); - expect(e.elementId).toBe('Typed'); - }); - - it('the deprecated camunda: prefix of the connector refuses alike, on an emit', async () => { - const e = await expectRefusal( - xmlToIr( - emitXml(connectorMessageDef('camunda'), { - roots: MESSAGE_ROOT, - defs: camundaDefs, - }), - ), - UnsupportedEventFeatureError, - 'a thrown message carries a connector; that is what makes the ' + - 'engine really send it, and this surface has no place to keep it', - ); - expect(e.elementId).toBe('Emit1'); - }); + it.each([ + [ + 'a message end', + endTriggerXml(connectorMessageDef('operaton'), { + roots: MESSAGE_ROOT, + defs: operatonDefs, + }), + 'Typed', + ], + [ + 'a message emit, under the camunda: prefix', + emitXml(connectorMessageDef('camunda'), { + roots: MESSAGE_ROOT, + defs: camundaDefs, + }), + 'Emit1', + ], + ] as const)( + 'a connector on the definition of %s refuses: the same send, in element form', + async (_title, xml, elementId) => { + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedEventFeatureError, + 'a thrown message carries a connector; that is what makes the ' + + 'engine really send it, and this surface has no place to keep it', + ); + expect(e.elementId).toBe(elementId); + }, + ); it('the same setting on the event itself warns: the engine reads it off the definition', async () => { const { node, warnings } = await importById( @@ -1743,9 +1668,6 @@ ${children} `for ${elementName} elements using '${binding}'" ` + '(BpmnParse.parseServiceTaskLike)'; - // Revert: drop the `refuseResultVariableBeside` call from - // `readThrownMessageBinding`, and the first two rows import with a - // warning; drop the expression arm, and the third does. it.each([ [ 'operaton:class with operaton:resultVariable on an emit', @@ -1828,9 +1750,6 @@ ${children} 'whose class or delegate binding receives it, on the step whose ' + 'built-in mail or shell behaviour does, and on no other position'; - // Revert: take the extras off `CONSUMED_EXTENSION_ATTRS`/`_ELEMENTS` - // for the definition and the event, and every row gains a generic - // unread-setting warning beside its own. it.each([ { case: 'a result variable beside an external type is dropped: the type branch never writes it', @@ -2085,19 +2004,6 @@ ${children} expect(node.eventDefinition).toEqual(messageDef('OrderReceived')); expect(warnings).toEqual([]); }); - - it('a plain start and end (no event definition at all) are not refused', async () => { - const { ir, warnings } = await xmlToIr( - bpmnDoc` - - `, - ); - expect(ir.flowElements.map((fe) => fe.kind)).toEqual([ - 'startEvent', - 'endEvent', - ]); - expect(warnings).toEqual([]); - }); }); describe('xmlToIr: imports a repetition', () => { @@ -2217,16 +2123,13 @@ describe('xmlToIr: imports a repetition', () => { expect(warnings).toEqual([]); }); - // A reference naming an element in the document resolves, and moddle answers - // with that element; the fixture declares one so both paths stay covered. - const dataDoc = (attrs: string, children: string): string => - oneNodeDoc('userTask', { - children: `${repeat(attrs, children)}`, - }); - - it('a bpmn:loopDataInputRef imports as the collection', async () => { + // moddle resolves a reference that names an element in the document to that + // element, so this fixture declares one to cover the resolved path too. + it('a bpmn:loopDataInputRef naming a declared element imports as the collection', async () => { const { node } = await importById( - dataDoc('', 'lines'), + oneNodeDoc('userTask', { + children: `${repeat('', 'lines')}`, + }), 'T', 'userTask', ); @@ -2359,18 +2262,6 @@ describe('xmlToIr: imports a repetition', () => { }, ); - it('a bpmn:inputDataItem name imports as the element variable', async () => { - const { node, warnings } = await importOnly( - repeatedTaskDoc( - 'operaton:collection="lines"', - '', - ), - 'userTask', - ); - expect(node.loop).toEqual({ collection: 'lines', elementVariable: 'line' }); - expect(warnings).toEqual([]); - }); - it('a bpmn:inputDataItem shadows operaton:elementVariable, and the drop is reported', async () => { const { node, warnings } = await importOnly( repeatedTaskDoc( @@ -2501,10 +2392,6 @@ describe('xmlToIr: imports a repetition', () => { retryCycle: 'R5/PT1M', }; - // Revert: drop the loop element from JOB_SETTING_OWNERS, and the two full - // rows each gain one 'was not imported' warning per run setting; read the - // loop through readJobSettings instead of readRunSettings, and the priority - // row gains the rewrap note. it.each([ { title: @@ -2548,7 +2435,7 @@ describe('xmlToIr: imports a repetition', () => { { elementId: 'T', category: 'unmappedConstruct', - message: asyncRespelling("the repetition of 'T'"), + message: asyncRespellingMessage("the repetition of 'T'"), }, ], }, @@ -2591,9 +2478,6 @@ describe('xmlToIr: imports a repetition', () => { ...STEP_SETTINGS, loop, }); - // `doc: dualDoc` above declares the camunda namespace regardless of - // which prefix the row itself writes, so every row draws the alias - // warning too. expect(imported.warnings).toEqual([...warnings, CAMUNDA_ALIAS_WARNING]); }, ); @@ -2803,9 +2687,8 @@ describe('xmlToIr: a #{...} body the printer spells bare is rebuilt inside ${... expect(warnings.map((w) => w.category)).toEqual(['unmappedConstruct']); }); - // An out-of-subset body prints as the quoted raw template with its opener, - // and a `#{` later in the body leaves the parse outside the subset, so the - // raw path returns both character for character. + // A body outside the printer's bare subset prints as a quoted raw template + // with its opener, so it comes back character for character. it.each(['${lineCount}', '${a} #{b}', '#{bean.count()}'])( 'a cardinality the printer keeps as written imports verbatim and reports nothing: %s', async (body) => { @@ -2949,9 +2832,6 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even }, ); - // Revert: drop the raw-text read of an unprefixed type, and the second row - // imports the flow with no condition under a residual drop attributed to - // the process. it.each([ ['xsi:type="bpmn:tExpression"', 'bpmn:tExpression'], [ @@ -3011,8 +2891,6 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even }, ); - // Revert: drop the `.trim()` on the recovered body, and this imports - // ' ' as the condition instead of matching moddle's own empty-body path. it('a whitespace-only recovered body imports as no condition, as moddle imports one', async () => { const { ir, warnings } = await xmlToIr( flowDoc( @@ -3126,10 +3004,8 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even }, ); - // Operaton's parseConditionExpression is the shared helper behind both a - // sequence flow's conditionExpression and a conditional event definition's - // condition, so the same language/resource handling applies at every - // position a conditional event definition can occupy. + // `BpmnParse.parseConditionExpression` reads a conditional event + // definition's condition the same way it reads a flow's, at every position. const conditionalDoc = ( conditionAttrs: string, position: 'boundary' | 'event-subprocess start' | 'intermediate catch', @@ -3255,8 +3131,7 @@ describe('xmlToIr: warns for dropped extension attributes', () => { `; // Each is declared in the moddle extension, so it parses into a typed - // property (not `$attrs`) and is invisible to the raw-attribute sweep: read - // it and the descriptor scan stays silent, drop it and the scan reports it. + // property (not `$attrs`) that the raw-attribute sweep never sees. it.each([ [ 'a process keeps the history time to live it authored', @@ -3319,8 +3194,6 @@ ${children} } `; - // Revert: drop the potential-starter read from mapProcess, and the first - // row keeps the attributes' entries alone under a residual-drop warning. it.each([ { title: @@ -3377,9 +3250,6 @@ ${children} }, ); - // Revert: drop a row from ENGINE_READS_ELSEWHERE, and its sentence loses - // the clause naming the engine's reader; drop the retry-attribute arm from - // warnUnreadPrefixedAttrs, and that row reads as a setting nothing reads. it.each([ { title: @@ -3528,11 +3398,8 @@ ${children} expect(warnings).toEqual(expected); }); - // A start under the exact id this tool mints for its container is one a - // script cannot repeat, so the whole statement is left out and the - // initiator with it. The unread-attribute sweep no longer covers it, so this - // report is all that stands between the value and a silent drop. (A - // handler's start drops the initiator before ever reaching this point.) + // The printer leaves out a start under the id this tool mints for its + // container, and the unread-attribute sweep does not cover what goes with it. it("a process's own start printing no statement says its initiator went with it", async () => { const { warnings } = await xmlToIr( headerDoc('', 'operaton:initiator="claimant"', 'StartEvent_p'), @@ -3600,13 +3467,8 @@ describe('xmlToIr: warns for dropped lanes', () => { }); describe('xmlToIr: an empty extensionElements is not flagged beside a real drop', () => { - /** - * A user task with a stray empty `` beside a - * service task with a real ``. A document-level "unparsable - * content" boolean cannot tell the two apart and would flag both; typing the - * operaton extension elements makes the drop attributable to the exact - * owning element. - */ + // A document-level "unparsable content" boolean could not tell the stray + // empty block from the real drop; the typed extension elements can. it('reports the field against the service task alone, because an operaton:expression binding never receives an injected field, and reads the assignee off the clean task', async () => { const { ir, warnings } = await xmlToIr( operatonDoc` @@ -3634,25 +3496,6 @@ describe('xmlToIr: an empty extensionElements is not flagged beside a real drop' }); }); -describe('xmlToIr: camunda: extension elements read exactly as operaton: ones', () => { - it('a camunda:field on a class-bound service task is injected, the same as operaton:field', async () => { - const xml = oneNodeDoc('serviceTask', { - id: 'CamSvc', - attrs: 'name="Cam Svc" camunda:class="com.example.Svc"', - children: extensionElements( - ` `, - ), - doc: camundaDoc, - }); - const { node, warnings } = await importOnly(xml, 'serviceTask'); - expect(node.binding).toEqual({ - ...classBinding('com.example.Svc'), - fields: [{ name: 'greeting', value: 'hello' }], - }); - expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); - }); -}); - describe('xmlToIr: undeclared operaton extension element residual', () => { const residualXml = operatonDoc` @@ -3736,10 +3579,8 @@ describe('xmlToIr: bpmn:documentation', () => { `; /** - * One row per position `bpmn:documentation` can sit on, each stating the text - * the IR node holds and the warnings the document draws whole. A position - * that carries draws none, and a position wired to neither branch fails both - * columns rather than passing in silence. + * One row per position `bpmn:documentation` can sit on: what the IR carries + * and the whole warning list, so a position wired to neither fails both. */ const POSITIONS: [ title: string, @@ -4171,15 +4012,6 @@ describe('xmlToIr: warns for unmapped BPMN content', () => { }); expect(reported[0].message).toMatch(/deploy/i); }); - - it('reports nothing for a process built only from mapped constructs', async () => { - const xml = oneNodeDoc('userTask', { - id: 'Review', - attrs: 'name="Review"', - doc: bpmnDoc, - }); - expect((await xmlToIr(xml)).warnings).toEqual([]); - }); }); describe('xmlToIr: a data object, its reference, or a data store reference is dropped, not refused', () => { @@ -4418,41 +4250,6 @@ describe('xmlToIr: embedded sub-process imports recursively', () => { expect('name' in sub).toBe(false); }); - it('imports a repeated sub-process, keeping its body', async () => { - const xml = subProcessDoc( - ` - 2 - - - - `, - ); - - const { node, warnings } = await importOnly(xml, 'subProcess'); - expect(node.loop).toEqual({ cardinality: '2', sequential: true }); - expect(node.flowElements).toHaveLength(2); - expect(warnings).toEqual([]); - }); - - it('warns for an unsupported extension attribute on a task nested inside a sub-process', async () => { - const xml = subProcessDoc( - ` - - - - `, - '', - operatonDoc, - ); - - const { warnings } = await xmlToIr(xml); - expectOneWarning(warnings, { - elementId: 'InnerTask', - message: 'formHandlerClass', - }); - }); - it('refuses a trigger on a start event nested inside a sub-process', async () => { const xml = subProcessDoc( ` @@ -4473,39 +4270,6 @@ describe('xmlToIr: embedded sub-process imports recursively', () => { expect(e.message).toContain("Put the trigger on an 'on' handler"); expect(e.message).not.toContain('Event handlers catch one'); }); - - it('imports two-level nesting recursively', async () => { - const xml = oneNodeDoc('subProcess', { - id: 'Outer', - doc: bpmnDoc, - children: ` - - - - - - - - - - `, - }); - - const { node: outer, warnings } = await importOnly(xml, 'subProcess'); - expect(warnings).toEqual([]); - - const inner = subProcess(outer, 'Inner'); - expect(inner.flowElements.map((fe) => fe.id)).toEqual([ - 'IStart', - 'Deep', - 'IEnd', - ]); - expect(outer.flowElements.map((fe) => fe.id)).toEqual([ - 'OStart', - 'Inner', - 'OEnd', - ]); - }); }); describe('xmlToIr: callActivity import', () => { @@ -4562,11 +4326,6 @@ describe('xmlToIr: callActivity import', () => { const importCall = (attrs: string) => importOnly(callXmlWithBindingAttrs(attrs), 'callActivity'); - it('no binding attributes -> the IR binding is absent', async () => { - const { node } = await importCall(''); - expect('binding' in node).toBe(false); - }); - it.each([ [ 'calledElementBinding="latest" -> { kind: "latest" }', @@ -4614,8 +4373,6 @@ describe('xmlToIr: callActivity import', () => { ); }); - // Revert: refuse the default branch of readVersionBinding again, and the - // row throws instead of importing. it('a binding word parseBinding does not know imports as latest, naming the rewrite', async () => { const { node, warnings } = await importCall( 'operaton:calledElementBinding="bogus"', @@ -4745,8 +4502,6 @@ describe('xmlToIr: callActivity import', () => { async (_title, attributes, expectedMapper, expectedWarnings) => { const { node, warnings } = await importCall(attributes); expect(node.mapper).toEqual(expectedMapper); - // `importCall` always declares the camunda namespace (`dualDoc`), so - // every row draws the alias warning alongside its own. expect(warnings).toEqual([...expectedWarnings, CAMUNDA_ALIAS_WARNING]); }, ); @@ -4806,8 +4561,6 @@ describe('xmlToIr: callActivity import', () => { const KEY_ALONE = 'BpmnParse.parseInputParameter reads the business key alone off that element'; - // Revert: refuse each combination in readCallMappings again, and the row - // throws instead of importing the engine's pick. it.each([ { title: 'source beside sourceExpression keeps source', @@ -4878,53 +4631,6 @@ describe('xmlToIr: callActivity import', () => { }, ); - it('a camunda:in on a call activity imports the mapping, exactly as operaton:in would', async () => { - const xml = callDoc( - '', - extensionElements(' '), - camundaDoc, - ); - - const { node, warnings } = await importOnly(xml, 'callActivity'); - expect(node.inMappings).toEqual([ - { kind: 'variable', source: 'a', target: 'b' }, - ]); - expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); - }); - - it('a call activity inside a sub-process imports into the nested container', async () => { - const xml = oneNodeDoc('subProcess', { - id: 'Sub', - doc: bpmnDoc, - children: ` - - - - `, - }); - - const { node: sub } = await importOnly(xml, 'subProcess'); - const inner = only(sub, 'callActivity'); - expect(inner.id).toBe('InnerCall'); - expect(inner.calledElement).toBe('sub-process'); - }); - - it('a call activity with multiInstanceLoopCharacteristics imports its repetition', async () => { - const xml = callDoc( - '', - ` - 2 - `, - bpmnDoc, - ); - - const { node, warnings } = await importOnly(xml, 'callActivity'); - expect(node.loop).toEqual({ cardinality: '2' }); - expect(warnings).toEqual([]); - }); - - // Revert: read the name through readString, and the empty label reads as - // absent, so the rebuilt document derives one the author erased. it.each([ ['equals humanize(id) is dropped', 'Fulfil Order', undefined], [ @@ -5018,43 +4724,35 @@ describe('xmlToIr: event layer import', () => { expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); }); - /** The trigger definition on the handler start of a `handlerDoc` fixture. */ - const triggerOf = async (xml: string) => { - const { node } = await importOnly(xml, 'subProcess'); - const start = byId(node, 'HStart'); - return start.kind === 'startEvent' ? start.eventDefinition : undefined; - }; - - const codelessRootXml = handlerDoc( - '', - { roots: ' \n' }, - ); - it.each([ [ 'a handler definition without errorRef imports with the code absent (catch-all)', handlerDoc(''), + [], ], [ - 'a ref to a code-less bpmn:Error root imports with the code absent', - codelessRootXml, + 'a ref to a code-less bpmn:Error root imports with the code absent, and the root warns about the missing code rather than "never caught"', + handlerDoc( + '', + { roots: ' \n' }, + ), + [ + { + elementId: 'Error_NoCode', + category: 'unreferencedRoot', + message: + "The error root 'Error_NoCode' has no code, so it cannot be " + + 'keyed or represented in the model; it was not imported.', + }, + ], ], - ])('%s', async (_title, xml) => { - expect(await triggerOf(xml)).toEqual(errorDef()); - }); - - it('a code-less bpmn:Error root warns about the missing code, not "never caught"', async () => { - // The root is referenced (errorRef), so "never caught or thrown" would be - // false. The message names the real reason: a code-less root cannot be - // keyed or represented. - const { warnings } = await xmlToIr(codelessRootXml); - const w = warnings.find( - (w) => - w.category === 'unreferencedRoot' && w.elementId === 'Error_NoCode', + ])('%s', async (_title, xml, expected) => { + const { node, warnings } = await importOnly(xml, 'subProcess'); + const start = byId(node, 'HStart'); + expect(start.kind === 'startEvent' && start.eventDefinition).toEqual( + errorDef(), ); - expect(w).toBeDefined(); - expect(w?.message).toContain('has no code'); - expect(w?.message).not.toContain('never caught'); + expect(warnings).toEqual(expected); }); describe('refusals', () => { @@ -5243,8 +4941,6 @@ describe('xmlToIr: event layer import', () => { 'which Operaton refuses to deploy ("could not find escalation with ' + `id 'Esc_Missing'", BpmnParse.${method})`; - // Revert: read a dangling or absent reference as a catch-all or a - // code-less throw again, and every row imports. it.each([ [ 'a boundary catch whose escalationRef names no root', @@ -5341,8 +5037,6 @@ describe('xmlToIr: event layer import', () => { 'back declares an error root carrying it.', }); - // Revert: drop the `unresolvedRef` read from either reader, and the - // catch rows import a catch-all with no warning while the throw refuses. it.each([ [ 'a boundary catch', @@ -5535,40 +5229,6 @@ describe('xmlToIr: event layer import', () => { }); }); - it('an event handler nested inside a plain sub-process imports into the nested container', async () => { - const xml = bpmnDefs` - - - - - - - - - - - - - - - - - - - - `; - - const { node: outer, warnings } = await importOnly(xml, 'subProcess'); - expect(warnings).toEqual([]); - - const inner = subProcess(outer, 'InnerHandler'); - expect(inner.triggeredByEvent).toBe(true); - const innerStart = byId(inner, 'IHStart'); - expect( - innerStart.kind === 'startEvent' && innerStart.eventDefinition, - ).toEqual(errorDef('X')); - }); - it('a normal (non-handler) start event with an error definition still refuses', async () => { const xml = bpmnDefs` @@ -5692,8 +5352,6 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { 'non-empty name', ], [ - // Revert: drop the `unresolvedRef` read from `resolveNamedRootRef`, - // and the detail no longer names the reference text. 'a prefixed messageRef, which the engine resolves and this tool does not', handlerDoc( '', @@ -5755,23 +5413,12 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { [ 'camunda:variableEvents on a conditional definition', handlerDoc( - ` - \${amount > 100} - `, - { body: '', defs: camundaDefs }, - ), - "a conditional definition's operaton:variableEvents narrows when the " + - 'condition is (re-)evaluated, which this tool cannot represent', - ], - [ - 'camunda:variableName on a conditional definition, read as operaton: like the rest of the namespace', - handlerDoc( - ` + ` \${amount > 100} `, { body: '', defs: camundaDefs }, ), - "a conditional definition's operaton:variableName narrows when the " + + "a conditional definition's operaton:variableEvents narrows when the " + 'condition is (re-)evaluated, which this tool cannot represent', ], ] as const)( @@ -5860,21 +5507,6 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { ).toEqual(messageDef('Ping')); }); - it('an unreferenced bpmn:Message root warns once', async () => { - const xml = rootedDoc( - ' \n', - ` - `, - ); - - const { warnings } = await xmlToIr(xml); - const unreferenced = warnings.filter( - (w) => w.category === 'unreferencedRoot', - ); - expect(unreferenced).toHaveLength(1); - expect(unreferenced[0].elementId).toBe('Message_Unused'); - }); - it('itemRef on a referenced bpmn:Message root warns once and still imports', async () => { const xml = handlerDoc( '', @@ -5894,43 +5526,10 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { const [itemRefWarning, rootWarning] = warnings; expect(itemRefWarning.elementId).toBe('Message_X'); expect(itemRefWarning.message).toContain('itemRef'); - // The item definition the itemRef pointed at is dropped in its own - // right, and is reported in its own right. expect(rootWarning.elementId).toBe('Item_1'); expect(rootWarning.message).toContain("bpmn:itemDefinition 'Item_1'"); }); }); - - it('a timer handler nested inside a plain sub-process imports into the nested container', async () => { - const xml = oneNodeDoc('subProcess', { - id: 'Outer', - doc: bpmnDoc, - children: ` - - - - - - PT30M - - - - - - - `, - }); - - const { node: outer, warnings } = await importOnly(xml, 'subProcess'); - expect(warnings).toEqual([]); - - const inner = subProcess(outer, 'InnerTimerHandler'); - expect(inner.triggeredByEvent).toBe(true); - const innerStart = byId(inner, 'ITStart'); - expect( - innerStart.kind === 'startEvent' && innerStart.eventDefinition, - ).toEqual(timerDef('duration', 'PT30M')); - }); }); describe('xmlToIr: compensation import', () => { @@ -6212,25 +5811,22 @@ describe('xmlToIr: compensation import', () => { }, ); - /** `S -> ReserveRoom -> E`, `ReserveRoom` compensated by `CancelReservation`. */ - const pairedDoc = ( - order: readonly ['boundary' | 'handler', 'boundary' | 'handler'], - ) => { - const boundary = ` + const BOUNDARY = ` `; - const handler = - ''; - const byName = { boundary, handler }; - return operatonDoc` - - ${byName[order[0]]} - ${byName[order[1]]} + const HANDLER = + ''; + const RESERVE = + ''; + + /** `S -> ReserveRoom -> E`, `ReserveRoom` compensated by `CancelReservation`, the three written in the given order. */ + const pairedDoc = (...nodes: readonly string[]): string => + operatonDoc` + ${nodes.join('\n ')} `; - }; const REWRITE_PREVIEW = [ 'subprocess Compensated_ReserveRoom {', @@ -6239,22 +5835,44 @@ describe('xmlToIr: compensation import', () => { ' user CancelReservation', ' }', '}', - ].join('\n'); + ]; it.each([ [ 'the boundary event comes first in document order', - pairedDoc(['boundary', 'handler'] as const), + pairedDoc(RESERVE, BOUNDARY, HANDLER), 'CompensationBoundary', + REWRITE_PREVIEW, ], [ 'the handler comes first in document order', - pairedDoc(['handler', 'boundary'] as const), + pairedDoc(RESERVE, HANDLER, BOUNDARY), 'CancelReservation', + REWRITE_PREVIEW, + ], + [ + 'the compensated activity repeats, which keeps the for-each clause', + pairedDoc( + ` + + `, + BOUNDARY, + HANDLER, + ), + 'CompensationBoundary', + [ + 'var lines: any', + 'subprocess Compensated_ReserveRoom {', + ' service ReserveRoom for each line in lines(class: "com.example.Reserve")', + ' on compensation {', + ' user CancelReservation', + ' }', + '}', + ], ], ] as const)( 'a compensated activity, its boundary event and its association-linked handler are all named in the refusal, and the printed rewrite re-parses through the compiler (%s)', - async (_title, xml, elementId) => { + async (_title, xml, elementId, preview) => { const e = await expectRefusal( xmlToIr(xml), UnsupportedEventFeatureError, @@ -6263,65 +5881,13 @@ describe('xmlToIr: compensation import', () => { expect(e.detail).toContain('ReserveRoom'); expect(e.detail).toContain('CompensationBoundary'); expect(e.detail).toContain('CancelReservation'); - - const marker = 'Write it by hand instead:\n\n'; - const cut = e.detail.indexOf(marker); - expect(cut).toBeGreaterThan(-1); - const rewrite = e.detail.slice(cut + marker.length); - expect(rewrite).toBe(REWRITE_PREVIEW); - - const wrapped = `process Preview {\n${rewrite}\n}\n`; - const doc = await parse(wrapped, { validation: true }); - expect(doc.parseResult.parserErrors).toEqual([]); - expect(doc.diagnostics ?? []).toEqual([]); + await expectRewrite(e, preview); }, ); - it('a compensated activity carrying a repetition keeps the for-each clause in the printed rewrite', async () => { - const xml = operatonDoc` - - - - - - - - - - - `; - - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedEventFeatureError, - ); - const marker = 'Write it by hand instead:\n\n'; - const cut = e.detail.indexOf(marker); - expect(cut).toBeGreaterThan(-1); - const rewrite = e.detail.slice(cut + marker.length); - expect(rewrite).toBe( - [ - 'var lines: any', - 'subprocess Compensated_ReserveRoom {', - ' service ReserveRoom for each line in lines(class: "com.example.Reserve")', - ' on compensation {', - ' user CancelReservation', - ' }', - '}', - ].join('\n'), - ); - - const wrapped = `process Preview {\n${rewrite}\n}\n`; - const doc = await parse(wrapped, { validation: true }); - expect(doc.parseResult.parserErrors).toEqual([]); - expect(doc.diagnostics ?? []).toEqual([]); - }); - it('a compensated activity that independently carries content this tool cannot import at all still raises the compensation refusal, not that unrelated one', async () => { - // The boundary comes first in document order, so `mapBoundaryEvent` - // reaches it, and its rewrite preview, before the container walk ever - // maps the host on its own: only the preview's own mapper call sees - // the resource defect first. + // The boundary comes first, so the rewrite preview maps the host before + // the container walk does: only the preview's mapper sees the defect. const xml = operatonDoc` @@ -6362,43 +5928,6 @@ describe('xmlToIr: compensation import', () => { }, ]); }); - - it('a compensation handler nested inside a plain sub-process nested inside another plain sub-process imports into the deepest container', async () => { - const xml = oneNodeDoc('subProcess', { - id: 'Outer', - doc: bpmnDoc, - children: ` - - - - - - - - - - - - - - - - - `, - }); - - const { node: outer, warnings } = await importOnly(xml, 'subProcess'); - expect(warnings).toEqual([]); - - const handler = subProcess(subProcess(outer, 'Inner'), 'UndoBooking'); - expect(handler.triggeredByEvent).toBe(true); - - const start = byId(handler, 'UndoStart'); - expect(start.kind === 'startEvent' && start.eventDefinition).toEqual({ - kind: 'compensation', - }); - expect(start.kind === 'startEvent' && start.isInterrupting).toBeUndefined(); - }); }); describe('xmlToIr: boundary event import', () => { @@ -6495,36 +6024,6 @@ describe('xmlToIr: boundary event import', () => { expect(warnings).toEqual([]); }); - it('a boundary event on a host nested inside a sub-process imports at that depth', async () => { - const xml = bpmnDoc` - - - - - - PT30M - - - - - - - - - `; - - const { ir, warnings } = await xmlToIr(xml); - expect(warnings).toEqual([]); - - expect(byId(subProcess(ir, 'Outer'), 'Boundary_Pack_timer')).toEqual( - boundaryEvent( - 'Boundary_Pack_timer', - 'Pack', - timerDef('duration', 'PT30M'), - ), - ); - }); - const TIMER_1H = ` PT1H `; @@ -6543,10 +6042,7 @@ ${extraFlows} `; describe('refusals', () => { - /** - * The whole refusal an attachedToRef that names nothing attachable draws. - * It enumerates every host noun, so pinning it whole pins that list. - */ + /** The whole refusal, since it enumerates every host noun. */ const unattachableDetail = (ref: string): string => `attachedToRef "${ref}" does not name a plain task, user task, ` + 'service task, send task, business rule task, receive task, script ' + @@ -6822,24 +6318,6 @@ describe('xmlToIr: intermediate catch event import', () => { const { ir, warnings } = await xmlToIr(xml); expect(ir).toEqual(expectedIr); expect(warnings).toEqual([]); - - // Every catch sits on the main flow: exactly one incoming and one - // outgoing sequence flow apiece. - for (const catchId of [ - 'WaitMsg', - 'WaitDur', - 'WaitDate', - 'WaitCycle', - 'WaitSig', - 'WaitCond', - ]) { - expect( - ir.sequenceFlows.filter((f) => f.targetRef === catchId), - ).toHaveLength(1); - expect( - ir.sequenceFlows.filter((f) => f.sourceRef === catchId), - ).toHaveLength(1); - } }); /** `S -> Wait -> E`, where the catch carries the given definition. */ @@ -6851,10 +6329,7 @@ describe('xmlToIr: intermediate catch event import', () => { }); describe('refuses an unsupported trigger', () => { - /** - * The whole refusal a trigger no await admits draws. It enumerates where - * each refused trigger does have a surface, so pinning it whole pins that. - */ + /** The whole refusal, since it names where each refused trigger does belong. */ const unawaitableDetail = (tag: string): string => `an await cannot carry a bpmn:${tag}: only message, timer, signal, ` + 'condition, or link triggers can be awaited inline; error and ' + @@ -7314,8 +6789,6 @@ describe('xmlToIr: flat engine settings on a user task', () => { expect(warnings).toEqual([]); }); - // Revert: drop the `operaton:async` read from readRunSettings, and the - // first row loses asyncBefore and gains the unread-setting warning. it.each([ [ 'operaton:async="true" imports as asyncBefore and names the respelling', @@ -7325,7 +6798,7 @@ describe('xmlToIr: flat engine settings on a user task', () => { { elementId: 'T', category: 'unmappedConstruct', - message: asyncRespelling("'T'"), + message: asyncRespellingMessage("'T'"), }, ], ], @@ -7576,26 +7049,6 @@ ${role('potentialOwner', 'Owner_1', 'managers')} }); }); -describe('xmlToIr: flat engine settings honor the camunda: alias', () => { - const importUserTask = (attrs: string) => - importOnly(oneNodeDoc('userTask', { attrs, doc: dualDoc }), 'userTask'); - - it('reads camunda:asyncBefore, camunda:exclusive, and camunda:candidateGroups', async () => { - const { node: task, warnings } = await importUserTask( - 'camunda:asyncBefore="true" camunda:exclusive="false" ' + - 'camunda:candidateGroups="managers"', - ); - expect(task).toEqual({ - kind: 'userTask', - id: 'T', - asyncBefore: true, - exclusive: false, - candidateGroups: 'managers', - }); - expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); - }); -}); - describe('xmlToIr: flat engine settings on every carrying node kind', () => { const everyKindXml = operatonDefs` @@ -7678,8 +7131,6 @@ describe('xmlToIr: flat engine settings on every carrying node kind', () => { 'operaton:resultVariable, which the engine reads the same.', }; - // Revert: read resultVariable alone in readResultVariable, and the first - // three rows lose the field and gain the unread-setting warning. it.each([ { title: 'an expression-bound service task', @@ -7812,10 +7263,6 @@ describe("xmlToIr: a timer job's lock is read where BpmnParse.parseTimer reads i /'jobPriority' setting on 'Handler' was not imported: the 'on timer' head.*timer job.*continuation job/, ); - // Revert: read the tag alone and the first two rows lose their `false`; - // drop the definition from the consumed table and they warn "not imported"; - // keep the sub-process's own timer keys and the last row's IR carries - // `jobPriority` with no warning. it.each< [string, string, { tag?: string; def?: string; sub?: string }, unknown] >([ @@ -7915,8 +7362,6 @@ describe('xmlToIr: the job settings on a gateway', () => { jobPriority: '7', retryCycle: 'R3/PT10M', }; - // Revert: drop GATEWAY_TAGS from JOB_SETTING_OWNERS, and every settings row - // loses its node fields and gains one 'was not imported' warning per setting. it.each([ { title: 'a split written first carries all five', @@ -7968,7 +7413,7 @@ describe('xmlToIr: the job settings on a gateway', () => { attrs: 'operaton:async="true"', placement: 'first' as const, settings: { asyncBefore: true }, - warnings: [['G', 'unmappedConstruct', asyncRespelling("'G'")]], + warnings: [['G', 'unmappedConstruct', asyncRespellingMessage("'G'")]], }, { title: @@ -8001,8 +7446,6 @@ describe('xmlToIr: the job settings on a gateway', () => { ); }); - // Revert: drop requireIntegerOrExpression from readJobSettings, and both - // rows import the priority as written with no warning. it.each([ ['a decimal', '1.5'], ['a word', 'abc'], @@ -8087,12 +7530,7 @@ describe('xmlToIr: content is consumed only on the owner kind that reads it', () }); }); -/** - * A service task carrying an arbitrary `` body, the - * shared subject of the input/output, listener, and refusal-matrix sections - * below. A service task is an activity, so it carries both the io block and - * execution listeners, and its own `operaton:class` keeps it importable. - */ +/** A class-bound service task carrying the given `` body: an activity, so it takes both the io block and execution listeners. */ const serviceTaskWith = (children: string): string => oneNodeDoc('serviceTask', { id: 'Svc', @@ -8191,25 +7629,6 @@ describe('xmlToIr: input/output parameters', () => { expect(warnings).toEqual([]); }); - it('parameter order is preserved across both directions', async () => { - const { node: task } = await importServiceTask( - ioBlock(` 1 - 2 - 3 - a - b`), - ); - expect(task.inputParameters?.map((p) => p.name)).toEqual([ - 'one', - 'two', - 'three', - ]); - expect(task.outputParameters?.map((p) => p.name)).toEqual([ - 'first', - 'second', - ]); - }); - /** One `` holding one io block, optionally with an output parameter beside the input. */ const IO = ( params = '1', @@ -8218,10 +7637,6 @@ describe('xmlToIr: input/output parameters', () => { ${params} `); - // Revert: take bpmn:IntermediateThrowEvent off the operaton:InputOutput - // row and the hand report out of the throw's definition arm, and the - // throw's block reports twice or not at all; drop an event's row from - // ENGINE_READS_ELSEWHERE and its sentence loses the reader. it('every activity kind reads its own io block, and an event that runs one reports it naming parseActivityInputOutput', async () => { const xml = operatonDefs` @@ -8289,8 +7704,6 @@ describe('xmlToIr: input/output parameters', () => { const TRIGGERED = " with attribute 'triggeredByEvent = true'"; - // Revert: drop the refuseIoMapping call from a mapper, and that row imports - // the block as a warned drop. it.each([ [ "the process's own start", @@ -8370,9 +7783,7 @@ describe('xmlToIr: input/output parameters', () => { ); // `BpmnParseUtil.parseNestedParamValueProvider` reads `element.elements()` - // alone, so body text beside the one nested child never reaches the engine; - // revert: dropping the `text !== undefined` warning branch turns this back - // into the old "carries both body text and a nested value" refusal. + // alone, so body text beside the one nested child never reaches the engine. it('a parameter carrying body text beside one nested child keeps the child and warns on the text', async () => { const { node: task, warnings } = await importServiceTask( ioBlock( @@ -8468,8 +7879,6 @@ ${listener} expect(warnings).toEqual([]); }); - // Revert: drop the process, flow or gateway row from ENGINE_READS_ELSEWHERE, - // and its sentence loses the parser that runs the listener. it('a listener on the process, a flow or a gateway is dropped naming the parser that runs it', async () => { const listener = (event: string): string => extensionElements( @@ -8507,10 +7916,6 @@ ${listener} ]); }); - // Each row's revert: `readListeners`/`resolveListenerBinding` refusing what - // Operaton actually runs (a repeated event, a second attribute, a shadowed - // script, an empty expression) turns the row into a refusal instead of the - // listener list plus warning it pins. it.each([ { case: 'two listeners on one event import in document order, since Operaton appends rather than replaces', @@ -8658,11 +8063,6 @@ describe('xmlToIr: task listeners', () => { ]); }); - // Each row's revert: `readListeners`/`readListenerTimer`/ - // `resolveListenerBinding` refusing what Operaton actually runs (a repeated - // timeout, an empty binding attribute, a timer off a non-timeout event) - // turns the row into a refusal instead of the listener list plus warning it - // pins. it.each([ { case: 'two timeout listeners import in document order, each carrying its own timer, since TaskDefinition.addTimeoutTaskListener keys by id rather than replacing', @@ -8827,21 +8227,21 @@ describe('xmlToIr: a consumed extension child reports its own unread attributes' ]); }); - it('a foreign attribute on the operaton:inputOutput block itself reports', async () => { - const { warnings } = await importServiceTask( - ` + it.each([ + [ + 'a foreign attribute on the operaton:inputOutput block itself reports', + serviceTaskWith( + ` v `, - ); - expect(warnings.map((w) => w.message)).toEqual([ - expect.stringMatching(/'foo:bar' on an operaton:inputOutput/), - ]); - }); - - it('two form fields are told apart by the id their warnings name', async () => { - const xml = oneNodeDoc('userTask', { - id: 'Review', - children: ` + ), + [/'foo:bar' on an operaton:inputOutput/], + ], + [ + 'two form fields are told apart by the id their warnings name', + oneNodeDoc('userTask', { + id: 'Review', + children: ` @@ -8849,19 +8249,18 @@ describe('xmlToIr: a consumed extension child reports its own unread attributes' `, - }); - const { warnings } = await xmlToIr(xml); - expect(warnings.map((w) => w.message)).toEqual([ - expect.stringMatching(/'foo:bar' on an operaton:formField 'approve'/), - expect.stringMatching(/'foo:bar' on an operaton:formField 'comment'/), - ]); - }); - - it('in and out mappings are told apart by the end their warnings name', async () => { - const xml = oneNodeDoc('callActivity', { - id: 'Call', - attrs: 'calledElement="sub"', - children: ` + }), + [ + /'foo:bar' on an operaton:formField 'approve'/, + /'foo:bar' on an operaton:formField 'comment'/, + ], + ], + [ + 'in and out mappings are told apart by the end their warnings name', + oneNodeDoc('callActivity', { + id: 'Call', + attrs: 'calledElement="sub"', + children: ` @@ -8869,14 +8268,19 @@ describe('xmlToIr: a consumed extension child reports its own unread attributes' `, - }); + }), + [ + /'foo:bar' on an operaton:in 'amount'/, + /'foo:bar' on an operaton:in 'customer'/, + /'foo:bar' on an operaton:in '\$\{now\(\)\}'/, + /'foo:bar' on an operaton:out 'verdict'/, + ], + ], + ] as const)('%s', async (_title, xml, expected) => { const { warnings } = await xmlToIr(xml); - expect(warnings.map((w) => w.message)).toEqual([ - expect.stringMatching(/'foo:bar' on an operaton:in 'amount'/), - expect.stringMatching(/'foo:bar' on an operaton:in 'customer'/), - expect.stringMatching(/'foo:bar' on an operaton:in '\$\{now\(\)\}'/), - expect.stringMatching(/'foo:bar' on an operaton:out 'verdict'/), - ]); + expect(warnings.map((w) => w.message)).toEqual( + expected.map((pattern) => expect.stringMatching(pattern)), + ); }); }); @@ -9206,8 +8610,6 @@ describe('xmlToIr: a mail or shell task imports with its fields on the three tag ['businessRuleTask', { element: 'businessRule' }], ] as const; - // Revert: drop the builtin arm from readCodeOrExternalBinding, and every row - // refuses as an unsupported form. it.each([ ...TAGS.flatMap(([tag, element]) => [ { @@ -9308,8 +8710,6 @@ describe('xmlToIr: a service-like task the engine would refuse is refused with i `operaton:type="mail" without ${missing} field, which Operaton refuses ` + `to deploy: "${rule}" (BpmnParse.validateFieldDeclarationsForEmail)`; - // Revert: delete refuseBuiltinShapes, and the first six rows import; delete - // the result-variable check in readServiceTaskBinding, and the last two do. it.each([ [ 'a mail task with cc and text and no to, since cc satisfies nothing', @@ -9518,8 +8918,8 @@ describe('xmlToIr: a listener carries an injected field on the same two bindings }); describe('xmlToIr: a user task names a deployed form by reference', () => { - const importFormRef = (attrs: string, doc = operatonDoc) => - importOnly(oneNodeDoc('userTask', { attrs, doc }), 'userTask'); + const importFormRef = (attrs: string) => + importOnly(oneNodeDoc('userTask', { attrs }), 'userTask'); const bound: readonly [string, string, VersionBinding][] = [ [ @@ -9554,23 +8954,6 @@ describe('xmlToIr: a user task names a deployed form by reference', () => { }, ); - it('the camunda: prefix spells the same form reference', async () => { - const { node, warnings } = await importFormRef( - 'camunda:formRef="review-form" camunda:formRefBinding="version" ' + - 'camunda:formRefVersion="3"', - camundaDoc, - ); - expect(node).toEqual({ - kind: 'userTask', - id: 'T', - formRef: { - key: 'review-form', - binding: { kind: 'version', version: '3' }, - }, - }); - expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); - }); - it.each([ [ 'a version the binding beside it never resolves is reported, and the reference still imports', @@ -9640,8 +9023,6 @@ describe('xmlToIr: a second extension block of one kind refuses, since Element.e `; - // Revert: onlyExtensionElement back to returning the first match, and - // every row imports the first block under a warning. it.each([ [ 'operaton:failedJobRetryTimeCycle', @@ -9784,8 +9165,6 @@ describe('xmlToIr: an initiator and a form on a start that is not the process ow }, ]; - // Revert: read `initiator` and the form off every start again, and both - // rows carry them with no warning. it.each([ [ 'a handler start', @@ -10366,9 +9745,6 @@ ${externalTask('', mapping)} `; - // Revert: refuse a dangling `errorRef` in `readErrorMapping` again, and the - // dangling row refuses; drop the no-`errorRef` warning and skip, and that - // row refuses too. it.each([ [ 'a mapping with no expression', @@ -10610,8 +9986,6 @@ ${body} expect(warnings).toEqual(expected); }); - // Revert: carry the priority as written in withExternalExtras, and both - // rows import with a warning instead of refusing. it.each([ ['a decimal task priority', '1.5'], ['a task priority opening with a digit before its expression', '1 ${x}'], @@ -10627,17 +10001,9 @@ ${body} }); }); -/** - * Every shape of `operaton:inputOutput` block or listener that carries content - * this surface cannot express. Each is semantic loss rather than decoration, - * so each throws before any IR is produced, naming the shape and the element. - * - * Two of them are visible only because the moddle descriptor declares a - * parameter's nested value as a repeating property: with a single-valued one, - * a parameter carrying two nested values and a parameter carrying a stray - * `operaton:entry` both parse to something plausible with no warning at all, - * and no reader could tell. - */ +// The two-nested-values and stray-entry rows are only detectable because the +// moddle descriptor declares a parameter's nested value as a repeating +// property; single-valued, both would parse to something plausible. describe('xmlToIr: the extension-form refusal matrix', () => { /** The service task carries the io block and execution listeners, the user task the task listeners. */ const HOSTS = { Svc: serviceTaskWith, Review: userTaskWith }; @@ -10658,6 +10024,12 @@ describe('xmlToIr: the extension-form refusal matrix', () => { ${body} `); + const unspellable = (subject: string): string => + `${subject} names a variable the script cannot spell (a name is ` + + "letters, digits and '_', with '-' between them, and no keyword), and " + + 'the engine sets the variable under that name, so writing another ' + + 'would change what runs'; + it.each([ { case: 'a parameter carrying two nested values', @@ -10790,11 +10162,7 @@ ${body} children: ioBlock( ` 1`, ), - detail: - "operaton:inputParameter 'my.param' names a variable the script " + - "cannot spell (a name is letters, digits and '_', with '-' " + - 'between them, and no keyword), and the engine sets the variable ' + - 'under that name, so writing another would change what runs', + detail: unspellable("operaton:inputParameter 'my.param'"), }, { case: 'a form field whose id the script cannot spell', @@ -10802,11 +10170,7 @@ ${body} children: ` `, - detail: - "operaton:formField 'my.field' names a variable the script " + - "cannot spell (a name is letters, digits and '_', with '-' " + - 'between them, and no keyword), and the engine sets the variable ' + - 'under that name, so writing another would change what runs', + detail: unspellable("operaton:formField 'my.field'"), }, { case: 'an injected field naming no value slot', @@ -10972,15 +10336,6 @@ describe('xmlToIr: task kinds', () => { }, ); - it('a send task imports as a service task node tagged as a send', async () => { - const { node } = await importOnly( - oneNodeDoc('sendTask', { attrs: 'operaton:class="com.example.Send"' }), - 'serviceTask', - ); - expect(node.element).toBe('send'); - expect(node.binding).toEqual(classBinding('com.example.Send')); - }); - it('a send task with no binding refuses as a send task, not as a service task', async () => { const e = await expectRefusal( xmlToIr(oneNodeDoc('sendTask')), @@ -11015,17 +10370,6 @@ describe('xmlToIr: task kinds', () => { expect(node.resultVariable).toBe('risk'); }); - it('a business rule task bound to code imports that binding instead', async () => { - const { node } = await importOnly( - oneNodeDoc('businessRuleTask', { - attrs: 'operaton:class="com.example.Rate"', - }), - 'serviceTask', - ); - expect(node.element).toBe('businessRule'); - expect(node.binding).toEqual(classBinding('com.example.Rate')); - }); - it('a code-bound business rule task warns about the decision settings it drops', async () => { const { warnings } = await importOnly( oneNodeDoc('businessRuleTask', { @@ -11179,22 +10523,6 @@ describe('xmlToIr: task kinds', () => { }, ); - it.each(KINDS)( - 'multi-instance loop characteristics on a bpmn:%s import as a repetition', - async (tag, kind, binding) => { - const { node, warnings } = await importOnly( - oneNodeDoc(tag, { - attrs: binding, - children: - '', - }), - kind, - ); - expect(node.loop).toEqual({ collection: 'lines' }); - expect(warnings).toEqual([]); - }, - ); - const MANUAL_TASK_WARNING = { elementId: 'M1', category: 'unmappedConstruct', @@ -11232,10 +10560,6 @@ describe('xmlToIr: task kinds', () => { expect(warnings).toEqual([MANUAL_TASK_WARNING]); }); - // Revert: refuse a definition-less intermediate throw again, and the - // import rejects instead of returning the step. - // Revert: drop readIoMapping from the none-throw arm, and the step loses - // its parameter under a second, residual warning. it('an intermediate throw with no definition (the Modeler milestone) imports as a plain step carrying its io mapping, prints and re-parses, warning that history will report it as a task', async () => { const { ir, warnings } = await xmlToIr( oneNodeDoc('intermediateThrowEvent', { @@ -11274,9 +10598,7 @@ describe('xmlToIr: task kinds', () => { expect(source).toContain( 'step M(label: "Milestone reached", asyncBefore: true)', ); - const doc = await parse(source, { validation: true }); - expect(doc.parseResult.parserErrors).toEqual([]); - expect(doc.diagnostics ?? []).toEqual([]); + await expectParses(source); }); it('a document holding all four kinds imports with no warnings at all', async () => { @@ -11344,19 +10666,14 @@ describe('xmlToIr: a block that can be given up', () => { `; interface BlockOptions { - /** The tag the block is written with. Defaults to `transaction`. */ tag?: string; - /** Extra attributes on the block's opening tag. */ attrs?: string; - /** The block's body, written after its start event. */ body?: string; - /** Content written in the process beside the block. */ beside?: string; - /** The `` wrapper. Defaults to {@link operatonDoc}. */ doc?: XmlTag; } - /** `S -> Book -> E`, where `Book` is the block and `beside` sits alongside it. */ + /** `S -> Book -> E`, where `Book` is the block, `body` follows its start, and `beside` sits alongside it. */ const blockDoc = ({ tag = 'transaction', attrs = '', @@ -11587,20 +10904,6 @@ ${cancelBoundary()} }, ); - it('a block writing none of the three warns about nothing', async () => { - const { warnings } = await xmlToIr(blockDoc()); - expect(warnings).toEqual([]); - }); - - it('isForCompensation="true" on the block still refuses', async () => { - const e = await expectRefusal( - xmlToIr(blockDoc({ attrs: 'isForCompensation="true"' })), - UnsupportedEventFeatureError, - IS_FOR_COMPENSATION_DETAIL, - ); - expect(e.elementId).toBe('Book'); - }); - it('an undo handler directly inside the block imports, while one at process level still refuses', async () => { const undoHandler = ` @@ -11780,8 +11083,6 @@ ${routes} }, ); - // Revert: read the dropped `default` reference without `|| undefined`, and - // the `default=""` row refuses the plain route as not the default. it.each([ ['no default', ''], [ @@ -11937,19 +11238,14 @@ ${element} describe('xmlToIr: a wait with several branches', () => { interface WaitOptions { - /** Extra attributes on the wait itself. */ attrs?: string; - /** Children of the wait, which make its tag an open one. */ children?: string; /** The elements the branches begin with, each with the id a flow names. */ branches?: readonly { id: string; element: string }[]; - /** Root declarations before the process. */ roots?: string; - /** Content written in the process beside the branches. */ beside?: string; /** Flows written on top of the ones every branch gets. */ extraFlows?: string; - /** The `` wrapper. Defaults to {@link bpmnDefs}. */ defs?: XmlTag; } @@ -12130,8 +11426,6 @@ ${branches expect(fork.warnings.map(reported)).toEqual(sweepsOn('Fork')); }); - // Revert: delete the asyncAfter throw in mapEventBasedGateway, and both - // rows import the wait with the flag set. it.each([ ['operaton:asyncAfter="true"', operatonDefs], ['camunda:asyncAfter="true"', camundaDefs], @@ -12216,12 +11510,7 @@ ${branches }); }); -/** - * Branches a coverage sweep found no row asserting: each pins the current - * wording (or the current warning list) of one refusal or warning arm named - * elsewhere in this file's own tables. A row's title names the one-line - * source change that would turn it red. - */ +/** One row per refusal or warning arm whose exact wording no table above pins. */ describe('xmlToIr: refusal and warning wording', () => { const potentialOwnerXml = ( id: string, @@ -12261,58 +11550,38 @@ describe('xmlToIr: refusal and warning wording', () => { ); }, ], - [ - 'two bpmn:Error roots sharing a code, both messageless, merge into one declaration', - async () => { - const { ir, warnings } = await xmlToIr( - rootedDoc( - ' \n' + - ' \n', - ' \n' + - ' ', - operatonDefs, - ), - ); - expect(ir.errorDecls).toEqual([{ name: 'DUP', code: 'DUP' }]); - expect(warnings).toEqual([]); - }, - ], - [ - 'two bpmn:Error roots sharing a code, only the second carrying a message, merge with it', - async () => { - const { ir, warnings } = await xmlToIr( - rootedDoc( - ' \n' + - ' \n', - ' \n' + - ' ', - operatonDefs, - ), - ); - expect(ir.errorDecls).toEqual([ + ...( + [ + ['both messageless', '', '', { name: 'DUP', code: 'DUP' }], + [ + 'only the second carrying a message', + '', + 'operaton:errorMessage="Oops"', { name: 'DUP', code: 'DUP', message: 'Oops' }, - ]); - expect(warnings).toEqual([]); - }, - ], - [ - 'two bpmn:Error roots sharing a code and agreeing on the message merge without refusing', + ], + [ + 'agreeing on the message', + 'operaton:errorMessage="Oops"', + 'operaton:errorMessage="Oops"', + { name: 'DUP', code: 'DUP', message: 'Oops' }, + ], + ] as const + ).map(([shape, first, second, decl]): (typeof rows)[number] => [ + `two bpmn:Error roots sharing a code, ${shape}, merge into one declaration`, async () => { const { ir, warnings } = await xmlToIr( rootedDoc( - ' \n' + - ' \n', + ` \n` + + ` \n`, ' \n' + ' ', operatonDefs, ), ); - expect(ir.errorDecls).toEqual([ - { name: 'DUP', code: 'DUP', message: 'Oops' }, - ]); + expect(ir.errorDecls).toEqual([decl]); expect(warnings).toEqual([]); }, - ], + ]), [ 'an unnamed, unreferenced bpmn:message root warns that it cannot be keyed, and is dropped', async () => { @@ -12352,9 +11621,7 @@ describe('xmlToIr: refusal and warning wording', () => { const printed = irToDsl(ir).source; expect(printed).toContain('in local *'); - const doc = await parse(printed, { validation: true }); - expect(doc.parseResult.parserErrors).toEqual([]); - expect(doc.diagnostics ?? []).toEqual([]); + await expectParses(printed); }, ], [ @@ -12388,9 +11655,8 @@ describe('xmlToIr: refusal and warning wording', () => { `, ), }); - // No binding attribute on the listener: this refusal fires reading - // its timer, before a binding is ever resolved, so it pins none of - // that wording. + // No binding on the listener: the timer is read before a binding is + // resolved, so this pins the timer refusal alone. await expectRefusal( xmlToIr(xml), UnsupportedExtensionFormError, @@ -12485,29 +11751,18 @@ describe('xmlToIr: refusal and warning wording', () => { UnsupportedEventFeatureError, ); expect(e.elementId).toBe('CompensationBoundary'); - const marker = 'Write it by hand instead:\n\n'; - const cut = e.detail.indexOf(marker); - expect(cut).toBeGreaterThan(-1); - const rewrite = e.detail.slice(cut + marker.length); - expect(rewrite).toBe( - [ - 'subprocess Compensated_ReserveRoom {', - ' subprocess ReserveRoom {', - ' start RStart', - ' user Hold', - ' end REnd', - ' }', - ' on compensation {', - ' user CancelReservation', - ' }', - '}', - ].join('\n'), - ); - - const wrapped = `process Preview {\n${rewrite}\n}\n`; - const doc = await parse(wrapped, { validation: true }); - expect(doc.parseResult.parserErrors).toEqual([]); - expect(doc.diagnostics ?? []).toEqual([]); + await expectRewrite(e, [ + 'subprocess Compensated_ReserveRoom {', + ' subprocess ReserveRoom {', + ' start RStart', + ' user Hold', + ' end REnd', + ' }', + ' on compensation {', + ' user CancelReservation', + ' }', + '}', + ]); }, ], [ @@ -12625,13 +11880,7 @@ describe('xmlToIr: refusal and warning wording', () => { expect(e.elementId).toBeUndefined(); expect(e.message).toBe( 'The BPMN element bpmn:ComplexGateway is a kind that this tool ' + - 'cannot import. Only start/end events, throws, emits, boundary ' + - 'events, event handlers, plain tasks, user tasks, service ' + - 'tasks, send tasks, receive tasks, business rule tasks, script ' + - 'tasks, exclusive gateways, parallel gateways, inclusive ' + - 'gateways, event-based gateways, embedded subprocesses, ' + - 'attempt blocks, call activities, and sequence flows are ' + - 'supported.', + `cannot import. ${SUPPORTED_KINDS_MESSAGE}`, ); }, ], diff --git a/tests/boundary-events.round-trip.test.ts b/tests/boundary-events.round-trip.test.ts index b437ee5..054cc4c 100644 --- a/tests/boundary-events.round-trip.test.ts +++ b/tests/boundary-events.round-trip.test.ts @@ -1,7 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { Diagnostic } from 'vscode-languageserver-types'; - import type { FlowContainer, FlowElement } from '@bpmn-script/transform'; import { @@ -12,7 +10,12 @@ import { import type { Bounds } from './helpers/di-bounds.js'; import { describeImportFirst } from './helpers/import-first.js'; import { kindOf, subProcess } from './helpers/ir-query.js'; -import { definitionRefOf, errorRoots } from './helpers/xml-query.js'; +import { + definitionRefOf, + errorRoots, + idsOfTag, + messageRoots, +} from './helpers/xml-query.js'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; const rt = roundTripFixture('boundary-events', { @@ -20,11 +23,6 @@ const rt = roundTripFixture('boundary-events', { recompile: 'errors', }); -// The decompiler prints handlers in a trailing group, so its output must never -// raise this. Source that does cannot be re-opened. -const HANDLER_PLACEMENT_DIAGNOSTIC = - 'Event handlers read like catch blocks: move it after the last step of this body.'; - type BoundaryEvent = Extract; function boundaryEvents(container: FlowContainer): BoundaryEvent[] { @@ -72,10 +70,9 @@ const EXPECTED_ATTACHMENTS = [ 'PrintLabel message ExpediteRequested interrupting', ].sort(); -// bpmn-auto-layout places `attachedToRef` children on the host's bottom edge, -// spread evenly: `n` attachers land at `x + width * i/(n+1)` for i in 1..n. -// Measuring against the host's own bounds stops a shape elsewhere on the canvas -// satisfying the check. +// bpmn-auto-layout spreads `n` attachers along the host's bottom edge at +// `x + width * i/(n+1)`; measuring against the host's own bounds keeps a shape +// elsewhere on the canvas from passing. function assertAttachedToHost( bounds: Map, hostId: string, @@ -101,10 +98,8 @@ function assertAttachedToHost( }); } -// Handwritten import-first: MIWG ``/`` children -// and hand-named boundary ids matching nothing the id template would produce. -// Two share the host `InspectCrate` and the trigger kind `error`, so -// re-synthesis necessarily collides and hands one of them the `_2` suffix. +// MIWG-style ``/`` children and boundary ids the +// id template would never produce. const IMPORT_FIRST_BPMN = ` @@ -172,18 +167,6 @@ const IMPORT_FIRST_BPMN = ` `; describe("idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3", () => { - it("every handler block in DSL' trails the body it guards", async () => { - // A boundary handler is walked early, so the orphan sweep does not mistake - // its escape chain for a detached fragment, yet it must print in the - // trailing group: printed in place it emits source the validator rejects. - const { diagnostics } = await rt.validate(rt.dslPrime); - expect( - diagnostics.filter( - (d) => Diagnostic.getMessageString(d) === HANDLER_PLACEMENT_DIAGNOSTIC, - ), - ).toEqual([]); - }); - it('the authored ids survive verbatim at their correct container depth', () => { expect(kindOf(rt.ir3, 'CheckAddress')).toBe('userTask'); expect(kindOf(rt.ir3, 'PackGoods')).toBe('subProcess'); @@ -218,27 +201,13 @@ describe("idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3", () => { }); it('the if inside an escape chain comes back as an if, not as gotos', () => { - // The boundary event is wired to the CFG's virtual entry, so its escape - // chain is reachable and the split inside it has an immediate dominator; - // without that the restructurer could only degrade the branch into jumps. + // The boundary event is wired to the CFG's virtual entry, so the split in + // its escape chain has an immediate dominator; without one the + // restructurer could only degrade the branch into jumps. expect(rt.dslPrime).toContain('if (parcelValue > 500) {'); }); }); -describe('golden generation: the pipeline output matches the frozen .bpmn', () => { - it('each task host carries its boundary event, pinned by attachedToRef', () => { - expect(rt.generatedXml).toContain( - '', - ); - expect(rt.generatedXml).toContain( - '', - ); - expect(rt.generatedXml).toContain( - '', - ); - }); -}); - describeSingleDiagram(rt); describe('DI attachment on the generated .bpmn', () => { @@ -289,57 +258,56 @@ describe('DI attachment on the generated .bpmn', () => { }); }); -describe('root sharing on the frozen .bpmn', () => { - it('the boundary signal and the host-less handler signal share one bpmn:Signal', () => { - const roots = [...rt.frozenXml.matchAll(/ { - const roots = [...rt.frozenXml.matchAll(/ { - const roots = errorRoots(rt.frozenXml, 'PAYMENT_DECLINED'); - expect(roots).toHaveLength(1); - const { id: rootId, message } = roots[0]!; - expect(message).toBe('The payment gateway declined the charge'); - - expect( - definitionRefOf(rt.frozenXml, 'Boundary_ChargePostage_error', 'error'), - ).toBe(rootId); - }); +// `[title, the ids of the one root the frozen .bpmn may carry, the definition +// kind, every element that has to reference it]`. +const SHARED_ROOTS: readonly [ + string, + (xml: string) => string[], + 'error' | 'escalation' | 'message' | 'signal', + readonly string[], +][] = [ + [ + 'the boundary signal and the host-less handler signal share one bpmn:Signal', + (xml) => idsOfTag(xml, 'signal'), + 'signal', + ['Boundary_BookCarrier_signal', 'StrikeNoted'], + ], + [ + 'the escalation thrown inside the sub-process and the one caught on its boundary share one bpmn:Escalation', + (xml) => idsOfTag(xml, 'escalation'), + 'escalation', + ['Oversized', 'Boundary_PackGoods_escalation'], + ], + [ + 'the error a boundary catches gets a root carrying its declared message', + (xml) => { + const roots = errorRoots(xml, 'PAYMENT_DECLINED'); + expect(roots.map((r) => r.message)).toEqual([ + 'The payment gateway declined the charge', + ]); + return roots.map((r) => r.id); + }, + 'error', + ['Boundary_ChargePostage_error'], + ], + [ + 'the message a boundary correlates on gets its own bpmn:Message root', + (xml) => + messageRoots(xml) + .filter((r) => r.name === 'ExpediteRequested') + .map((r) => r.id), + 'message', + ['Boundary_PrintLabel_message'], + ], +]; - it('the message a boundary correlates on gets its own bpmn:Message root', () => { - const roots = [ - ...rt.frozenXml.matchAll( - / { + it.each(SHARED_ROOTS)('%s', (_title, rootsOf, definition, referrers) => { + const roots = rootsOf(rt.frozenXml); expect(roots).toHaveLength(1); - - expect( - definitionRefOf(rt.frozenXml, 'Boundary_PrintLabel_message', 'message'), - ).toBe(roots[0]![1]); + for (const id of referrers) { + expect(definitionRefOf(rt.frozenXml, id, definition), id).toBe(roots[0]); + } }); }); diff --git a/tests/branch-and-race.round-trip.test.ts b/tests/branch-and-race.round-trip.test.ts index ddc29d5..5537c4e 100644 --- a/tests/branch-and-race.round-trip.test.ts +++ b/tests/branch-and-race.round-trip.test.ts @@ -31,9 +31,8 @@ interface Flow { conditioned: boolean; } -// Regex rather than a parser: the tests workspace declares no moddle dependency. -// A flow closes on itself unless it carries a condition, which is the only -// child a flow of this artifact has. +// A flow closes on itself unless it carries a condition, the only child a flow +// of this artifact has. function sequenceFlows(xml: string): Flow[] { const flow = /]*\bsourceRef="([^"]+)" targetRef="([^"]+)"\s*(?:\/>|>([\s\S]*?)<\/bpmn:sequenceFlow>)/g; @@ -45,7 +44,6 @@ function sequenceFlows(xml: string): Flow[] { })); } -/** The `(gateway id, default flow id)` pair of every gateway naming a default. */ function declaredDefaults(xml: string): [string, string][] { return [ ...xml.matchAll(/ fe.kind === kind).length; } +// Every line opening a branch or a wait, so a lost `else`, a bare branch that +// gained a head, or an `await` missing a trigger all show up in order. +function blockHeads(source: string): string[] { + return source + .split('\n') + .map((line) => line.trim()) + .filter((line) => + /^(if |else|\{$|await|message\(|timer\(|signal\(|condition\()/.test(line), + ); +} + const RACE_GATEWAYS = [ 'Gateway_order-handling_5_race', 'Gateway_order-handling_6_race', @@ -95,16 +104,22 @@ describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { }); it("the decompiled DSL' writes every branch head and both wait forms back", () => { - expect(rt.dslPrime).toContain('if (orderValue > 10000) {'); - expect(rt.dslPrime).toContain('if (!stockShort) {'); - expect(rt.dslPrime).toContain('else {'); - expect(rt.dslPrime).toContain('if (overseas) {'); - expect(rt.dslPrime.match(/await \{/g)).toHaveLength(2); - expect(rt.dslPrime).toContain('message("PaymentReceived") {'); - expect(rt.dslPrime).toContain('timer("P3D", asyncBefore: true) {'); - expect(rt.dslPrime).toContain('signal("StockArrived") {'); - expect(rt.dslPrime).toContain('condition(stockShort) {'); - expect(rt.dslPrime).toContain('await message("CarrierBooked")'); + expect(blockHeads(rt.dslPrime)).toEqual([ + 'if (orderValue > 10000) {', + 'if (!stockShort) {', + 'else {', + 'if (overseas) {', + '{', + '{', + '{', + 'await {', + 'message("PaymentReceived") {', + 'timer("P3D", asyncBefore: true) {', + 'await {', + 'signal("StockArrived") {', + 'condition(stockShort) {', + 'await message("CarrierBooked")', + ]); }); it("every edge folds into a block, so DSL' jumps nowhere", () => { @@ -195,13 +210,10 @@ describe('gateway shape pins on the frozen .bpmn', () => { describeNoOverlappingShapes(rt); -// A fork shape the golden pair above cannot carry: a fallback beside a branch -// that carries no condition, so the fallback is left nothing to pick up. No -// script authors it, the validator refusing the `else` it prints, so the -// fixture is built as IR and taken in through the XML the way a modeled -// diagram arrives. Import to print to re-read, with both channels asserted at -// once: the report is what stands between the author and an error in the -// editor with nothing behind it. +// A fork the golden pair cannot carry: a fallback beside a branch with no +// condition, so nothing is ever left for the fallback. No script authors it +// (the validator refuses the `else` it prints), so the fixture is built as IR +// and taken in through XML, the way a modeled diagram arrives. const DEAD_FALLBACK_FLOW = 'Flow_Gateway_1_fork_default'; function inclusiveForkIr(fallbackTarget: string): BpmnProcess { @@ -282,11 +294,7 @@ describe('a fork whose fallback can never fire', () => { }); it('reports it because the printed source is what the validator refuses', () => { - // The two halves of the contract, pinned together: printing this shape - // draws exactly the refusal the report warns about, so a report dropped - // here leaves the refusal unexplained. - expect(errors).toHaveLength(1); - expect(errors[0]).toContain('could never run'); + expect(errors).toEqual([expect.stringContaining('could never run')]); }); it('says nothing when the fallback runs straight into the merge', async () => { diff --git a/tests/call-activity.round-trip.test.ts b/tests/call-activity.round-trip.test.ts index ef53299..2832771 100644 --- a/tests/call-activity.round-trip.test.ts +++ b/tests/call-activity.round-trip.test.ts @@ -7,26 +7,19 @@ import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { xmlToIr, astToIr } from '@bpmn-script/transform'; import type { - BpmnProcess, FlowContainer, CallActivity, CallVariableMapper, - ImportWarning, } from '@bpmn-script/transform'; -import { camundaAliasWarning } from './helpers/import-first.js'; +import { + camundaAliasWarning, + describeImportFirst, +} from './helpers/import-first.js'; import { normalizeIr } from './helpers/normalize-ir.js'; import { idsOf, subProcess as findSubProcess } from './helpers/ir-query.js'; -import { - parse, - parseToAst, - printDsl, - roundTrip, - roundTripOf, - validate, -} from './helpers/pipeline.js'; +import { roundTrip, roundTripOf, validate } from './helpers/pipeline.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -47,108 +40,76 @@ function findCallActivity(container: FlowContainer, id: string): CallActivity { return el; } -describe('round-trip: minimal call (process only)', () => { - const MINIMAL_CALL_SRC = [ - 'process minimal-call {', - ' start Start', - ' call InvokeSub(process: "invoice-approval")', - ' end End', - '}', - '', - ].join('\n'); - - const run = roundTripOf(MINIMAL_CALL_SRC); - - it('desugars to a callActivity carrying only calledElement', () => { - const call = findCallActivity(run.ir1, 'InvokeSub'); - expect(call.calledElement).toBe('invoice-approval'); - expect(call.binding).toBeUndefined(); - expect(call.businessKey).toBeUndefined(); - expect(call.inMappings).toBeUndefined(); - expect(call.outMappings).toBeUndefined(); - }); - - it('generates calledElement and NO binding attributes and NO extensionElements', () => { - expect(run.xml).toContain('calledElement="invoice-approval"'); - expect(run.xml).not.toContain('calledElementBinding'); - expect(run.xml).not.toContain('calledElementVersion'); - expect(run.xml).not.toContain('extensionElements'); - }); - - it('re-emits the same one-attribute call and re-parses with zero errors', async () => { - expect(run.dsl).toContain('call InvokeSub(process: "invoice-approval")'); - const document = await parse(run.dsl); - expect(document.parseResult.parserErrors).toHaveLength(0); - }); -}); - -describe('round-trip: call activity with a deployment binding', () => { - const DEPLOYMENT_BINDING_SRC = [ - 'process call-deployment-binding {', - ' start Start', - ' call InvokeSub(process: "invoice-approval", binding: deployment)', - ' end End', - '}', - '', - ].join('\n'); - - const run = roundTripOf(DEPLOYMENT_BINDING_SRC); - - it('desugars `binding: deployment` to { kind: "deployment" }', () => { - expect(findCallActivity(run.ir1, 'InvokeSub').binding).toEqual({ - kind: 'deployment', - }); - }); - - it('generates operaton:calledElementBinding="deployment" and no version attribute', () => { - expect(run.xml).toContain('operaton:calledElementBinding="deployment"'); - expect(run.xml).not.toContain('calledElementVersion'); - }); - - it('re-imports to the same binding and re-emits `binding: deployment`', async () => { - expect(findCallActivity(run.ir2, 'InvokeSub').binding).toEqual({ - kind: 'deployment', - }); - expect(run.dsl).toContain('binding: deployment'); - const document = await parse(run.dsl); - expect(document.parseResult.parserErrors).toHaveLength(0); - }); -}); - -describe('round-trip: call activity with a pinned version', () => { - const PINNED_VERSION_SRC = [ - 'process call-pinned-version {', - ' start Start', - ' call InvokeSub(process: "invoice-approval", version: 3)', - ' end End', - '}', +// `[what the call carries, the authored and printed settings after `process`, +// the IR binding, the Operaton attributes the XML carries, the ones it must not]`. +const BINDING_ROWS: readonly [ + string, + string, + CallActivity['binding'], + readonly string[], + readonly string[], +][] = [ + [ + 'no binding', '', - ].join('\n'); - - const run = roundTripOf(PINNED_VERSION_SRC); - - it('desugars `version = 3` to { kind: "version", version: "3" }', () => { - expect(findCallActivity(run.ir1, 'InvokeSub').binding).toEqual({ - kind: 'version', - version: '3', - }); - }); + undefined, + ['calledElement="invoice-approval"'], + ['calledElementBinding', 'calledElementVersion', 'extensionElements'], + ], + [ + 'a deployment binding', + ', binding: deployment', + { kind: 'deployment' }, + ['operaton:calledElementBinding="deployment"'], + ['calledElementVersion'], + ], + [ + 'a pinned version', + ', version: 3', + { kind: 'version', version: '3' }, + [ + 'operaton:calledElementBinding="version"', + 'operaton:calledElementVersion="3"', + ], + [], + ], +]; - it('generates calledElementBinding="version" and calledElementVersion="3"', () => { - expect(run.xml).toContain('operaton:calledElementBinding="version"'); - expect(run.xml).toContain('operaton:calledElementVersion="3"'); - }); +describe.each(BINDING_ROWS)( + 'round-trip: a call with %s', + (_label, settings, binding, written, notWritten) => { + const CALL = `call InvokeSub(process: "invoice-approval"${settings})`; + + const run = roundTripOf( + [ + 'process call-binding {', + ' start Start', + ` ${CALL}`, + ' end End', + '}', + '', + ].join('\n'), + ); - it('re-imports to the same binding and re-emits `version = 3`', async () => { - expect(findCallActivity(run.ir2, 'InvokeSub').binding).toEqual({ - kind: 'version', - version: '3', + it('desugars to the binding alone, writes exactly its attributes, and prints the call back', () => { + expect(findCallActivity(run.ir1, 'InvokeSub')).toEqual({ + kind: 'callActivity', + id: 'InvokeSub', + calledElement: 'invoice-approval', + binding, + }); + for (const attribute of written) expect(run.xml).toContain(attribute); + for (const attribute of notWritten) { + expect(run.xml).not.toContain(attribute); + } + expect(run.warnings).toEqual([]); + expect(findCallActivity(run.ir2, 'InvokeSub')).toEqual( + findCallActivity(run.ir1, 'InvokeSub'), + ); + expect(run.dsl).toContain(CALL); }); - expect(run.dsl).toContain('version: 3'); - const document = await parse(run.dsl); - expect(document.parseResult.parserErrors).toHaveLength(0); - }); -}); + }, +); describe('round-trip: call activity with businessKey and every mapping shape', () => { // The validator checks `in` sources against caller scope, so they are declared @@ -319,11 +280,9 @@ describe('round-trip: call activity nested inside a subprocess', () => { } }); - it('the re-emitted DSL reconstructs the nested `subprocess { call ... }` shape and re-parses cleanly', async () => { + it('the re-emitted DSL reconstructs the nested `subprocess { call ... }` shape', () => { expect(run.dsl).toContain('subprocess Payment(label: "Handle payment") {'); expect(run.dsl).toContain('call ChargeCustomer'); - const document = await parse(run.dsl); - expect(document.parseResult.parserErrors).toHaveLength(0); }); }); @@ -360,7 +319,7 @@ describe('round-trip: goto targeting a call activity', () => { } }); - it("a second round-trip (DSL' -> IR3) is normalized-equal to the first, and re-parses with zero errors", async () => { + it("a second round-trip (DSL' -> IR3) is normalized-equal to the first", () => { // irToDsl reconstructs the goto/fallthrough convergence as `if`/`else` // rather than replaying the literal `goto`, and re-desugaring that grows a // pass-through join, so compare through normalizeIr. @@ -368,16 +327,12 @@ describe('round-trip: goto targeting a call activity', () => { 'invoice-approval', ); expect(normalizeIr(run.ir3)).toEqual(normalizeIr(run.ir1)); - - const document = await parse(run.dsl); - expect(document.parseResult.parserErrors).toHaveLength(0); }); }); -describe('round-trip: import-first, with interleaved mappings and the camunda: binding alias', () => { - // The `name` differs from the name humanized from the id, so it survives as a - // real label instead of being dropped as derivable. - const HANDWRITTEN_BPMN = ` +// The `name` differs from the name humanized from the id, so it survives as a +// real label instead of being dropped as derivable. +const HANDWRITTEN_BPMN = ` `; - let irFirstImport: BpmnProcess; - let importWarnings: ImportWarning[]; - let dsl: string; - let irSecondImport: BpmnProcess; - - beforeAll(async () => { - const first = await xmlToIr(HANDWRITTEN_BPMN); - irFirstImport = first.ir; - importWarnings = first.warnings; - dsl = printDsl(irFirstImport); - irSecondImport = astToIr(await parseToAst(dsl)); - }); - - it('imports the alias binding and the interleaved mappings with exactly the one namespace-alias warning', () => { - expect(importWarnings).toEqual([camundaAliasWarning('call-import-demo')]); - const call = findCallActivity(irFirstImport, 'ReviewApprovalCall'); - expect(call.binding).toEqual({ kind: 'deployment' }); - expect(call.inMappings).toEqual([ - { kind: 'variable', source: 'amount', target: 'invoiceAmount' }, - { - kind: 'expression', - sourceExpression: '${amount * 2}', - target: 'doubledAmount', - }, - ]); - expect(call.outMappings).toEqual([ - { kind: 'variable', source: 'approved', target: 'wasApproved' }, - { - kind: 'expression', - sourceExpression: '${approved ? 1 : 0}', - target: 'approvedFlag', - }, - ]); - }); - - it('re-parsing and re-desugaring the emitted DSL is normalized-equal to the first import', () => { - expect(normalizeIr(irSecondImport)).toEqual(normalizeIr(irFirstImport)); - }); +describeImportFirst( + 'interleaved mappings and the camunda: binding alias', + HANDWRITTEN_BPMN, + (first) => { + it('imports the alias binding and the interleaved mappings', () => { + const call = findCallActivity(first.ir, 'ReviewApprovalCall'); + expect(call.binding).toEqual({ kind: 'deployment' }); + expect(call.inMappings).toEqual([ + { kind: 'variable', source: 'amount', target: 'invoiceAmount' }, + { + kind: 'expression', + sourceExpression: '${amount * 2}', + target: 'doubledAmount', + }, + ]); + expect(call.outMappings).toEqual([ + { kind: 'variable', source: 'approved', target: 'wasApproved' }, + { + kind: 'expression', + sourceExpression: '${approved ? 1 : 0}', + target: 'approvedFlag', + }, + ]); + }); - it('the emitted call canonically reorders (all `in`s, then all `out`s) and keeps the alias-normalized binding', () => { - expect(dsl).toContain( - 'call ReviewApprovalCall(label: "Get invoice sign-off", ' + - 'process: "invoice-approval", binding: deployment, ' + - 'businessKey: "${orderId}") {\n' + - ' in invoiceAmount = amount\n' + - ' in doubledAmount = "${amount * 2}"\n' + - ' out wasApproved = approved\n' + - ' out approvedFlag = "${approved ? 1 : 0}"\n' + - ' }', - ); - }); -}); + it('the emitted call canonically reorders (all `in`s, then all `out`s) and keeps the alias-normalized binding', () => { + expect(first.dsl).toContain( + 'call ReviewApprovalCall(label: "Get invoice sign-off", ' + + 'process: "invoice-approval", binding: deployment, ' + + 'businessKey: "${orderId}") {\n' + + ' in invoiceAmount = amount\n' + + ' in doubledAmount = "${amount * 2}"\n' + + ' out wasApproved = approved\n' + + ' out approvedFlag = "${approved ? 1 : 0}"\n' + + ' }', + ); + }); + }, + [camundaAliasWarning('call-import-demo')], +); describe('example: purchasing.bpmnscript calls the invoice-approval example by id', () => { let source: string; @@ -493,8 +436,5 @@ describe('example: purchasing.bpmnscript calls the invoice-approval example by i ); expect(run.xml).toContain('calledElement="invoice-approval"'); expect(run.warnings).toEqual([]); - - const document = await parse(run.dsl); - expect(document.parseResult.parserErrors).toHaveLength(0); }); }); diff --git a/tests/compensation.round-trip.test.ts b/tests/compensation.round-trip.test.ts index ba05b46..8faed68 100644 --- a/tests/compensation.round-trip.test.ts +++ b/tests/compensation.round-trip.test.ts @@ -1,9 +1,6 @@ -// Why the restructured DSL stays validator-clean: every throw and emit is -// named, so the printer emits the authored id instead of a `Throw_` one -// that would trip the reserved-name check, and the guards read variables -// declared on the start form. The `var compensation` declaration does not -// survive the round trip, but its only reference sits inside an opaque service -// expression the validator never parses. +// The guards read start-form fields, and the `var compensation` declaration +// that does not survive the round trip is referenced only inside an opaque +// service expression, so DSL' validates clean. import { describe, it, expect } from 'vitest'; @@ -35,10 +32,9 @@ function startEventOpenTag(xml: string, id: string): string | undefined { return new RegExp(`]*>`).exec(xml)?.[0]; } -// Handwritten import-first. The compensation end event carries an explicit -// `waitForCompletion="true"`, the moddle default, accepted on import and then -// dropped as unmodeled. Every task label differs from the name humanized from -// its id, so the importer keeps it. +// The compensation end carries an explicit `waitForCompletion="true"`, the +// moddle default, accepted on import and dropped as unmodeled. Every task label +// differs from the name humanized from its id, so the importer keeps it. const IMPORT_FIRST_BPMN = ` @@ -138,12 +134,9 @@ describe('compensation shape pins on the frozen .bpmn', () => { }); it('every bpmn:compensateEventDefinition is attribute-less', () => { - const all = - rt.frozenXml.match(/]*>/g) ?? []; - expect(all).toHaveLength(4); - for (const tag of all) { - expect(tag).toBe(''); - } + expect( + rt.frozenXml.match(/]*>/g), + ).toEqual(Array(4).fill('')); }); it('each undo block is a triggeredByEvent sub-process whose start is interrupting', () => { diff --git a/tests/documentation.round-trip.test.ts b/tests/documentation.round-trip.test.ts index f0aa540..56cd8bb 100644 --- a/tests/documentation.round-trip.test.ts +++ b/tests/documentation.round-trip.test.ts @@ -1,7 +1,5 @@ -// The three transform suites each prove one hop; this pair is where the four -// compose. A documentation string is inert, so nothing about the process looks -// different when one stops travelling: only a value read back at every hop -// catches that. +// A documentation string is inert, so a process whose text stopped travelling +// looks unchanged; only reading the value back at every hop catches that. import { describe, it, expect } from 'vitest'; diff --git a/tests/e2e/booking-attempt.test.ts b/tests/e2e/booking-attempt.test.ts index f3e8fd2..60c98bd 100644 --- a/tests/e2e/booking-attempt.test.ts +++ b/tests/e2e/booking-attempt.test.ts @@ -53,7 +53,6 @@ describe.skipIf(SKIP)( return { processInstanceId, activityIds }; } - // The block's own history entry, read once it satisfies `settled`. async function blockInstance( processInstanceId: string, settled: (activity: HistoricActivityInstance) => boolean, diff --git a/tests/e2e/branch-and-race.test.ts b/tests/e2e/branch-and-race.test.ts index a6dc59f..063de7c 100644 --- a/tests/e2e/branch-and-race.test.ts +++ b/tests/e2e/branch-and-race.test.ts @@ -12,6 +12,7 @@ import { engineGet, eventSubscriptions, historicActivities, + jobsOfInstance, waitFor, waitForTaskId, waitForTaskKeys, @@ -28,19 +29,6 @@ const MESSAGE_CATCH = 'Catch_order-dispatch_2_b0'; const FORK_GATEWAY = 'Gateway_order-dispatch_1_fork'; const JOIN_GATEWAY = 'Gateway_order-dispatch_1_join'; -// One engine serves every instance this file starts, so a job query keyed on -// the definition would count the timers of the other tests too. -async function jobsOfInstance( - fixture: FixtureAdapter, - processInstanceId: string, -): Promise> { - return engineGet>( - fixture, - `/engine-rest/job?processInstanceId=${encodeURIComponent(processInstanceId)}`, - `jobsOfInstance(${processInstanceId})`, - ); -} - describe.skipIf(SKIP)( 'E2E: an inclusive fork and a race on Spring Boot Operaton', () => { @@ -54,8 +42,6 @@ describe.skipIf(SKIP)( await fixture?.stop(); }, ENGINE_STOP_TIMEOUT_MS); - // Starts an instance and walks it past the fork, so the race assertions - // begin from a token sitting at the event-based gateway. async function reachRace(): Promise { const { processInstanceId } = await fixture.startProcess(PROCESS_KEY, { amount: 100, diff --git a/tests/e2e/deploy-sweep.test.ts b/tests/e2e/deploy-sweep.test.ts index 958882f..4f7cc0e 100644 --- a/tests/e2e/deploy-sweep.test.ts +++ b/tests/e2e/deploy-sweep.test.ts @@ -1,7 +1,7 @@ // Every golden, fixture and example on the engine, in one boot: once as the -// file is written, and once rebuilt from the tool's own print of it. The -// second table is where a defect of the first kind hides: a deployable file -// whose print compiles back to one the engine refuses. +// file is written, and once rebuilt from the tool's own print of it, where a +// deployable file whose print compiles back to one the engine refuses would +// hide. // // Each row deploys in a tenant of its own, because // BpmnDeployer.addMessageStartEventSubscription refuses a second message start @@ -92,7 +92,7 @@ describe.skipIf(SKIP)('E2E: every golden, fixture and example deploys', () => { buildExample(row.dslPath, row.xmlPath); } } - fixture = await startFixture('spring-boot'); + fixture = await startFixture(); }, ENGINE_BOOT_TIMEOUT_MS); afterAll(async () => { diff --git a/tests/e2e/engine-extensions.test.ts b/tests/e2e/engine-extensions.test.ts index f91aefb..db99e56 100644 --- a/tests/e2e/engine-extensions.test.ts +++ b/tests/e2e/engine-extensions.test.ts @@ -1,11 +1,7 @@ // A listener the engine never invokes, an input parameter it ignores, an // injected field that never reaches its delegate, and an `asyncBefore` that // does not break the transaction all compile to the same well-formed file, so -// no XML-against-XML test can tell them apart. This suite boots a real Operaton -// and reads back what it did: the markers the listeners wrote, the values the -// engine set on each bean before invoking it, the values the service task -// resolved from its mapped parameters, the form reference it parsed off the -// user task, and the job the async continuation parked the token on. +// only a real Operaton can tell them apart. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -74,9 +70,9 @@ describe.skipIf(SKIP)( return (await variablesOf(processInstanceId))[name]?.value; } - // In the order the engine fired them. An instance whose listeners never ran - // holds no such variable and gets an empty list, so the assertion that - // follows reports the missing marker rather than a read error. + // In firing order; an instance whose listeners never ran holds no such + // variable and gets an empty list, so the assertion that follows names the + // missing marker rather than a read error. async function listenerMarkers( processInstanceId: string, ): Promise { @@ -99,9 +95,9 @@ describe.skipIf(SKIP)( ); } - // Returns once the instance reaches the user task, the one wait state on the - // path. Everything before it runs in the starting transaction, so the - // listener markers and mapped parameters are settled when this resolves. + // The user task is the one wait state on the path, and everything before + // it runs in the starting transaction, so the listener markers and mapped + // parameters are settled when this resolves. async function startAndReachUserTask(): Promise<{ processInstanceId: string; taskId: string; @@ -150,12 +146,11 @@ describe.skipIf(SKIP)( // Null where the binding carried no field, so a field that leaked onto // the wrong bean fails as loudly as one that never arrived. The start - // listener's value is the instance id the expression resolved to, which - // nothing in the deployment could have carried: had the engine been - // handed the literal instead, the marker would read `${...}` verbatim. - // The delegate's value runs the reverse check: it holds a `${...}` the - // engine would evaluate to `recorded-2` had it arrived in the expression - // slot, so it reads back verbatim only while the literal slot is used. + // listener's value is the instance id its expression resolved to, which + // nothing in the deployment could have carried literally; the delegate's + // holds a `${...}` the engine would have evaluated to `recorded-2` had it + // arrived in the expression slot, so it reads back verbatim only while + // the literal slot is used. expect(markers.map((marker) => marker.split(':')[2] ?? null)).toEqual([ processInstanceId, 'recorded-${1+1}', diff --git a/tests/e2e/event-positions.test.ts b/tests/e2e/event-positions.test.ts index 8aa9bfe..cb29576 100644 --- a/tests/e2e/event-positions.test.ts +++ b/tests/e2e/event-positions.test.ts @@ -1,13 +1,11 @@ -// Three ways of entering a process that no compiled document shows: a message +// Ways in and out of a process that no compiled document shows: a message // correlated with no instance to aim at, a signal broadcast to whatever -// subscribed at deployment, and a timer job parked until something fires it. -// The terminate end is here for the same reason: what separates it from a -// plain end is that it stops a sibling branch still parked on its own task. -// The audit timer is dated 2099, so only the test can fire it. -// A fourth way, entering the same process by two different starts, checks that -// each start is a real entry and not a pass-through the other flows into. -// A link pair is a fifth: the token leaves a throw with no drawn flow and -// appears at the catch of the same name, which no compiled document draws. +// subscribed at deployment, a timer job parked until something fires it (the +// audit timer is dated 2099, so only the test can), two starts that are each a +// real entry rather than a pass-through the other flows into, and a link pair +// whose token leaves the throw with no drawn flow. The terminate end is here +// because what separates it from a plain end is that it stops a sibling branch +// still parked on its own task. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; diff --git a/tests/e2e/forms-and-external-tasks.test.ts b/tests/e2e/forms-and-external-tasks.test.ts index 0f886aa..3f702ea 100644 --- a/tests/e2e/forms-and-external-tasks.test.ts +++ b/tests/e2e/forms-and-external-tasks.test.ts @@ -73,7 +73,6 @@ describe.skipIf(SKIP)('E2E: forms and external tasks on Operaton', () => { await fixture?.stop(); }, ENGINE_STOP_TIMEOUT_MS); - // One instance per journey, locked as a worker would lock it. async function startAndFetchCharge() { const businessKey = randomUUID(); const processInstanceId = await startWithBusinessKey( diff --git a/tests/e2e/invoice-approval.test.ts b/tests/e2e/invoice-approval.test.ts index 49c6c19..93c76ef 100644 --- a/tests/e2e/invoice-approval.test.ts +++ b/tests/e2e/invoice-approval.test.ts @@ -26,7 +26,6 @@ const CLI_ENTRY = path.resolve(__dirname, '../../packages/cli/bin/cli.js'); describe.skipIf(SKIP)('E2E: invoice-approval on Spring Boot Operaton', () => { let fixture: FixtureAdapter; - // One deployment serves every case. beforeAll(async () => { fixture = await deployExamples('invoice-approval'); }, ENGINE_BOOT_TIMEOUT_MS); @@ -90,9 +89,8 @@ describe.skipIf(SKIP)('E2E: invoice-approval on Spring Boot Operaton', () => { // The fixture's service task carries no execution binding, so `xmlToIr` // rejects it with `UnsupportedServiceTaskFormError` and the CLI exits 1. - // Checking exit status and stderr content, rather than just that - // `execFileSync` threw, is what tells a real refusal apart from the CLI - // never having run at all (wrong path, spawn failure, timeout). + // Checking status and stderr, not just that `execFileSync` threw, tells a + // real refusal apart from the CLI never having run at all. it('refuses unsupported service-task form', () => { const badBpmnPath = path.resolve( __dirname, diff --git a/tests/e2e/job-settings-and-mail.test.ts b/tests/e2e/job-settings-and-mail.test.ts index bd01c74..e9ca08a 100644 --- a/tests/e2e/job-settings-and-mail.test.ts +++ b/tests/e2e/job-settings-and-mail.test.ts @@ -1,11 +1,6 @@ // A job setting on a gateway or on a loop element, and a shell task the engine // runs itself, compile to well-formed XML that shows nothing of what the -// engine does with them. This suite boots a real Operaton and reads back what -// it did: the jobs an async join parks, one per arriving branch; the three -// jobs a repetition of three parks with `runAsyncBefore`, one per run and none -// around the repetition; the output and exit code a shell `echo` wrote into -// the variables its fields name; and a mail task the engine builds at -// deployment, beside one it refuses for want of a body. +// engine does with them; this suite reads back what a real Operaton did. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdirSync, writeFileSync } from 'node:fs'; @@ -83,8 +78,6 @@ describe.skipIf(SKIP)('E2E: job settings, shell and mail tasks', () => { await fixture?.stop(); }, ENGINE_STOP_TIMEOUT_MS); - // The definition and suspension state of each job once the instance holds - // the expected number of them. async function parkedJobs( processInstanceId: string, count: number, diff --git a/tests/e2e/loan-approval-kopp.test.ts b/tests/e2e/loan-approval-kopp.test.ts index 56c38d4..4819cfc 100644 --- a/tests/e2e/loan-approval-kopp.test.ts +++ b/tests/e2e/loan-approval-kopp.test.ts @@ -69,7 +69,6 @@ describe.skipIf(SKIP)('E2E: loan-approval-kopp on Spring Boot Operaton', () => { const assess = pending.find((t) => t.taskDefinitionKey === 'AssessRisk'); expect(assess).toBeDefined(); - // Submit the `assessorRes` form field, exactly as Tasklist would. await fixture.completeTask(assess!.id, { assessorRes: 'low' }); const remaining = await waitForTasks( diff --git a/tests/e2e/loan-approval.test.ts b/tests/e2e/loan-approval.test.ts index 3a11ac6..294559e 100644 --- a/tests/e2e/loan-approval.test.ts +++ b/tests/e2e/loan-approval.test.ts @@ -13,7 +13,6 @@ import { } from '../helpers/e2e-fixture.js'; import { waitForTasks } from '../helpers/engine-rest.js'; -// Read off the compiled IR, so it holds without an engine. describe('Always-on guard: loan-approval.bpmnscript migration', () => { it('no longer contains the InitDelegate default-seeding service task', async () => { const ir = await irOfExample('loan-approval'); @@ -89,7 +88,6 @@ describe.skipIf(SKIP)('E2E: loan-approval on Spring Boot Operaton', () => { expect(pending).toHaveLength(1); expect(pending[0]!.taskDefinitionKey).toBe('Approve'); - // Submit the `approved` form field, exactly as Tasklist would. await fixture.completeTask(pending[0]!.id, { approved: true }); const remaining = await waitForTasks( diff --git a/tests/e2e/parallel-approval.test.ts b/tests/e2e/parallel-approval.test.ts index 4e5886a..cebe883 100644 --- a/tests/e2e/parallel-approval.test.ts +++ b/tests/e2e/parallel-approval.test.ts @@ -38,7 +38,6 @@ describe('Always-on guard: parallel-approval.bpmnscript desugars to parallelGate describe.skipIf(SKIP)('E2E: parallel-approval on Spring Boot Operaton', () => { let fixture: FixtureAdapter; - // One deployment serves every case. beforeAll(async () => { fixture = await deployExamples('parallel-approval'); }, ENGINE_BOOT_TIMEOUT_MS); diff --git a/tests/e2e/repetition.test.ts b/tests/e2e/repetition.test.ts index 697c21b..7a42d2e 100644 --- a/tests/e2e/repetition.test.ts +++ b/tests/e2e/repetition.test.ts @@ -30,8 +30,6 @@ describe.skipIf(SKIP)('E2E: repetition on Spring Boot Operaton', () => { await fixture?.stop(); }, ENGINE_STOP_TIMEOUT_MS); - // The open tasks of one activity, once the engine has offered at least `count` - // of them. async function tasksOf( instanceId: string, key: string, @@ -46,8 +44,6 @@ describe.skipIf(SKIP)('E2E: repetition on Spring Boot Operaton', () => { return tasks.filter((task) => task.taskDefinitionKey === key); } - // Completes every open run of an activity, which is what a parallel - // repetition offers all at once. async function completeAll( instanceId: string, key: string, @@ -58,9 +54,6 @@ describe.skipIf(SKIP)('E2E: repetition on Spring Boot Operaton', () => { } } - // Completes `runs` runs of an activity one at a time, checking on every run - // that the engine offered exactly one, and returning the task id each run was - // offered under. async function completeInTurn( instanceId: string, key: string, @@ -79,7 +72,6 @@ describe.skipIf(SKIP)('E2E: repetition on Spring Boot Operaton', () => { return ids; } - // The list entry bound to one run, read off the execution the task hangs on. // Values stay serialized so the read never depends on how the engine stored // the list itself. async function boundElement(taskId: string): Promise { @@ -91,8 +83,6 @@ describe.skipIf(SKIP)('E2E: repetition on Spring Boot Operaton', () => { return variables.line?.value; } - // Walks a fresh instance to the tasks the review sub-process opens, so every - // assertion past the first two starts from the same known point. async function walkToReview(): Promise { const { processInstanceId } = await fixture.startProcess(PROCESS_KEY, {}); await completeAll(processInstanceId, 'CollectApprovals', 3); diff --git a/tests/e2e/service-boundary-and-compensation.test.ts b/tests/e2e/service-boundary-and-compensation.test.ts index 1920c9f..25bf6b2 100644 --- a/tests/e2e/service-boundary-and-compensation.test.ts +++ b/tests/e2e/service-boundary-and-compensation.test.ts @@ -1,7 +1,7 @@ // Only a real engine decides whether an interrupting error boundary on a // service task catches the `BpmnError` its delegate throws, cancels the host, -// and moves the token onto the escape path. Each path is steered by a boolean -// process variable the shared conditional delegate reads, never by a clock or a +// and moves the token onto the escape path; each path is steered by a boolean +// variable the shared conditional delegate reads, never by a clock or a // correlated message. // // The compensation half covers the other route into the undo machinery: an @@ -34,7 +34,6 @@ describe.skipIf(SKIP)( () => { let fixture: FixtureAdapter; - // Both examples are deployed into one container boot. beforeAll(async () => { fixture = await deployExamples( CHARGE_PROCESS_KEY, diff --git a/tests/engine-attributes.round-trip.test.ts b/tests/engine-attributes.round-trip.test.ts index af907b1..9928bd1 100644 --- a/tests/engine-attributes.round-trip.test.ts +++ b/tests/engine-attributes.round-trip.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest'; +import { ENGINE_KEYS, type EngineKey } from '@bpmn-script/language'; import { isGateway } from '@bpmn-script/transform'; import type { EngineAttributes, FlowElement } from '@bpmn-script/transform'; @@ -17,10 +18,8 @@ const rt = roundTripFixture('engine-attributes', { recompile: 'clean', }); -// Asserted as a whole rather than key by key, so a value that stops travelling -// and a value that appears out of nowhere both fail. `historyTimeToLive` is -// authored as something other than the exporter's own default on purpose: at -// `P30D` the importer reads it as unwritten and the hop would prove nothing. +// `historyTimeToLive` is deliberately not the exporter's default: at `P30D` +// the importer reads it as unwritten and the hop proves nothing. const PROCESS_HEADER = { versionTag: '3.1.0', historyTimeToLive: 'P90D', @@ -57,9 +56,8 @@ const ENGINE_ATTRIBUTE_CONTRACT: readonly (readonly [ ['OrderRepair', 'retryCycle', 'R5/PT10M'], ['PayoutReviewNeeded', 'asyncAfter', true], ['PayoutReviewNeeded', 'jobPriority', '40'], - // The key and the binding are one value in the IR, so the whole reference is - // one row: a version that stopped travelling fails it as loudly as a key that - // did. + // The key and the binding are one IR value, so one row: a version that + // stopped travelling fails as loudly as a key. [ 'ApprovePayout', 'formRef', @@ -68,18 +66,15 @@ const ENGINE_ATTRIBUTE_CONTRACT: readonly (readonly [ ['ClaimSettled', 'asyncBefore', true], ]; -// Spelled out rather than derived, because the sweep below reads it off a -// gateway, which declares the five job settings and never the listeners. The -// `satisfies` clause is what keeps the copy honest: a field added to the -// interface and not here stops compiling. -const ENGINE_ATTRIBUTE_KEYS = Object.keys({ - asyncBefore: 0, - asyncAfter: 0, - exclusive: 0, - jobPriority: 0, - retryCycle: 0, - executionListeners: 0, -} satisfies Record); +// The `satisfies` clause keeps the list complete: a field added to +// `EngineAttributes` beyond the job settings and not here stops compiling. +const ENGINE_ATTRIBUTE_KEYS = [ + ...ENGINE_KEYS, + ...Object.keys({ executionListeners: 0 } satisfies Record< + Exclude, + number + >), +]; // Indexed rather than a field access: the IR types keep these fields off the // kinds that cannot carry them, and the gateway block below reads exactly those. @@ -139,8 +134,7 @@ describe('the frozen engine-attribute contract', () => { }); // The `gateway-settings` pair pins what a gateway written with settings -// carries; this artifact writes none, so what it pins is that no direction -// invents one, on either kind, at any depth. +// carries; this artifact writes none, so it pins that no direction invents one. describe('a gateway written without settings gains none at any hop', () => { it('neither gateway kind holds one, at any container depth or hop', () => { for (const [label, ir] of rt.hops) { diff --git a/tests/event-handlers.round-trip.test.ts b/tests/event-handlers.round-trip.test.ts index 4ba5b46..4585f49 100644 --- a/tests/event-handlers.round-trip.test.ts +++ b/tests/event-handlers.round-trip.test.ts @@ -30,9 +30,9 @@ const rt = roundTripFixture('event-handlers', { recompile: 'errors', }); -// Handwritten import-first, with `camunda:` aliases for the error root message -// and the catch bindings, and labels that differ from the name humanized from -// each id, so the importer keeps them. +// `camunda:` aliases on the error root message and the catch bindings, and +// labels that differ from the name humanized from each id, so the importer +// keeps them. const IMPORT_FIRST_BPMN = ` @@ -103,14 +103,16 @@ describe("idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3", () => { it('the handler trigger start carries its event definition at every hop', () => { const isPaymentError = (def: EventDefinition | undefined): boolean => def?.kind === 'error' && def.errorCode === 'PAYMENT_DECLINED'; - for (const ir of [rt.ir1, rt.ir2, rt.ir3]) { - const payment = subProcess(ir, 'ProcessPayment'); - const def = handlerTriggerDef(payment, isPaymentError); - expect(def, `handler error definition missing in a hop`).toBeDefined(); - if (def?.kind === 'error') { - expect(def.codeVariable).toBe('c'); - expect(def.messageVariable).toBe('m'); - } + for (const [label, ir] of rt.hops) { + expect( + handlerTriggerDef(subProcess(ir, 'ProcessPayment'), isPaymentError), + `handler error definition differs in ${label}`, + ).toEqual({ + kind: 'error', + errorCode: 'PAYMENT_DECLINED', + codeVariable: 'c', + messageVariable: 'm', + }); } }); diff --git a/tests/event-triggers.round-trip.test.ts b/tests/event-triggers.round-trip.test.ts index 9e81e5e..70952c3 100644 --- a/tests/event-triggers.round-trip.test.ts +++ b/tests/event-triggers.round-trip.test.ts @@ -1,7 +1,5 @@ -// Why the restructured DSL is asserted validator-clean: the fixture avoids the -// early-exit-inside-`if` shape that degrades a jump into a goto onto an unnamed -// synthesized join, and every throw and emit is named, so the printer emits the -// authored id instead of a `Throw_` one that trips the reserved-name check. +// The fixture avoids the early-exit-inside-`if` shape, which degrades into a +// goto onto an unnamed synthesized join, so DSL' validates clean. import { describe, it, expect } from 'vitest'; @@ -19,7 +17,7 @@ import { kindOf, subProcess, } from './helpers/ir-query.js'; -import { definitionRefOf } from './helpers/xml-query.js'; +import { definitionRefOf, messageRoots } from './helpers/xml-query.js'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; const rt = roundTripFixture('event-triggers', { @@ -34,9 +32,9 @@ function timerExpressions(container: FlowContainer): string[] { .sort(); } -// Handwritten import-first. One `bpmn:Signal` root is referenced twice, by the -// intermediate throw and by the end event. Every task label differs from the -// name humanized from its id, so the importer keeps it. +// One `bpmn:Signal` root is referenced twice, by the intermediate throw and by +// the end event. Every task label differs from the name humanized from its id, +// so the importer keeps it. const IMPORT_FIRST_BPMN = ` @@ -115,15 +113,11 @@ describe("idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3", () => { it('the conditional handler condition survives as the same expression at every hop', () => { const isConditional = (def: EventDefinition | undefined): boolean => def?.kind === 'conditional'; - for (const ir of [rt.ir1, rt.ir2, rt.ir3]) { - const def = handlerTriggerDef( - subProcess(ir, 'FulfilOrder'), - isConditional, - ); - expect(def, 'conditional handler missing in a hop').toBeDefined(); - if (def?.kind === 'conditional') { - expect(def.condition).toBe('${stockLevel < 5}'); - } + for (const [label, ir] of rt.hops) { + expect( + handlerTriggerDef(subProcess(ir, 'FulfilOrder'), isConditional), + `conditional handler differs in ${label}`, + ).toEqual({ kind: 'conditional', condition: '${stockLevel < 5}' }); } }); @@ -169,9 +163,10 @@ describe('root sharing on the frozen .bpmn', () => { expect(definitionRefOf(rt.frozenXml, 'Announce', 'signal')).toBe(signalId); }); - it('there is exactly one root per distinct message and signal name', () => { - expect(rt.frozenXml.match(/ { + expect(messageRoots(rt.frozenXml).map((root) => root.name)).toEqual([ + 'OrderCancelled', + ]); }); }); diff --git a/tests/examples.health.test.ts b/tests/examples.health.test.ts index 598e3a2..ae6220e 100644 --- a/tests/examples.health.test.ts +++ b/tests/examples.health.test.ts @@ -1,9 +1,7 @@ // Directory-driven rather than a hand-kept list, so a new example is covered the -// moment it lands. A few examples are also the only place a construct's -// desugared shape is pinned without a golden fixture, so those keep a block of -// their own below. -// The round-trip block below is the examples' counterpart of the golden pair -// suites' import path: same hops, over the directory instead of one fixture. +// moment it lands. The last block pins the lowered shape of the examples that +// double as walkthroughs: the round-trip block alone would pass an `await` +// lowered to a receive task just as cleanly. import { describe, it, expect } from 'vitest'; import { readdirSync, readFileSync } from 'node:fs'; @@ -56,8 +54,7 @@ describe("every example round-trips through the tool's own output without a word const ir3 = astToIr(await parseToAst(dslPrime)); const { diagnostics } = await validate(dslPrime); - // Mapped rather than compared raw: a failure then names the id and the - // category or message, not a range and offset nobody reads. + // Mapped so a failure names the id and the message, not a range. expect({ importWarnings: importWarnings.map((w) => ({ elementId: w.elementId, @@ -83,7 +80,6 @@ describe("every example round-trips through the tool's own output without a word describe('construct shapes pinned only by a deployable example', () => { it('awaiting-confirmation desugars the await into an intermediateCatchEvent carrying the message definition', async () => { - // Desugaring decides what an `await` lowers to, so XML alone would miss it. const ir = astToIr(await parseToAst(sourceOf('awaiting-confirmation'))); const catchNode = ir.flowElements.find( (el) => el.kind === 'intermediateCatchEvent', @@ -100,7 +96,6 @@ describe('construct shapes pinned only by a deployable example', () => { }); it('order-handling compiles to BPMN XML with the expected attached boundary events', async () => { - // Doubles as the boundary-event walkthrough; no golden fixture of its own. const xml = await compile('order-handling'); expect(xml).toContain( diff --git a/tests/fixtures/adapters/external-tasks.ts b/tests/fixtures/adapters/external-tasks.ts deleted file mode 100644 index efc5af8..0000000 --- a/tests/fixtures/adapters/external-tasks.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { FixtureAdapter } from '../types.js'; - -export function start(): Promise { - throw new Error('FixtureAdapter "external-tasks" is not implemented yet'); -} diff --git a/tests/fixtures/adapters/standalone.ts b/tests/fixtures/adapters/standalone.ts deleted file mode 100644 index 5994bda..0000000 --- a/tests/fixtures/adapters/standalone.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { FixtureAdapter } from '../types.js'; - -export function start(): Promise { - throw new Error('FixtureAdapter "standalone" is not implemented yet'); -} diff --git a/tests/fixtures/index.test.ts b/tests/fixtures/index.test.ts deleted file mode 100644 index dd9b945..0000000 --- a/tests/fixtures/index.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { startFixture } from './index.js'; - -describe('startFixture: stub adapters', () => { - it('startFixture("external-tasks") throws "not implemented"', async () => { - await expect(startFixture('external-tasks')).rejects.toThrow( - 'FixtureAdapter "external-tasks" is not implemented yet', - ); - }); - - it('startFixture("standalone") throws "not implemented"', async () => { - await expect(startFixture('standalone')).rejects.toThrow( - 'FixtureAdapter "standalone" is not implemented yet', - ); - }); -}); diff --git a/tests/fixtures/index.ts b/tests/fixtures/index.ts index 120f1aa..2599a36 100644 --- a/tests/fixtures/index.ts +++ b/tests/fixtures/index.ts @@ -3,12 +3,10 @@ import { mkdirSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { ActiveTask, FixtureAdapter, FixtureMode } from './types.js'; +import type { ActiveTask, FixtureAdapter } from './types.js'; import * as springBootAdapter from './adapters/spring-boot.js'; -import * as externalTasksAdapter from './adapters/external-tasks.js'; -import * as standaloneAdapter from './adapters/standalone.js'; -export type { ActiveTask, FixtureAdapter, FixtureMode }; +export type { ActiveTask, FixtureAdapter }; // Addressed by path rather than run through `npx`, which resolves a command by // walking `node_modules/.bin` upwards and so can reach a neighbouring checkout's @@ -30,17 +28,6 @@ export function buildExample(dslPath: string, xmlOutPath: string): void { ); } -export async function startFixture(mode: FixtureMode): Promise { - switch (mode) { - case 'spring-boot': - return springBootAdapter.start(); - case 'external-tasks': - return externalTasksAdapter.start(); - case 'standalone': - return standaloneAdapter.start(); - default: { - const _exhaustive: never = mode; - throw new Error(`Unknown FixtureMode: ${String(_exhaustive)}`); - } - } +export async function startFixture(): Promise { + return springBootAdapter.start(); } diff --git a/tests/fixtures/types.ts b/tests/fixtures/types.ts index 99b2625..a43b9d3 100644 --- a/tests/fixtures/types.ts +++ b/tests/fixtures/types.ts @@ -1,5 +1,5 @@ -// One uniform API over the deployment modes, so an integration test is written -// once and runs against any of them. +// One API for a deployment fixture, so an integration test is written once and +// can run against another runtime later. export interface ActiveTask { id: string; name: string; @@ -35,5 +35,3 @@ export interface FixtureAdapter { stop(): Promise; } - -export type FixtureMode = 'spring-boot' | 'external-tasks' | 'standalone'; diff --git a/tests/forms.round-trip.test.ts b/tests/forms.round-trip.test.ts index ddd8781..f43362a 100644 --- a/tests/forms.round-trip.test.ts +++ b/tests/forms.round-trip.test.ts @@ -9,8 +9,7 @@ import type { BpmnProcess, FormField } from '@bpmn-script/transform'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; import { elementById } from './helpers/ir-query.js'; -// Only a start event and a user task carry a form; narrowing here keeps the -// contract test itself free of `kind` bookkeeping. +// Only a start event and a user task carry a form. function formFieldsOf( container: BpmnProcess, id: string, @@ -153,6 +152,5 @@ describe('the frozen form-field contract', () => { }); }); -// The fixture has no nested container (an `if`/`else` between two flat tasks), -// so there is nothing for describeDiContainment to walk, the same reason -// intermediate-catch leaves it out. +// The fixture has no nested container, so there is nothing for +// describeDiContainment to walk. diff --git a/tests/gateway-settings.round-trip.test.ts b/tests/gateway-settings.round-trip.test.ts index 277e8f4..8c5d88d 100644 --- a/tests/gateway-settings.round-trip.test.ts +++ b/tests/gateway-settings.round-trip.test.ts @@ -10,6 +10,7 @@ import { isGateway } from '@bpmn-script/transform'; import type { FlowContainer, JobSettings } from '@bpmn-script/transform'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; +import { bpmnDoc } from './helpers/bpmn-doc.js'; import { describeDiContainment } from './helpers/di-bounds.js'; import { allElements } from './helpers/ir-query.js'; import { describeImportFirst } from './helpers/import-first.js'; @@ -22,8 +23,7 @@ const rt = roundTripFixture('gateway-settings', { // Every gateway of the artifact, the ones written bare included, so a setting // that appears on a gateway nothing wrote it for fails as loudly as one that -// stops travelling. Revert: any one direction dropping a `join*` key from the -// join it synthesizes, or reading a head key onto the join. +// stops travelling. const GATEWAY_SETTINGS: Record = { 'Gateway_loan-application_2_split': { asyncBefore: true, @@ -73,9 +73,8 @@ function settingsByGateway(ir: FlowContainer): Record { ); } -// Regex rather than a parser: the tests workspace declares no moddle dependency. // Every `operaton:` attribute on a gateway open tag, then the retry child in -// its body, as `(id, attribute or child, value)` in document order. +// its body, as `[id, attribute or child, value]` in document order. function gatewayWireSettings(xml: string): [string, string, string][] { const gateway = /]*?)(?:\/>|>([\s\S]*?)<\/bpmn:\1>)/g; @@ -111,8 +110,6 @@ describe('the frozen gateway-settings contract', () => { } }); - // Revert: `jobSettingAttrs` or `retryCycleElement` skipped for a gateway kind - // in `irToXml` -> that gateway's rows gone. it('the wire carries the attributes and the retry child on the gateway elements', () => { expect(gatewayWireSettings(rt.frozenXml)).toEqual([ ['Gateway_loan-application_2_split', 'operaton:asyncBefore', 'true'], @@ -150,10 +147,9 @@ describe('the frozen gateway-settings contract', () => { ]); }); - // The whole set of heads, the bare ones included, so a parens printed on a - // gateway that carries nothing fails too. The harness re-parses and - // validates DSL' as a whole. Revert: `takeJoinSettings` returning `[]` -> - // every `join*` key gone from the heads. + // Every head, the bare ones included, so a parens printed on a gateway that + // carries nothing fails too; the fixture harness re-parses and validates + // DSL'. it('the printed script carries the settings on the statement heads', () => { const heads = rt.dslPrime .split('\n') @@ -176,34 +172,27 @@ describe('the frozen gateway-settings contract', () => { // A modeled document names its gateways, so the split and the join reach the // printer under authored ids rather than the coordinate ids the frozen artifact // carries, and the `join*` keys have to be recovered from the shape alone. -const AUTHORED_SPLIT_AND_JOIN = - '\n' + - '\n' + - ' \n' + +const AUTHORED_SPLIT_AND_JOIN = bpmnDoc( ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' ${creditScore >= 700}\n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ''; + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' ${creditScore >= 700}\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' ', +); describeImportFirst( 'an authored .bpmn with settings on a join imports onto the join keys', AUTHORED_SPLIT_AND_JOIN, (first) => { - // Revert: `readJobSettings` skipped for `bpmn:exclusiveGateway` in - // `xmlToIr` -> the head loses both keys. it('the head carries the split key and, join-prefixed, the join key', () => { expect(first.dsl).toBe( [ diff --git a/tests/golden/README.md b/tests/golden/README.md index 3392b23..cd9e0ab 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -41,7 +41,7 @@ The three invoice-approval files all describe the same process (review, then a g Change the parser, the desugarer, or `irToXml` in a way that should alter the output (a new attribute, different formatting, a layout-library upgrade, an id-scheme change) and the frozen `.bpmn` has to be regenerated: -1. Run the full pipeline on the source: `irToXml(astToIr(parse(source)))`, wiring the Langium services exactly as `tests/round-trip.test.ts` does, with `createBpmnScriptServices(EmptyFileSystem)` and `parseHelper`. +1. Run the full pipeline on the source: `irToXml(astToIr(parse(source)))`, wiring the Langium services as `tests/helpers/pipeline.ts` does, with `createBpmnScriptServices(EmptyFileSystem)` and `parseHelper`. `bpmns build` is not a substitute. It passes its own version into `irToXml`, so what it writes carries the real `exporterVersion`, where a frozen file carries `0.0.0`; the `bpmn:Definitions` id is `Definitions_` either way. 2. Write the returned string over the frozen `.bpmn`. diff --git a/tests/goto-fallback.round-trip.test.ts b/tests/goto-fallback.round-trip.test.ts index b79eea5..936fcb1 100644 --- a/tests/goto-fallback.round-trip.test.ts +++ b/tests/goto-fallback.round-trip.test.ts @@ -1,23 +1,12 @@ // A `goto` names a statement, and gateways have no statement form, so an edge -// into a gateway is expressible only through the gateway's successor and only -// while the gateway's routing has a single outcome. Two ordinary BPMN shapes -// sit either side of that line: -// -// - A loop headed by a pass-through join. One successor, so the back-edge is -// expressible even though the structured walk already consumed the join's -// out-edge. Consumption records that an edge was printed, not that it -// stopped existing. -// - A loop with the condition on the back-edge. No loop pattern matches, so -// the head gateway prints as an `if` and the back-edge still points at a -// two-way gateway. Nothing names that, so the edge is dropped and the -// marker records where. -// -// The rest of the file pins the shapes that structure cleanly, so a change to -// the fallback cannot turn a `while` into a jump unnoticed. +// into a gateway is expressible only through the gateway's successor, and only +// while the gateway routes one way. The shapes below sit on both sides of that +// line, and the clean ones at the end pin that the fallback never turns a +// `while` into a jump. -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect } from 'vitest'; -import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver-types'; +import { DiagnosticSeverity } from 'vscode-languageserver-types'; import { xmlToIr, @@ -30,40 +19,31 @@ import type { BpmnProcess } from '@bpmn-script/transform'; import { realNodeReachability } from './helpers/real-node-reachability.js'; import { - parse, parseToAst as parseUnvalidated, printDsl, + validate, } from './helpers/pipeline.js'; -// Validation, not just parsing: a `goto` naming an elided node parses fine and -// fails only once the reference is linked, and a `goto` written ahead of a -// statement leaves that statement unreachable. Both are validator findings. +// Validates as well as parses: a `goto` naming an elided node, or one that +// leaves a later statement unreachable, parses fine and fails only in the +// validator. async function parseToAst(source: string) { - const document = await parse(source, { validation: true }); - const errors = document.parseResult.parserErrors; - if (errors.length > 0) { - throw new Error( - 'Parser errors in emitted DSL:\n' + - errors.map((e) => e.message).join('\n'), - ); - } - const problems = (document.diagnostics ?? []).filter( + const { document, diagnostics } = await validate(source); + const problems = diagnostics.filter( (d) => d.severity === DiagnosticSeverity.Error, ); if (problems.length > 0) { throw new Error( - 'Validation errors in emitted DSL:\n' + - problems.map((d) => Diagnostic.getMessageString(d)).join('\n'), + 'Errors in emitted DSL:\n' + problems.map((d) => d.message).join('\n'), ); } return document.parseResult.value; } -// Emission only. Re-desugaring belongs in the test that needs it, so a model -// that fails to validate fails a test rather than the whole suite's setup. async function emit(ir: BpmnProcess) { const { ir: imported, warnings } = await xmlToIr(await irToXml(ir)); - return { imported, warnings, dsl: printDsl(imported) }; + expect(warnings).toEqual([]); + return { imported, dsl: printDsl(imported) }; } const flow = (id: string, from: string, to: string, condition?: string) => ({ @@ -73,15 +53,17 @@ const flow = (id: string, from: string, to: string, condition?: string) => ({ ...(condition !== undefined ? { conditionExpression: condition } : {}), }); +const task = (id: string) => ({ kind: 'userTask' as const, id }); + const JOIN_HEADED_LOOP: BpmnProcess = { id: 'JoinHeadedLoop', isExecutable: true, flowElements: [ { kind: 'startEvent', id: 'Start_1' }, { kind: 'exclusiveGateway', id: 'Gateway_H_join' }, - { kind: 'userTask', id: 'T1' }, + task('T1'), { kind: 'exclusiveGateway', id: 'Gateway_X_split' }, - { kind: 'userTask', id: 'T2' }, + task('T2'), { kind: 'endEvent', id: 'End_1' }, ], sequenceFlows: [ @@ -95,33 +77,29 @@ const JOIN_HEADED_LOOP: BpmnProcess = { }; describe('back-edge into a pass-through join whose out-edge is already consumed', () => { - let imported: BpmnProcess; - let warnings: Awaited>['warnings']; - let dsl: string; - - beforeAll(async () => { - ({ imported, warnings, dsl } = await emit(JOIN_HEADED_LOOP)); - }); - - it('imports through the XML path with no warnings', () => { - expect(warnings).toHaveLength(0); - expect(imported.sequenceFlows).toHaveLength(6); - }); - - it("carries the back-edge as a `goto` naming the join's successor", () => { - expect(dsl).toContain('goto T1'); - // The join is elided, so it must never be named. - expect(dsl).not.toContain('goto Gateway_'); - expect(dsl).not.toContain(UNSTRUCTURED_MARKER); - }); - - it('preserves the real-node reachability across the round-trip', async () => { - // The edge at stake: resolving the jump through the consumed join keeps it. + // The structured walk printed the join's out-edge, but printing an edge does + // not stop it existing, so the jump resolves through the join to `T1`. + it("carries the back-edge as a `goto` naming the join's successor and keeps the reachability", async () => { + const { imported, dsl } = await emit(JOIN_HEADED_LOOP); + expect(dsl).toBe( + [ + 'process JoinHeadedLoop {', + ' var c: any', + ' start Start_1', + ' user T1', + ' if (c) {', + ' user T2', + ' goto T1', + ' }', + ' end End_1', + '}', + '', + ].join('\n'), + ); const reDesugared = astToIr(await parseToAst(dsl)); expect(realNodeReachability(reDesugared)).toEqual( realNodeReachability(imported), ); - expect(realNodeReachability(reDesugared)).toContain('T2->T1'); }); }); @@ -131,7 +109,7 @@ const BACK_EDGE_CONDITION_LOOP: BpmnProcess = { flowElements: [ { kind: 'startEvent', id: 'Start_1' }, { kind: 'exclusiveGateway', id: 'Gateway_L_loop' }, - { kind: 'userTask', id: 'Body_1' }, + task('Body_1'), { kind: 'endEvent', id: 'End_1' }, ], sequenceFlows: [ @@ -143,54 +121,52 @@ const BACK_EDGE_CONDITION_LOOP: BpmnProcess = { }; describe('loop condition on the back-edge (no expressible jump target)', () => { - let imported: BpmnProcess; - let warnings: Awaited>['warnings']; - let dsl: string; - - beforeAll(async () => { - ({ imported, warnings, dsl } = await emit(BACK_EDGE_CONDITION_LOOP)); - }); - - it('imports the conditioned back-edge faithfully and without warnings', () => { + // No loop pattern matches, so the head prints as an `if` and the back-edge + // still points at a two-way gateway. A jump to the head would re-run the + // `done` test and a jump to the body would skip it, so the edge is dropped + // and the marker names where. + it('drops the conditioned back-edge with a marker naming the gateway, and never invents a target', async () => { + const { imported, dsl } = await emit(BACK_EDGE_CONDITION_LOOP); // The loss happens on emission, not on import: the IR still has the edge. - expect(warnings).toHaveLength(0); - const backEdge = imported.sequenceFlows.find( - (f) => f.sourceRef === 'Body_1' && f.targetRef === 'Gateway_L_loop', - ); - expect(backEdge?.conditionExpression).toBe('${more}'); - }); - - it('marks the dropped edge and names the element it led into', () => { - expect(dsl).toContain(UNSTRUCTURED_MARKER); - expect(dsl).toContain( - `${UNSTRUCTURED_MARKER} (dropped edge into Gateway_L_loop)`, + expect( + imported.sequenceFlows.find( + (f) => f.sourceRef === 'Body_1' && f.targetRef === 'Gateway_L_loop', + )?.conditionExpression, + ).toBe('${more}'); + expect(dsl).toBe( + [ + 'process BackEdgeConditionLoop {', + ' var done: any', + ' var more: any', + ' start Start_1', + ' if (done) {', + ' } else {', + ' user Body_1', + ` ${UNSTRUCTURED_MARKER} (dropped edge into Gateway_L_loop)`, + ' }', + ' end End_1', + '}', + '', + ].join('\n'), ); - // Never invent a target: a jump to the loop head would re-run the `done` - // test, and a jump to the body would skip it. - expect(dsl).not.toContain('goto'); - }); - - it('still emits source that re-parses', async () => { - // The marker is a comment, so the output stays usable as a starting point. await expect(parseToAst(dsl)).resolves.toBeDefined(); }); }); -// The approve-review loop as a modeler draws it: the split inside the body -// carries a condition on both routes and names no default, and so does the -// loop gateway. The route leaving the loop puts the split's immediate -// post-dominator outside it, so the split has no clean join, and with no -// unconditioned route it has no guard clause either. +// The approve-review loop as a modeler draws it: the split inside the body and +// the loop gateway both carry a condition on every route and name no default. +// The route leaving the loop puts the split's immediate post-dominator outside +// it, so the split has neither a clean join nor a guard clause. const REVIEW_LOOP: BpmnProcess = { id: 'ReviewLoop', isExecutable: true, flowElements: [ { kind: 'startEvent', id: 'Start_1' }, - { kind: 'userTask', id: 'Approve' }, + task('Approve'), { kind: 'exclusiveGateway', id: 'Gateway_approved' }, - { kind: 'userTask', id: 'Review' }, + task('Review'), { kind: 'exclusiveGateway', id: 'Gateway_clarified' }, - { kind: 'userTask', id: 'Pay' }, + task('Pay'), { kind: 'endEvent', id: 'End_1' }, { kind: 'endEvent', id: 'End_2' }, ], @@ -209,7 +185,27 @@ const REVIEW_LOOP: BpmnProcess = { describe('a split inside a loop body whose every route is conditioned', () => { it('keeps the route that stays in the loop, so only the leaving route is a jump', async () => { const { imported, dsl } = await emit(REVIEW_LOOP); - expect(dsl).not.toContain(UNSTRUCTURED_MARKER); + expect(dsl).toBe( + [ + 'process ReviewLoop {', + ' var approved: any', + ' var clarified: any', + ' start Start_1', + ' do {', + ' user Approve', + ' if (approved) {', + ' goto Pay', + ' } else if (!approved) {', + ' user Review', + ' }', + ' } while (clarified)', + ' end End_2', + ' user Pay', + ' end End_1', + '}', + '', + ].join('\n'), + ); // The chain closes with no `else`, so the recompiled split falls through // to the loop gateway where the model had no route: `Approve` gains the @@ -231,9 +227,9 @@ const SURPLUS_EDGE_ON_TASK: BpmnProcess = { flowElements: [ { kind: 'startEvent', id: 'Start_1' }, { kind: 'exclusiveGateway', id: 'Gateway_H_join' }, - { kind: 'userTask', id: 'T1' }, - { kind: 'userTask', id: 'Cont' }, - { kind: 'userTask', id: 'Later' }, + task('T1'), + task('Cont'), + task('Later'), { kind: 'endEvent', id: 'End_1' }, ], sequenceFlows: [ @@ -247,126 +243,145 @@ const SURPLUS_EDGE_ON_TASK: BpmnProcess = { }; describe('surplus out-edge on a plain node keeps the fall-through', () => { - let dsl: string; - - beforeAll(async () => { - ({ dsl } = await emit(SURPLUS_EDGE_ON_TASK)); - }); - - it('gives both routes a branch rather than a jump beside the fall-through', () => { - // A bare `goto` beside the fall-through would end the chain and leave - // everything after it with no incoming flow. Both routes head a branch - // instead, so the second route keeps its edge and the first keeps its - // chain. The back edge names `T1` because the join it runs into has one - // way out, and that is the step it leads to. The step forks in the model - // and no merge closes it, which the marker names; no edge is dropped. - expect(dsl).toContain('if (true) {'); - expect(dsl).toContain('goto Cont'); - expect(dsl).toContain('goto T1'); - expect(dsl).toContain( - `${UNSTRUCTURED_MARKER} (split T1 degraded to jumps; was parallel)`, + // A bare `goto` beside the fall-through would end the chain and leave + // everything after it unreachable, so both routes head a branch. The back + // edge names `T1`, the one step the join leads to, and the marker records + // that the fork closes on no merge. + it('gives both routes a branch rather than a jump beside the fall-through, and keeps the rest reachable', async () => { + const { imported, dsl } = await emit(SURPLUS_EDGE_ON_TASK); + expect(dsl).toBe( + [ + 'process SurplusEdgeOnTask {', + ' start Start_1', + ' user T1', + ` ${UNSTRUCTURED_MARKER} (split T1 degraded to jumps; was parallel)`, + ' if (true) {', + ' goto Cont', + ' } else {', + ' goto T1', + ' }', + ' user Cont', + ' user Later', + ' end End_1', + '}', + '', + ].join('\n'), + ); + const reDesugared = astToIr(await parseToAst(dsl)); + expect(realNodeReachability(reDesugared)).toEqual( + realNodeReachability(imported), ); - expect(dsl).not.toContain('dropped edge'); - }); - - it('keeps the rest of the process reachable and valid', async () => { - const reach = realNodeReachability(astToIr(await parseToAst(dsl))); - expect(reach).toContain('T1->Cont'); - expect(reach).toContain('Cont->Later'); - expect(reach).toContain('Later->End_1'); }); }); +// A synthesized terminal is not printed at all, and `await` prints only its +// trigger, so a jump into either has nothing to name. describe('a goto never names a node the emitter elides', () => { - it('marks the edge instead of naming a synthesized terminal', async () => { - const ir: BpmnProcess = { - id: 'ElidedTerminal', - isExecutable: true, - flowElements: [ - { kind: 'startEvent', id: 'Start_1' }, - { kind: 'userTask', id: 'T1' }, - { kind: 'exclusiveGateway', id: 'Gateway_E_join' }, - { kind: 'endEvent', id: 'EndEvent_ElidedTerminal' }, - ], - sequenceFlows: [ - flow('f1', 'Start_1', 'T1'), - flow('f2', 'T1', 'Gateway_E_join'), - flow('f3', 'Gateway_E_join', 'EndEvent_ElidedTerminal'), - flow('f4', 'T1', 'Gateway_E_join'), + it.each<[title: string, ir: BpmnProcess, printed: string[]]>([ + [ + 'marks the edge instead of naming a synthesized terminal', + { + id: 'ElidedTerminal', + isExecutable: true, + flowElements: [ + { kind: 'startEvent', id: 'Start_1' }, + task('T1'), + { kind: 'exclusiveGateway', id: 'Gateway_E_join' }, + { kind: 'endEvent', id: 'EndEvent_ElidedTerminal' }, + ], + sequenceFlows: [ + flow('f1', 'Start_1', 'T1'), + flow('f2', 'T1', 'Gateway_E_join'), + flow('f3', 'Gateway_E_join', 'EndEvent_ElidedTerminal'), + flow('f4', 'T1', 'Gateway_E_join'), + ], + }, + [ + 'process ElidedTerminal {', + ' start Start_1', + ' user T1', + ` ${UNSTRUCTURED_MARKER} (split T1 degraded to jumps; was parallel)`, + ' if (true) {', + ` ${UNSTRUCTURED_MARKER} (dropped edge into Gateway_E_join)`, + ' } else {', + ` ${UNSTRUCTURED_MARKER} (dropped edge into Gateway_E_join)`, + ' }', + '}', + '', ], - }; - const dsl = printDsl(ir); - expect(dsl).not.toMatch(/goto EndEvent_/); - await expect(parseToAst(dsl)).resolves.toBeDefined(); - }); - - it('marks the edge instead of naming an awaited catch event', async () => { - // A timer the flow loops back to. `await` prints only the trigger, so - // `goto Catch_1` would name something the output never declares. - const ir: BpmnProcess = { - id: 'CatchLoop', - isExecutable: true, - flowElements: [ - { kind: 'startEvent', id: 'Start_1' }, - { - kind: 'intermediateCatchEvent', - id: 'Catch_1', - eventDefinition: { - kind: 'timer', - timerKind: 'duration', - expression: 'PT5M', + ], + [ + 'marks the edge instead of naming an awaited catch event', + { + id: 'CatchLoop', + isExecutable: true, + flowElements: [ + { kind: 'startEvent', id: 'Start_1' }, + { + kind: 'intermediateCatchEvent', + id: 'Catch_1', + eventDefinition: { + kind: 'timer', + timerKind: 'duration', + expression: 'PT5M', + }, }, - }, - { kind: 'userTask', id: 'T1' }, - ], - sequenceFlows: [ - flow('f1', 'Start_1', 'Catch_1'), - flow('f2', 'Catch_1', 'T1'), - flow('f3', 'T1', 'Catch_1'), + task('T1'), + ], + sequenceFlows: [ + flow('f1', 'Start_1', 'Catch_1'), + flow('f2', 'Catch_1', 'T1'), + flow('f3', 'T1', 'Catch_1'), + ], + }, + [ + 'process CatchLoop {', + ' start Start_1', + ' await timer("PT5M")', + ' user T1', + ` ${UNSTRUCTURED_MARKER} (dropped edge into Catch_1)`, + '}', + '', ], - }; - const { ir: imported, warnings } = await xmlToIr(await irToXml(ir)); - expect(warnings).toHaveLength(0); - - const dsl = printDsl(imported); - expect(dsl).not.toContain('goto Catch_1'); - expect(dsl).toContain(`${UNSTRUCTURED_MARKER} (dropped edge into Catch_1)`); + ], + ])('%s', async (_title, ir, printed) => { + const { dsl } = await emit(ir); + expect(dsl).toBe(printed.join('\n')); await expect(parseToAst(dsl)).resolves.toBeDefined(); }); }); -const task = (id: string) => ({ kind: 'userTask' as const, id }); - describe('shapes that structure cleanly stay structured', () => { - it('nests an `if` inside an `if` branch', () => { - const ir: BpmnProcess = { - id: 'NestedIf', - isExecutable: true, - flowElements: [ - { kind: 'startEvent', id: 'Start_1' }, - { kind: 'exclusiveGateway', id: 'Gateway_O_split' }, - { kind: 'exclusiveGateway', id: 'Gateway_I_split' }, - task('Inner_1'), - task('Inner_2'), - { kind: 'exclusiveGateway', id: 'Gateway_I_join' }, - task('Outer_1'), - { kind: 'exclusiveGateway', id: 'Gateway_O_join' }, - { kind: 'endEvent', id: 'End_1' }, - ], - sequenceFlows: [ - flow('f1', 'Start_1', 'Gateway_O_split'), - flow('f2', 'Gateway_O_split', 'Gateway_I_split', '${a}'), - flow('f3', 'Gateway_O_split', 'Outer_1'), - flow('f4', 'Gateway_I_split', 'Inner_1', '${b}'), - flow('f5', 'Gateway_I_split', 'Inner_2'), - flow('f6', 'Inner_1', 'Gateway_I_join'), - flow('f7', 'Inner_2', 'Gateway_I_join'), - flow('f8', 'Gateway_I_join', 'Gateway_O_join'), - flow('f9', 'Outer_1', 'Gateway_O_join'), - flow('f10', 'Gateway_O_join', 'End_1'), - ], - }; - expect(printDsl(ir)).toBe( + it.each<[title: string, ir: BpmnProcess, printed: string[]]>([ + [ + 'nests an `if` inside an `if` branch', + { + id: 'NestedIf', + isExecutable: true, + flowElements: [ + { kind: 'startEvent', id: 'Start_1' }, + { kind: 'exclusiveGateway', id: 'Gateway_O_split' }, + { kind: 'exclusiveGateway', id: 'Gateway_I_split' }, + task('Inner_1'), + task('Inner_2'), + { kind: 'exclusiveGateway', id: 'Gateway_I_join' }, + task('Outer_1'), + { kind: 'exclusiveGateway', id: 'Gateway_O_join' }, + { kind: 'endEvent', id: 'End_1' }, + ], + sequenceFlows: [ + flow('f1', 'Start_1', 'Gateway_O_split'), + flow('f2', 'Gateway_O_split', 'Gateway_I_split', '${a}'), + flow('f3', 'Gateway_O_split', 'Outer_1'), + flow('f4', 'Gateway_I_split', 'Inner_1', '${b}'), + flow('f5', 'Gateway_I_split', 'Inner_2'), + flow('f6', 'Inner_1', 'Gateway_I_join'), + flow('f7', 'Inner_2', 'Gateway_I_join'), + flow('f8', 'Gateway_I_join', 'Gateway_O_join'), + flow('f9', 'Outer_1', 'Gateway_O_join'), + flow('f10', 'Gateway_O_join', 'End_1'), + ], + }, [ 'process NestedIf {', ' var a: any', @@ -384,37 +399,35 @@ describe('shapes that structure cleanly stay structured', () => { ' end End_1', '}', '', - ].join('\n'), - ); - }); - - it('emits two sibling `if`s for two independent splits in sequence', () => { - const ir: BpmnProcess = { - id: 'SiblingIfs', - isExecutable: true, - flowElements: [ - { kind: 'startEvent', id: 'Start_1' }, - { kind: 'exclusiveGateway', id: 'Gateway_A_split' }, - task('A_1'), - { kind: 'exclusiveGateway', id: 'Gateway_A_join' }, - { kind: 'exclusiveGateway', id: 'Gateway_B_split' }, - task('B_1'), - { kind: 'exclusiveGateway', id: 'Gateway_B_join' }, - { kind: 'endEvent', id: 'End_1' }, - ], - sequenceFlows: [ - flow('f1', 'Start_1', 'Gateway_A_split'), - flow('f2', 'Gateway_A_split', 'A_1', '${a}'), - flow('f3', 'Gateway_A_split', 'Gateway_A_join'), - flow('f4', 'A_1', 'Gateway_A_join'), - flow('f5', 'Gateway_A_join', 'Gateway_B_split'), - flow('f6', 'Gateway_B_split', 'B_1', '${b}'), - flow('f7', 'Gateway_B_split', 'Gateway_B_join'), - flow('f8', 'B_1', 'Gateway_B_join'), - flow('f9', 'Gateway_B_join', 'End_1'), ], - }; - expect(printDsl(ir)).toBe( + ], + [ + 'emits two sibling `if`s for two independent splits in sequence', + { + id: 'SiblingIfs', + isExecutable: true, + flowElements: [ + { kind: 'startEvent', id: 'Start_1' }, + { kind: 'exclusiveGateway', id: 'Gateway_A_split' }, + task('A_1'), + { kind: 'exclusiveGateway', id: 'Gateway_A_join' }, + { kind: 'exclusiveGateway', id: 'Gateway_B_split' }, + task('B_1'), + { kind: 'exclusiveGateway', id: 'Gateway_B_join' }, + { kind: 'endEvent', id: 'End_1' }, + ], + sequenceFlows: [ + flow('f1', 'Start_1', 'Gateway_A_split'), + flow('f2', 'Gateway_A_split', 'A_1', '${a}'), + flow('f3', 'Gateway_A_split', 'Gateway_A_join'), + flow('f4', 'A_1', 'Gateway_A_join'), + flow('f5', 'Gateway_A_join', 'Gateway_B_split'), + flow('f6', 'Gateway_B_split', 'B_1', '${b}'), + flow('f7', 'Gateway_B_split', 'Gateway_B_join'), + flow('f8', 'B_1', 'Gateway_B_join'), + flow('f9', 'Gateway_B_join', 'End_1'), + ], + }, [ 'process SiblingIfs {', ' var a: any', @@ -429,138 +442,140 @@ describe('shapes that structure cleanly stay structured', () => { ' end End_1', '}', '', - ].join('\n'), - ); + ], + ], + ])('%s', async (_title, ir, printed) => { + const dsl = printDsl(ir); + expect(dsl).toBe(printed.join('\n')); + await expect(parseToAst(dsl)).resolves.toBeDefined(); }); }); -// Shapes a fuzz run drew, each compiled, exported, imported and printed. A -// row pins the print's whole warning list by category and whether the print -// validates, so a shape that stops being reported, or starts failing, turns -// the row red rather than the next fuzz run. +const compiled = async (source: string) => astToIr(await parseToAst(source)); + +// `do { end X } while (true)` compiles to a loop gateway with no incoming flow +// (the body always ends, so `lowerDoWhile` wires no back edge), and the +// validator refuses the statement it proves unreachable, so the shapes carrying +// it skip validation or are built as IR, the way a Modeler file would import. +const compiledUnvalidated = async (source: string) => + astToIr(await parseUnvalidated(source)); + +const DANGLING_LOOP_HEAD: BpmnProcess = { + id: 'DanglingLoopHead', + isExecutable: true, + flowElements: [ + { kind: 'startEvent', id: 'S' }, + { kind: 'endEvent', id: 'X' }, + { kind: 'exclusiveGateway', id: 'Gateway_L_loop', defaultFlowId: 'f3' }, + { kind: 'endEvent', id: 'Done' }, + ], + sequenceFlows: [ + flow('f1', 'S', 'X'), + flow('f2', 'Gateway_L_loop', 'X', '${true}'), + flow('f3', 'Gateway_L_loop', 'Done'), + ], +}; + +// Shapes a fuzz run drew, each exported, imported and printed. A row pins the +// print's whole warning list by category and whether the print validates, so a +// shape that stops being reported, or starts failing, turns the row red rather +// than the next fuzz run. describe('composite shapes through compile, import and print', () => { it.each< - [title: string, source: string, categories: string[], valid: boolean] + [ + title: string, + shape: () => Promise, + categories: string[], + valid: boolean, + ] >([ [ 'a loop and a throw inside a guarded branch walk inline', - [ - 'process p {', - ' escalation E', - ' start S', - ' if (true) {', - ' while (true) {', - ' script A ```javascript', - 'x = 1', - '```', - ' }', - ' throw escalation X(E)', - ' }', - ' end Done', - '}', - ].join('\n'), + () => + compiled( + [ + 'process p {', + ' escalation E', + ' start S', + ' if (true) {', + ' while (true) {', + ' script A ```javascript', + 'x = 1', + '```', + ' }', + ' throw escalation X(E)', + ' }', + ' end Done', + '}', + ].join('\n'), + ), [], true, ], [ 'a pre-test loop whose body ends prints as a guard clause jumping to the body', - 'process p {\n start S\n while (true) {\n user A\n end X\n }\n user B\n end Done\n}', + () => + compiled( + 'process p {\n start S\n while (true) {\n user A\n end X\n }\n user B\n end Done\n}', + ), [], true, ], [ 'a fork with an empty weighed branch beside an ending one prints whole', - 'process p {\n start S\n parallel {\n if (true) {\n }\n {\n end X\n }\n }\n end Done\n}', + () => + compiled( + 'process p {\n start S\n parallel {\n if (true) {\n }\n {\n end X\n }\n }\n end Done\n}', + ), [], true, ], [ 'a fork carrying settings with an ending branch prints whole', - 'process p {\n start S\n parallel (retryCycle: "R1/PT1M") {\n {\n user A\n }\n {\n end X\n }\n }\n end Done\n}', + () => + compiled( + 'process p {\n start S\n parallel (retryCycle: "R1/PT1M") {\n {\n user A\n }\n {\n end X\n }\n }\n end Done\n}', + ), [], true, ], - ])('%s', async (_title, source, categories, valid) => { - const { ir: imported, warnings } = await xmlToIr( - await irToXml(astToIr(await parseToAst(source))), - ); - expect(warnings).toEqual([]); - const printed = irToDsl(imported); - - expect(printed.warnings.map((w) => w.category)).toEqual(categories); - expect( - await parseToAst(printed.source).then( - () => true, - () => false, - ), - ).toBe(valid); - }); - - // `do { end X } while (true)` inside a branch is refused as source, since - // the loop gateway would have no incoming flow, so the two shapes below - // skip validation, the way a Modeler file carrying them would import. - it.each<[string, string]>([ [ 'a race branch holding a post-test loop whose body ends draws a jump into the branch', - 'process p {\n start S\n await {\n timer("PT1M") {\n }\n message("m") {\n do {\n end X\n } while (true)\n }\n }\n end Done\n}', + () => + compiledUnvalidated( + 'process p {\n start S\n await {\n timer("PT1M") {\n }\n message("m") {\n do {\n end X\n } while (true)\n }\n }\n end Done\n}', + ), + ['refusedStatement'], + false, ], [ 'a fork branch holding a post-test loop whose body ends draws a jump into the branch', - 'process p {\n start S\n parallel {\n {\n user F\n }\n if (true) {\n do {\n end S2\n } while (true)\n }\n }\n end Done\n}', + () => + compiledUnvalidated( + 'process p {\n start S\n parallel {\n {\n user F\n }\n if (true) {\n do {\n end S2\n } while (true)\n }\n }\n end Done\n}', + ), + ['refusedStatement'], + false, ], - ])('%s', async (_title, source) => { - const { ir: imported, warnings } = await xmlToIr( - await irToXml(astToIr(await parseUnvalidated(source))), - ); - expect(warnings).toEqual([]); - const printed = irToDsl(imported); - - expect(printed.warnings.map((w) => w.category)).toEqual([ - 'refusedStatement', - ]); - expect( - await parseToAst(printed.source).then( - () => true, - () => false, - ), - ).toBe(false); - }); - - // `do { end X } while (true)` compiles a loop gateway with no incoming - // flow: the body always ends, so `lowerDoWhile` never wires the back edge - // from the body into the gateway, only the gateway's two outgoing edges. - // A step after such a loop can no longer be written as source, since the - // validator now refuses a statement it can prove unreachable, so this - // shape is built as IR directly, the way a Modeler file would import it. - const DANGLING_LOOP_HEAD: BpmnProcess = { - id: 'DanglingLoopHead', - isExecutable: true, - flowElements: [ - { kind: 'startEvent', id: 'S' }, - { kind: 'endEvent', id: 'X' }, - { kind: 'exclusiveGateway', id: 'Gateway_L_loop', defaultFlowId: 'f3' }, - { kind: 'endEvent', id: 'Done' }, - ], - sequenceFlows: [ - flow('f1', 'S', 'X'), - flow('f2', 'Gateway_L_loop', 'X', '${true}'), - flow('f3', 'Gateway_L_loop', 'Done'), + [ + 'a post-test loop whose body ends prints its unreachable head as a jump', + () => Promise.resolve(DANGLING_LOOP_HEAD), + [], + false, ], - }; - - it('a post-test loop whose body ends prints its unreachable head as a jump', async () => { + ])('%s', async (_title, shape, categories, valid) => { const { ir: imported, warnings } = await xmlToIr( - await irToXml(DANGLING_LOOP_HEAD), + await irToXml(await shape()), ); expect(warnings).toEqual([]); const printed = irToDsl(imported); - expect(printed.warnings.map((w) => w.category)).toEqual([]); + expect(printed.warnings.map((w) => w.category)).toEqual(categories); expect( await parseToAst(printed.source).then( () => true, () => false, ), - ).toBe(false); + ).toBe(valid); }); }); diff --git a/tests/helpers/bpmn-doc.ts b/tests/helpers/bpmn-doc.ts new file mode 100644 index 0000000..150a61c --- /dev/null +++ b/tests/helpers/bpmn-doc.ts @@ -0,0 +1,11 @@ +/** One `` document: `roots` sit before the process, `body` inside it. */ +export function bpmnDoc(body: string, roots = ''): string { + return ( + '\n' + + '\n' + + roots + + ` \n${body}\n \n` + + '' + ); +} diff --git a/tests/helpers/di-bounds.ts b/tests/helpers/di-bounds.ts index 87b28a9..0625262 100644 --- a/tests/helpers/di-bounds.ts +++ b/tests/helpers/di-bounds.ts @@ -11,7 +11,7 @@ export interface Bounds { height: number; } -// Regex rather than a parser: the tests workspace declares no moddle dependency. +// Regex, for the reason xml-query.ts gives. export function parseShapeBounds(xml: string): Map { const shape = /]*\bbpmnElement="([^"]+)"[^>]*>\s* { it('every flow node has a shape and only a boundary and its own host overlap', () => { @@ -80,7 +75,6 @@ export function describeNoOverlappingShapes(rt: RoundTrip): void { }); } -/** DI is emitted once for the whole document, not once per container. */ export function describeSingleDiagram(rt: RoundTrip): void { describe('DI on the generated .bpmn', () => { it('exactly one bpmndi:BPMNDiagram is emitted', () => { @@ -127,13 +121,11 @@ function assertShapeContainment( } } -// An event sub-process is a disconnected node, so the layout library only -// places its box and its children inside the parent when the `isExpanded="true"` -// stub irToXml emits is present. Removing that stub fails this block. -// -// `requiredIds` stops the walk passing because the interesting containers are -// absent; pass a thunk when the ids come from the IR, which is readable only -// after the pipeline has run. +// The layout library places a disconnected event sub-process and its children +// inside the parent only with the `isExpanded="true"` stub irToXml emits; drop +// the stub and this block fails. `requiredIds` keeps the walk from passing +// because the interesting containers are absent; a thunk defers ids that come +// from the IR, readable only once the pipeline has run. export function describeDiContainment( rt: RoundTrip, requiredIds: readonly string[] | (() => readonly string[]) = [], diff --git a/tests/helpers/e2e-fixture.ts b/tests/helpers/e2e-fixture.ts index 482eae2..f575bd3 100644 --- a/tests/helpers/e2e-fixture.ts +++ b/tests/helpers/e2e-fixture.ts @@ -17,8 +17,7 @@ export const SKIP_DOCKER = process.env.SKIP_DOCKER_TESTS === 'true'; // A cold image build plus Spring Boot startup needs this much. export const ENGINE_BOOT_TIMEOUT_MS = 300_000; -// Stopping and removing the container, which is bounded by the Docker stop -// grace period rather than by anything the engine does. +// Bounded by the Docker stop grace period, not by the engine. export const ENGINE_STOP_TIMEOUT_MS = 120_000; export function dslPath(name: string): string { @@ -47,7 +46,7 @@ export async function deployExamples( return { name, xmlPath }; }); - const fixture = await startFixture('spring-boot'); + const fixture = await startFixture(); for (const { name, xmlPath } of built) { const { deploymentId } = await fixture.deploy(xmlPath, `${name}-test`); expect(deploymentId).toBeTruthy(); diff --git a/tests/helpers/engine-rest.ts b/tests/helpers/engine-rest.ts index c98deb1..ac21b3e 100644 --- a/tests/helpers/engine-rest.ts +++ b/tests/helpers/engine-rest.ts @@ -25,32 +25,48 @@ export async function engineGet( return (await response.json()) as T; } +async function engineSend( + fixture: FixtureAdapter, + resource: string, + body: unknown, + context: string, + method: 'POST' | 'PUT' = 'POST', +): Promise { + const response = await fetch(fixture.restBaseUrl() + resource, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + await assertOk(response, context); + return response; +} + export async function correlateMessage( fixture: FixtureAdapter, messageName: string, processInstanceId: string, ): Promise { - const response = await fetch(fixture.restBaseUrl() + '/engine-rest/message', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ messageName, processInstanceId }), - }); - await assertOk(response, `correlateMessage(${messageName})`); + await engineSend( + fixture, + '/engine-rest/message', + { messageName, processInstanceId }, + `correlateMessage(${messageName})`, + ); } -// A message with no instance to aim at starts one: the engine matches it -// against the message start events of every deployed definition. -// `resultEnabled` is what makes the response name the instance it created. +// With no instance named, the engine matches the message against every +// deployed message start event; `resultEnabled` makes the response name the +// instance it created. export async function startByMessage( fixture: FixtureAdapter, messageName: string, ): Promise { - const response = await fetch(fixture.restBaseUrl() + '/engine-rest/message', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ messageName, resultEnabled: true }), - }); - await assertOk(response, `startByMessage(${messageName})`); + const response = await engineSend( + fixture, + '/engine-rest/message', + { messageName, resultEnabled: true }, + `startByMessage(${messageName})`, + ); const results = (await response.json()) as Array<{ processInstance: { id: string }; }>; @@ -67,12 +83,12 @@ export async function broadcastSignal( fixture: FixtureAdapter, name: string, ): Promise { - const response = await fetch(fixture.restBaseUrl() + '/engine-rest/signal', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }), - }); - await assertOk(response, `broadcastSignal(${name})`); + await engineSend( + fixture, + '/engine-rest/signal', + { name }, + `broadcastSignal(${name})`, + ); } export interface EngineJob { @@ -94,7 +110,6 @@ export async function jobsOf( ); } -// The jobs an instance is parked on, async continuations included. export async function jobsOfInstance( fixture: FixtureAdapter, processInstanceId: string, @@ -112,10 +127,10 @@ export interface JobDefinition { jobConfiguration: string; } -// The engine creates one job definition per activity that needs a job at -// all, so an activity without an async continuation has none. A repeated -// activity is two activities to the engine, the body under -// `#multiInstanceBody` and the run under ``, each with its own. +// The engine creates one job definition per activity that needs a job, so an +// activity without an async continuation has none; a repeated activity is two +// activities to the engine, `#multiInstanceBody` and ``, each with its +// own. export async function jobDefinitionsFor( fixture: FixtureAdapter, processDefinitionKey: string, @@ -129,24 +144,19 @@ export async function jobDefinitionsFor( } // A suspended job definition stamps its state onto jobs created later too, -// which is what holds the async continuation still long enough to observe +// which is what holds an async continuation still long enough to observe // instead of racing the job executor for it. export async function setJobDefinitionSuspended( fixture: FixtureAdapter, jobDefinitionId: string, suspended: boolean, ): Promise { - const response = await fetch( - `${fixture.restBaseUrl()}/engine-rest/job-definition/${encodeURIComponent(jobDefinitionId)}/suspended`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ suspended, includeJobs: true }), - }, - ); - await assertOk( - response, + await engineSend( + fixture, + `/engine-rest/job-definition/${encodeURIComponent(jobDefinitionId)}/suspended`, + { suspended, includeJobs: true }, `setJobDefinitionSuspended(${jobDefinitionId}, ${suspended})`, + 'PUT', ); } @@ -205,7 +215,6 @@ export interface HistoricActivityInstance { canceled: boolean; } -// Running and finished alike. export async function historicActivities( fixture: FixtureAdapter, processInstanceId: string, @@ -224,9 +233,8 @@ export interface EventSubscription { processInstanceId: string; } -// The engine's record of which trigger an instance is parked on. A message -// intermediate catch holds an active `message` subscription while the token -// sits at it, and the subscription goes once the message is consumed. +// Which trigger an instance is parked on: a message catch holds a `message` +// subscription while the token sits at it, gone once the message is consumed. export async function eventSubscriptions( fixture: FixtureAdapter, processInstanceId: string, @@ -238,9 +246,9 @@ export async function eventSubscriptions( ); } -// Asks the single-instance resource, not the collection query: the runtime -// collection has no processInstanceId filter, so a query built on that name -// answers for every other instance the shared engine holds. A 404 means the +// The single-instance resource, not the collection query: the runtime +// collection has no `processInstanceId` filter, so a query built on that name +// answers for every instance the shared engine holds. A 404 means the // instance left the runtime, which for a process nothing cancels means it // ran to its end. export async function isRunning( @@ -257,10 +265,10 @@ export async function isRunning( return true; } -// Operaton's REST API is eventually consistent: after a correlated message, a -// completed task, or a fired event, the successor state can stay invisible to a -// query for a moment. Poll, then assert on the last value read, so a real -// mismatch still fails, just later. +// Operaton's REST API is eventually consistent, so the state after a correlated +// message, a completed task, or a fired event can stay invisible to a query for +// a moment. Polls, then hands back the last value read, so a real mismatch +// still fails the caller's assertion, just later. export async function waitFor( probe: () => Promise, predicate: (value: T) => boolean, @@ -275,8 +283,8 @@ export async function waitFor( return value; } -// Polls until the instance has left the runtime. The caller asserts on the -// answer, so an instance that never finishes fails rather than hangs. +// Returns rather than throws on timeout, so an instance that never finishes +// fails the caller's assertion instead of hanging. export async function waitUntilFinished( fixture: FixtureAdapter, processInstanceId: string, @@ -309,8 +317,6 @@ export function activeTaskKeys( return tasks.map((task) => task.taskDefinitionKey).sort(); } -// The sorted definition keys of whatever is active once the predicate holds, -// for asserting which tasks a branch opened rather than acting on one of them. export async function waitForTaskKeys( fixture: FixtureAdapter, processInstanceId: string, @@ -322,8 +328,8 @@ export async function waitForTaskKeys( return activeTaskKeys(tasks); } -// The runtime id of an active task, which completing one needs: the definition -// key names the modeled activity, the id names this instance's token. +// Completing a task needs its runtime id: the definition key names the modeled +// activity, the id names this instance's token. export async function waitForTaskId( fixture: FixtureAdapter, processInstanceId: string, @@ -341,7 +347,6 @@ export async function waitForTaskId( return match.id; } -// Every activity the instance has visited, once the named one is among them. export async function activityIdsIncluding( fixture: FixtureAdapter, processInstanceId: string, @@ -362,15 +367,12 @@ export async function startWithBusinessKey( processDefinitionKey: string, businessKey: string, ): Promise { - const response = await fetch( - `${fixture.restBaseUrl()}/engine-rest/process-definition/key/${encodeURIComponent(processDefinitionKey)}/start`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ businessKey }), - }, + const response = await engineSend( + fixture, + `/engine-rest/process-definition/key/${encodeURIComponent(processDefinitionKey)}/start`, + { businessKey }, + `startWithBusinessKey(${processDefinitionKey})`, ); - await assertOk(response, `startWithBusinessKey(${processDefinitionKey})`); return ((await response.json()) as { id: string }).id; } @@ -385,9 +387,8 @@ export interface LockedExternalTask { extensionProperties: Record; } -// Locks the topic's task of the instance carrying the business key, as a -// worker would. `includeExtensionProperties` is what puts the task's -// `operaton:property` map on the answer; `priority` comes regardless. +// `includeExtensionProperties` is what puts the task's `operaton:property` map +// on the answer; `priority` comes regardless. export async function fetchAndLock( fixture: FixtureAdapter, topicName: string, @@ -395,26 +396,23 @@ export async function fetchAndLock( ): Promise { const locked = await waitFor( async () => { - const response = await fetch( - `${fixture.restBaseUrl()}/engine-rest/external-task/fetchAndLock`, + const response = await engineSend( + fixture, + '/engine-rest/external-task/fetchAndLock', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workerId: WORKER_ID, - maxTasks: 1, - topics: [ - { - topicName, - businessKey, - lockDuration: 60_000, - includeExtensionProperties: true, - }, - ], - }), + workerId: WORKER_ID, + maxTasks: 1, + topics: [ + { + topicName, + businessKey, + lockDuration: 60_000, + includeExtensionProperties: true, + }, + ], }, + `fetchAndLock(${topicName})`, ); - await assertOk(response, `fetchAndLock(${topicName})`); return (await response.json()) as LockedExternalTask[]; }, (tasks) => tasks.length > 0, @@ -431,15 +429,12 @@ export async function completeExternalTask( fixture: FixtureAdapter, taskId: string, ): Promise { - const response = await fetch( - `${fixture.restBaseUrl()}/engine-rest/external-task/${encodeURIComponent(taskId)}/complete`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workerId: WORKER_ID }), - }, + await engineSend( + fixture, + `/engine-rest/external-task/${encodeURIComponent(taskId)}/complete`, + { workerId: WORKER_ID }, + `completeExternalTask(${taskId})`, ); - await assertOk(response, `completeExternalTask(${taskId})`); } export interface ExternalTaskFailure { @@ -448,23 +443,20 @@ export interface ExternalTaskFailure { retries: number; } -// Reports the failure the way a worker does. `ExternalTaskEntity.failed` runs -// the task's error mappings against the message before it touches the -// retries, so a matching message never leaves a task behind. +// `ExternalTaskEntity.failed` runs the task's error mappings against the +// message before it touches the retries, so a matching message never leaves a +// task behind. export async function failExternalTask( fixture: FixtureAdapter, taskId: string, failure: ExternalTaskFailure, ): Promise { - const response = await fetch( - `${fixture.restBaseUrl()}/engine-rest/external-task/${encodeURIComponent(taskId)}/failure`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workerId: WORKER_ID, ...failure }), - }, + await engineSend( + fixture, + `/engine-rest/external-task/${encodeURIComponent(taskId)}/failure`, + { workerId: WORKER_ID, ...failure }, + `failExternalTask(${taskId})`, ); - await assertOk(response, `failExternalTask(${taskId})`); } export interface ExternalTask { @@ -474,7 +466,6 @@ export interface ExternalTask { retries: number | null; } -// Every external task the instance holds, locked or not. export async function externalTasksOf( fixture: FixtureAdapter, processInstanceId: string, @@ -514,8 +505,7 @@ export interface IdentityLink { groupId: string | null; } -// The assignee and candidate links the engine created for the task, which is -// what `operaton:assignee`, `operaton:candidateUsers` and +// What `operaton:assignee`, `operaton:candidateUsers` and // `operaton:candidateGroups` resolve to at runtime. export async function identityLinksOf( fixture: FixtureAdapter, diff --git a/tests/helpers/ir-query.ts b/tests/helpers/ir-query.ts index 2a372e8..8560152 100644 --- a/tests/helpers/ir-query.ts +++ b/tests/helpers/ir-query.ts @@ -31,7 +31,6 @@ export function subProcess( return el; } -// Recurses into sub-processes. export function allElements(container: FlowContainer): FlowElement[] { return container.flowElements.flatMap((fe) => fe.kind === 'subProcess' ? [fe, ...allElements(fe)] : [fe], @@ -81,8 +80,8 @@ export function definitionOf( } // A handler's trigger sits on the start event of the event sub-process it -// lowers to, so match on what it catches, never on the synthesized id. Every -// event sub-process at any depth contributes, an unset trigger included. +// lowers to, so match on what it catches, never on the synthesized id; every +// depth contributes, an unset trigger included. export function handlerTriggerDefs( container: FlowContainer, ): (EventDefinition | undefined)[] { @@ -118,7 +117,6 @@ export function allOf( ); } -// Throws unless exactly one match, for the common case of one carrier per kind. export function theOnly( container: FlowContainer, kind: K, diff --git a/tests/helpers/normalize-ir.test.ts b/tests/helpers/normalize-ir.test.ts index cefad57..accaced 100644 --- a/tests/helpers/normalize-ir.test.ts +++ b/tests/helpers/normalize-ir.test.ts @@ -1,7 +1,3 @@ -// Two boundary handlers on one host sharing a trigger kind but differing in -// payload both base to `Boundary_Pack_error`, and moddle may present them on -// import in a different order than the author wrote them. A re-key that ignored -// the payload would collapse them and mask the reordering. import { describe, it, expect } from 'vitest'; import { normalizeIr } from './normalize-ir.js'; import { gatewayDefaultFlowId, isGateway } from '@bpmn-script/transform'; @@ -94,101 +90,63 @@ describe('normalizeIr: boundary-event re-key', () => { expect(new Set(boundaryIds(forward)).size).toBe(2); }); - it('re-keys the sequence flow leaving a boundary event to match its canonical id', () => { - const ir = process( - [ - { kind: 'userTask', id: 'Review' }, - boundary('Timeout_Boundary', 'Review', TIMER_PT2H), - { kind: 'userTask', id: 'Escalate' }, - ], - [ - { - id: 'Flow_Timeout', - sourceRef: 'Timeout_Boundary', - targetRef: 'Escalate', - }, - ], - ); - - const normalized = normalizeIr(ir); - const attacher = normalized.flowElements.find( - (fe) => fe.kind === 'boundaryEvent', - ); - - expect(attacher).toBeDefined(); - expect(attacher?.id).not.toBe('Timeout_Boundary'); - expect(normalized.sequenceFlows[0].sourceRef).toBe(attacher?.id); - // attachedToRef is an authored host id and is never re-keyed. - expect( - attacher && 'attachedToRef' in attacher && attacher.attachedToRef, - ).toBe('Review'); - }); - - it('distinguishes an interrupting boundary from an otherwise-identical non-interrupting one', () => { - const interrupting = process( - [ - { kind: 'userTask', id: 'Ship' }, - boundary('A', 'Ship', { kind: 'message', messageName: 'Cancel' }), - ], - [], - ); - const alongside = process( - [ - { kind: 'userTask', id: 'Ship' }, - boundary( - 'B', - 'Ship', - { kind: 'message', messageName: 'Cancel' }, - false, + it.each([ + [ + 'the host', + boundary('X', 'Pack', { kind: 'error', errorCode: 'A' }), + boundary('Y', 'Ship', { kind: 'error', errorCode: 'A' }), + ], + [ + 'the interrupting flag', + boundary('X', 'Pack', { kind: 'message', messageName: 'Cancel' }), + boundary('Y', 'Pack', { kind: 'message', messageName: 'Cancel' }, false), + ], + ])( + 'two boundaries alike in all but %s get distinct ids without a positional suffix', + (_axis, a, b) => { + // Drop the axis from the signature and the two collapse onto one + // canonical id told apart only by `#1`. + const ids = boundaryIds( + process( + [ + { kind: 'userTask', id: 'Pack' }, + { kind: 'userTask', id: 'Ship' }, + a, + b, + ], + [], ), - ], - [], - ); - - expect(boundaryIds(interrupting)).not.toEqual(boundaryIds(alongside)); - }); - - it('keeps two same-trigger same-payload boundary events on different hosts distinct', () => { - // Drop the host from the signature and these two collapse to one canonical - // id with positional suffixes, the exact hazard the re-key removes. - const ir = process( - [ - { kind: 'userTask', id: 'Pack' }, - { kind: 'userTask', id: 'Ship' }, - boundary('PackTimeout', 'Pack', { kind: 'error', errorCode: 'X' }), - boundary('ShipTimeout', 'Ship', { kind: 'error', errorCode: 'X' }), - ], - [], - ); - - const ids = boundaryIds(ir); - expect(new Set(ids).size).toBe(2); - // A positional suffix would mean the two signatures had collided. - for (const id of ids) expect(id).not.toContain('#'); - }); + ); + expect(ids).toHaveLength(2); + expect(ids.filter((id) => id.includes('#'))).toEqual([]); + }, + ); - it('leaves every end event untouched, including one named after a boundary event', () => { - // The printer emits a terminal end under its literal id, so it is authored - // again on the way back and needs no canonical mapping. + it('re-keys gateways and boundaries alone: a task and an end event keep their ids, and only the gateway loses its name', () => { const ir = process( [ - { kind: 'userTask', id: 'Review' }, + { kind: 'userTask', id: 'Review', name: 'Review the claim' }, + { kind: 'exclusiveGateway', id: 'Decide', name: 'Approved?' }, boundary('Timeout_Boundary', 'Review', TIMER_PT2H), { kind: 'endEvent', id: 'EndEvent_Timeout_Boundary' }, ], - [], + [{ id: 'Flow_1', sourceRef: 'Review', targetRef: 'Decide' }], ); - const ids = normalizeIr(ir) - .flowElements.filter((fe) => fe.kind === 'endEvent') - .map((fe) => fe.id); - expect(ids).toEqual(['EndEvent_Timeout_Boundary']); - }); - - it('leaves a boundary-free container byte-identical to the un-normalized shape', () => { - const ir = process([{ kind: 'userTask', id: 'Solo' }], []); - - expect(normalizeIr(ir)).toEqual(ir); + expect(normalizeIr(ir).flowElements).toEqual([ + { + kind: 'boundaryEvent', + id: 'Boundary_[host:Review]_[trigger:timer]_[code:duration PT2H]_[interrupting]', + attachedToRef: 'Review', + eventDefinition: TIMER_PT2H, + }, + { kind: 'endEvent', id: 'EndEvent_Timeout_Boundary' }, + { + kind: 'exclusiveGateway', + id: 'Gateway_exclusiveGateway_[in:Review]_[out:]', + }, + { kind: 'userTask', id: 'Review', name: 'Review the claim' }, + ]); }); }); @@ -315,7 +273,9 @@ describe('normalizeIr: gateway re-key', () => { const gateway = authored.flowElements.find( (fe) => fe.kind === 'eventBasedGateway', ); - expect(gateway?.id).toContain('Gateway_eventBasedGateway_'); + expect(gateway?.id).toBe( + 'Gateway_eventBasedGateway_[in:Escalate]_[out:Wait]', + ); }); }); diff --git a/tests/helpers/normalize-ir.ts b/tests/helpers/normalize-ir.ts index 4929a53..a6db52f 100644 --- a/tests/helpers/normalize-ir.ts +++ b/tests/helpers/normalize-ir.ts @@ -1,14 +1,9 @@ -// One half of a round-trip comparison carries hand-authored ids, the other the -// deterministic ids the pipeline synthesizes (ADR 0010). They are semantically -// equal but mechanically different, so this canonicalizes the differences away -// before `toEqual`. -// -// A subProcess's flows never cross its boundary, so every step here runs per -// container at every depth. -// -// Gateways are re-keyed by structural position, not by the synthesized-id -// regex, because the handwritten counterpart is hand-named. Task and event ids -// are never re-keyed: they have to survive the round trip verbatim. +// One half of a round-trip comparison carries authored ids, the other the ids +// the pipeline synthesizes (ADR 0010); this canonicalizes that difference away +// before `toEqual`. Gateways, boundaries and event sub-processes are re-keyed +// by structural position, since the handwritten counterpart is hand-named; +// task and event ids are never re-keyed and have to survive verbatim. Flows +// never cross a sub-process boundary, so every step runs per container. import { ENGINE_KEYS } from '@bpmn-script/language'; import { @@ -42,9 +37,9 @@ function normalizeContainer(container: T): T { const flowElements: FlowElement[] = inlined.flowElements .map((fe) => { - // A plain sub-process id is authored, so it stays. A `triggeredByEvent` - // one has no surface id and is re-keyed to its trigger signature. Its - // own `defaultFlowId` names a flow of this container, not of its body. + // A plain sub-process keeps its authored id; a `triggeredByEvent` one + // has no surface id and is re-keyed to its trigger. Its `defaultFlowId` + // names a flow of this container, not of its body. if (fe.kind === 'subProcess') { const normalized = { ...normalizeContainer(fe), ...reKeyedDefault(fe) }; return fe.triggeredByEvent === true @@ -57,13 +52,11 @@ function normalizeContainer(container: T): T { return { ...fe, id: canonicalId(fe.id) }; } - // A step's `defaultFlowId` points at a flow that is itself re-keyed - // below; a gateway's is handled with the gateway. if (!isGateway(fe)) return { ...fe, ...reKeyedDefault(fe) }; const id = canonicalId(fe.id); - // The structured syntax has no slot for a gateway label, so only gateways - // lose their name. Task and event names survive verbatim. + // The structured syntax has no slot for a gateway label, so only + // gateways lose their name. const { name: _name, ...withoutName } = fe; return { ...withoutName, id, ...reKeyedDefault(fe) }; }) @@ -93,12 +86,10 @@ function normalizeContainer(container: T): T { } as T; } -// A re-synthesized `if/else` always grows a join the hand-authored IR never -// had. Treating that join as transparent lets the two halves compare -// structurally. Only sibling flows of the same container are considered. -// A join carrying a job setting stays: inlining it would take the setting -// out of the comparison, and a `join*` key that one direction drops is what -// the comparison has to catch. +// A re-synthesized `if/else` grows a join the hand-authored IR never had, so +// such a join is treated as transparent. One carrying a job setting stays: +// inlining it would take the setting out of both sides of the comparison, and +// a `join*` key one direction drops is what the comparison has to catch. function inlinePassThroughJoins(ir: FlowContainer): FlowContainer { const successorOf = new Map(); for (const fe of ir.flowElements) { @@ -202,8 +193,6 @@ function boundarySignature(fe: FlowElement): string | undefined { return `Boundary_[host:${fe.attachedToRef}]_[trigger:${fe.eventDefinition.kind}]_[code:${code}]_[${interrupting}]`; } -// The datum that identifies a handler within its trigger kind, so two same-kind -// handlers with different payloads stay apart. function definitionPayloadKey(def: EventDefinition | undefined): string { if (def === undefined) return ''; switch (def.kind) { @@ -221,16 +210,14 @@ function definitionPayloadKey(def: EventDefinition | undefined): string { return `${def.timerKind} ${def.expression}`; case 'conditional': return def.condition; + // Payload-free; a container takes one undo block and a block one cancel + // handler, and a terminate is never a trigger, so a constant cannot + // collide. case 'compensation': - // The validator allows one undo block per container, so a constant here - // cannot collide. return ''; case 'terminate': - // Payload-free and at most one per container, so a constant cannot collide. return ''; case 'cancel': - // Payload-free, and the engine takes at most one cancel handler per - // block, so a constant cannot collide either. return ''; default: { const exhaustive: never = def; @@ -239,31 +226,23 @@ function definitionPayloadKey(def: EventDefinition | undefined): string { } } -// Re-keyed when the id starts with `Flow_` (the generated families) or either -// end is itself re-keyed. Everything else stays verbatim. +// `Flow_` is the prefix of every generated flow id. function normalizeFlow( sf: SequenceFlow, canonicalId: (id: string) => string, ): SequenceFlow { + const sourceRef = canonicalId(sf.sourceRef); + const targetRef = canonicalId(sf.targetRef); const touchesReKeyedNode = - canonicalId(sf.sourceRef) !== sf.sourceRef || - canonicalId(sf.targetRef) !== sf.targetRef; + sourceRef !== sf.sourceRef || targetRef !== sf.targetRef; if (/^Flow_/.test(sf.id) || touchesReKeyedNode) { return { ...sf, - id: canonicalFlowKey(sf, canonicalId), - sourceRef: canonicalId(sf.sourceRef), - targetRef: canonicalId(sf.targetRef), + id: `Flow_${sourceRef}_${targetRef}`, + sourceRef, + targetRef, }; } return sf; } - -// A re-keyed flow's id; `reKeyedDefault` reaches it through `normalizeFlow`, so the two agree. -function canonicalFlowKey( - sf: SequenceFlow, - canonicalId: (id: string) => string, -): string { - return `Flow_${canonicalId(sf.sourceRef)}_${canonicalId(sf.targetRef)}`; -} diff --git a/tests/helpers/pipeline.ts b/tests/helpers/pipeline.ts index 004403c..946ccbb 100644 --- a/tests/helpers/pipeline.ts +++ b/tests/helpers/pipeline.ts @@ -32,7 +32,20 @@ export async function parseToAst(source: string): Promise { return document.parseResult.value; } -// Every hop of DSL -> ir1 -> xml -> ir2 -> dsl -> ir3. +export type IrHops = readonly (readonly [label: string, ir: BpmnProcess])[]; + +export function irHops( + ir1: BpmnProcess, + ir2: BpmnProcess, + ir3: BpmnProcess, +): IrHops { + return [ + ['IR1', ir1], + ['IR2', ir2], + ['IR3', ir3], + ]; +} + export interface RoundTripRun { ir1: BpmnProcess; xml: string; @@ -40,14 +53,27 @@ export interface RoundTripRun { ir2: BpmnProcess; dsl: string; ir3: BpmnProcess; + hops: IrHops; } +// The cli's build.ts and the extension's conversion-core.ts run the same +// astToIr -> irToXml chain, each with its own failure reporting; here a +// failure throws. export async function roundTrip(source: string): Promise { const ir1 = astToIr(await parseToAst(source)); const xml = await irToXml(ir1); const { ir: ir2, warnings } = await xmlToIr(xml); const dsl = printDsl(ir2); - return { ir1, xml, warnings, ir2, dsl, ir3: astToIr(await parseToAst(dsl)) }; + const ir3 = astToIr(await parseToAst(dsl)); + return { + ir1, + xml, + warnings, + ir2, + dsl, + ir3, + hops: irHops(ir1, ir2, ir3), + }; } // The returned run is filled by a beforeAll, so read it only from an `it` body. @@ -59,10 +85,9 @@ export function roundTripOf(source: string): RoundTripRun { return run; } -// Two full compile/decompile passes, chaining the second onto the first's -// printed DSL. Every other suite stops after one hop each direction, which -// misses a value that is stable on the first print but drifts on the second -// (a fuzz-only class of bug: idempotence, not correctness of a single hop). +// The second pass starts from the first's printed DSL, catching a value that +// is stable on the first print but drifts on the second, which one hop each +// direction cannot see. export interface RoundTripTwice { xml1: string; dsl1: string; diff --git a/tests/helpers/round-trip-fixture.ts b/tests/helpers/round-trip-fixture.ts index 49c1e89..620a802 100644 --- a/tests/helpers/round-trip-fixture.ts +++ b/tests/helpers/round-trip-fixture.ts @@ -14,7 +14,8 @@ import { xmlToIr, astToIr, irToXml } from '@bpmn-script/transform'; import type { BpmnProcess } from '@bpmn-script/transform'; import { normalizeIr } from './normalize-ir.js'; -import { parse, parseToAst, printDsl, validate } from './pipeline.js'; +import { irHops, parse, parseToAst, printDsl, validate } from './pipeline.js'; +import type { IrHops } from './pipeline.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -34,11 +35,10 @@ export interface RoundTripOptions { // and re-desugars normalized-equal to IR1. importPath?: boolean; - // How strictly DSL' validation is asserted, and where. 'clean' checks every - // diagnostic and only fits fixtures that name every throw and emit, so - // nothing synthesizes an id the reserved-name check rejects; 'errors' checks - // error severity only, for fixtures whose decompiled form does warn. - // 'errors-standalone' is 'errors' in a block of its own. + // 'clean' checks every diagnostic and fits only fixtures that name every + // throw and emit, so nothing synthesizes an id the reserved-name check + // rejects; 'errors' checks error severity alone, for fixtures whose decompiled + // form does warn; 'errors-standalone' is 'errors' in a block of its own. recompile: 'errors' | 'clean' | 'errors-standalone'; validatorCleanTitles?: [describeTitle: string, itTitle: string]; @@ -57,7 +57,7 @@ export interface RoundTrip { ir1: BpmnProcess; ir2: BpmnProcess; ir3: BpmnProcess; - hops: readonly (readonly [label: string, ir: BpmnProcess])[]; + hops: IrHops; dslPrime: string; // The next two are only filled when `importPath` is on. importWarnings: Awaited>['warnings']; @@ -94,11 +94,7 @@ export function roundTripFixture( )); rt.dslPrime = printDsl(rt.ir2); rt.ir3 = astToIr(await parseToAst(rt.dslPrime)); - rt.hops = [ - ['IR1', rt.ir1], - ['IR2', rt.ir2], - ['IR3', rt.ir3], - ]; + rt.hops = irHops(rt.ir1, rt.ir2, rt.ir3); if (options.importPath === true) { const imported = await xmlToIr(rt.frozenXml); diff --git a/tests/helpers/xml-query.ts b/tests/helpers/xml-query.ts index 6a367b8..e0e43c4 100644 --- a/tests/helpers/xml-query.ts +++ b/tests/helpers/xml-query.ts @@ -1,20 +1,17 @@ // Regex rather than a parser: the tests workspace declares no moddle dependency. -/** Every id carried by a `>` open tag, in document order. */ export function idsOfTag(xml: string, tag: string): string[] { return [...xml.matchAll(new RegExp(` m[1]!, ); } -/** Every `bpmn:message` root in the document, in document order. */ export function messageRoots(xml: string): { id: string; name: string }[] { return [...xml.matchAll(/ ({ id: m[1]!, name: m[2]! }), ); } -/** Every `bpmn:error` root carrying `errorCode`, with the message it declares. */ export function errorRoots( xml: string, errorCode: string, diff --git a/tests/import-contract.round-trip.test.ts b/tests/import-contract.round-trip.test.ts index 6094c6a..fe4949c 100644 --- a/tests/import-contract.round-trip.test.ts +++ b/tests/import-contract.round-trip.test.ts @@ -7,6 +7,12 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { astToIr, + COMPENSATION_BOUNDARY_DETAIL, + CONNECTOR_CONSTRUCT, + dataConstructDropMessage, + IS_FOR_COMPENSATION_DETAIL, + loopDroppedMessage, + manualTaskMessage, xmlToIr, UnsupportedConditionExpressionError, UnsupportedElementError, @@ -16,23 +22,9 @@ import { } from '@bpmn-script/transform'; import type { BpmnProcess, ImportWarning } from '@bpmn-script/transform'; +import { bpmnDoc } from './helpers/bpmn-doc.js'; import { normalizeIr } from './helpers/normalize-ir.js'; -import { parse, parseToAst, printDsl, validate } from './helpers/pipeline.js'; - -const NAMESPACES = - 'xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" ' + - 'xmlns:operaton="http://operaton.org/schema/1.0/bpmn"'; - -/** One `` document: `roots` sit before the process, `body` inside it. */ -function bpmnDoc(body: string, roots = ''): string { - return ( - '\n' + - `\n` + - roots + - ` \n${body}\n \n` + - '' - ); -} +import { parseToAst, printDsl, validate } from './helpers/pipeline.js'; // `GlobalScriptTask.script` is declared `isAttr: true` in the moddle schema, // but the BPMN XSD makes `` an element, so this root exercises @@ -43,10 +35,6 @@ const GLOBAL_SCRIPT_TASK_ROOT = ' x\n' + ' \n'; -// A manual task, a de-looped step, all three data-plumbing kinds, and a -// scripted-looking condition (a lone operaton:resource, no language) that -// warns rather than refuses, all in one process alongside the compensation-free -// happy path so the interaction, not just each construct alone, is exercised. const FIXTURE = bpmnDoc( ' \n' + ' \n' + @@ -60,9 +48,8 @@ const FIXTURE = bpmnDoc( ' \n' + ' \n' + ' \n' + - // A literal comparison, not a process-variable read: the fixture is about - // the dropped operaton:resource, not about declaring a variable, and a - // free variable here would draw an unrelated "not declared" diagnostic. + // A literal comparison: a free variable would draw an unrelated "not + // declared" diagnostic. ' ${1 < 2}\n' + ' \n' + ' \n' + @@ -76,27 +63,18 @@ const IMPORTED_FLOW_NOTE = 'steps, and nothing declared or drawn beside it).'; const dataDropped = (tag: string, id: string) => - `A bpmn:${tag} '${id}' was not imported: Operaton keeps process ` + - 'variables in its own store and never dispatches on it, so the ' + - 'imported process runs identically.'; + dataConstructDropMessage(`bpmn:${tag} '${id}'`); const EXPECTED_WARNINGS: ImportWarning[] = [ { elementId: 'SignOff', category: 'unmappedConstruct', - message: - "The bpmn:manualTask 'SignOff' imports as a plain step: token flow, " + - 'waiting, listeners, async and job configuration are all unchanged, ' + - "but history and Cockpit will report its activity type as 'task' " + - "rather than 'manualTask'.", + message: manualTaskMessage('SignOff'), }, { elementId: 'Review', category: 'unmappedConstruct', - message: - "The bpmn:standardLoopCharacteristics on 'Review' was not imported: " + - 'Operaton does not run one at all, it deploys the step and runs it ' + - 'once, so the imported step runs once too.', + message: loopDroppedMessage('bpmn:standardLoopCharacteristics', 'Review'), }, { elementId: 'OrderDetails', @@ -129,7 +107,7 @@ const EXPECTED_WARNINGS: ImportWarning[] = [ }, ]; -describe('a document holding every construct this phase converted', () => { +describe('a document holding every construct the import converts', () => { const state = {} as { ir: BpmnProcess; warnings: ImportWarning[]; @@ -147,10 +125,7 @@ describe('a document holding every construct this phase converted', () => { expect(state.warnings).toEqual(EXPECTED_WARNINGS); }); - it('the script printed from that import re-parses with no parser errors and validates clean', async () => { - const document = await parse(state.dsl); - expect(document.parseResult.parserErrors).toEqual([]); - + it('the script printed from that import validates clean', async () => { const { diagnostics } = await validate(state.dsl); expect(diagnostics).toEqual([]); }); @@ -161,12 +136,8 @@ describe('a document holding every construct this phase converted', () => { }); }); -/** - * Runs `xmlToIr(xml)` and asserts it refuses as `errorClass`, returning the - * error. Typed to the `Error` base, not the refusal subclass: the table below - * mixes every refusal class in one column, and narrowing per row is left to - * the one caller (the paired-triple test) that reads a subclass-only field. - */ +// Typed to the `Error` base since the table mixes every refusal class in one +// column; the one caller reading a subclass field narrows itself. async function refusalError( xml: string, errorClass: abstract new (...args: never[]) => Error, @@ -180,28 +151,10 @@ async function refusalError( throw new Error('expected xmlToIr to refuse the document'); } -const CONNECTOR_CONSTRUCT = - 'an element, which the Connect plugin runs in ' + - 'place of whatever operaton:class, expression, delegateExpression, or ' + - 'type names beside it, and which an engine without the plugin runs ' + - 'instead of, so the same file has two possible executions'; - const CONNECTOR_CHILD = 'http-connector' + ''; -const IS_FOR_COMPENSATION_DETAIL = - 'isForCompensation="true" marks this activity as excluded from normal ' + - 'flow: the boundary-event compensation-handler pattern, which this ' + - 'tool cannot import; wrap the steps in their own subprocess and ' + - 'target it with "on compensation" instead'; - -const COMPENSATION_BOUNDARY_DETAIL = - 'a compensation boundary event is not imported: BPMN attaches ' + - 'compensation through isForCompensation and a bpmn:association on ' + - 'the activity being compensated, not a boundary event; wrap the ' + - 'steps in their own subprocess and target it with "on compensation" instead'; - const PAIRED_TRIPLE_XML = bpmnDoc( ' \n' + ' \n' + @@ -224,7 +177,7 @@ const REWRITE_PREVIEW = [ '}', ].join('\n'); -describe('every construct this phase refuses names itself in its own message', () => { +describe('every construct the import refuses names itself in its own message', () => { it.each([ [ 'a scripted condition expression names the language it would run in', @@ -409,9 +362,6 @@ describe('every construct this phase refuses names itself in its own message', ( expect(rewrite).toBe(REWRITE_PREVIEW); const wrapped = `process Preview {\n${rewrite}\n}\n`; - const document = await parse(wrapped); - expect(document.parseResult.parserErrors).toEqual([]); - const { diagnostics } = await validate(wrapped); expect(diagnostics).toEqual([]); }); diff --git a/tests/input-output.round-trip.test.ts b/tests/input-output.round-trip.test.ts index 0d439d9..5e764c2 100644 --- a/tests/input-output.round-trip.test.ts +++ b/tests/input-output.round-trip.test.ts @@ -112,10 +112,15 @@ describe("idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3", () => { } }); - it('every map key comes back quoted, keyword-shaped or not', () => { - expect(rt.dslPrime).toContain('"start": "${payoutStart}"'); - expect(rt.dslPrime).toContain('"lines": ["principal", "vat"]'); - expect(rt.dslPrime).toContain('"field": "receipts"'); + it('every map key prints quoted, keyword-shaped or not', () => { + const literals = rt.dslPrime + .split('\n') + .map((line) => line.trim()) + .filter((line) => /^(input|output) \w+ = [[{]/.test(line)); + expect(literals).toEqual([ + 'input auditChecks = [{ "field": "receipts", "rule": "present" }, { "field": "mileage", "rule": "within-policy" }]', + 'output payoutReceipt = { "start": "${payoutStart}", "lines": ["principal", "vat"] }', + ]); }); }); @@ -145,30 +150,39 @@ describe('the call activity keeps its variable mappings and its parameters apart } }); + // Variable mappings sit beside the inputOutput block, not inside it. Nested + // in it they would still be present but would stop being read as mappings. it('the frozen call activity serializes both, as siblings under extensionElements', () => { - const element = //.exec( - rt.frozenXml, - )?.[0]; - expect(element, 'no callActivity in the frozen artifact').toBeDefined(); - - expect(element).toContain( - '', - ); - expect(element).toContain( - '', + expect( + //.exec( + rt.frozenXml, + )?.[0], + ).toBe( + [ + '', + ' ', + ' ', + ' sepa', + ' ', + ' ', + ' ${payoutStart}', + ' ', + ' ', + ' principal', + ' vat', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' Flow_AuditClaim_PayOut', + ' Flow_PayOut_ClaimSettled', + ' ', + ].join('\n'), ); - - // Variable mappings sit beside the inputOutput block, not inside it. Nested - // in it they would still be present but would stop being read as mappings. - const block = /[\s\S]*?<\/operaton:inputOutput>/.exec( - element ?? '', - )?.[0]; - expect(block, 'no inputOutput block on the call activity').toBeDefined(); - - expect(block).toContain(''); - expect(block).toContain(''); - expect(block).not.toContain(' IR3", () => { } }); - it('no synthesized Catch_ id token leaks into the decompiled source', () => { - // `await` has no name slot, so the decompiler prints the trigger and its - // payload, never the synthesized goto-target id. - expect(rt.dslPrime).not.toMatch(/Catch_/); + it("the decompiled DSL' prints each catch as a bare `await` on its trigger, never the synthesized id", () => { + expect(rt.dslPrime).toBe( + [ + 'process order-processing {', + ' var amount: any', + ' start OrderReceived', + ' user ReviewOrder(label: "Review the order", assignee: "demo")', + ' await message("PaymentConfirmed")', + ' await timer("PT1H")', + ' await signal("StockReplenished")', + ' await condition(amount > 100)', + ' service DispatchOrder(label: "Dispatch the order", class: "com.example.orders.DispatchDelegate")', + ' end OrderDispatched', + '}', + '', + ].join('\n'), + ); }); }); diff --git a/tests/invoice-receipt.import.test.ts b/tests/invoice-receipt.import.test.ts index 64db589..fbd53f9 100644 --- a/tests/invoice-receipt.import.test.ts +++ b/tests/invoice-receipt.import.test.ts @@ -1,11 +1,8 @@ -// The Operaton invoice example, the thesis's demonstration process: a pool -// wrapping three lanes, a data store and its reference, an unreferenced -// message root, and an approve/review loop whose inner split names no -// default (`invoice_approved`). `describeImportFirst` asserts a warning-free -// import, which this document is not, so the pipeline is driven by hand -// here: every per-feature suite in this package pins one shape apiece, and -// this one pins that the whole file survives import, print, re-parse, -// validation and a second import together. +// The Operaton invoice example: a pool wrapping three lanes, a data store and +// its reference, an unreferenced message root, and an approve/review loop +// whose inner split names no default. It imports with warnings, which +// `describeImportFirst` refuses, so the pipeline runs by hand and pins that +// the whole file survives import, print, validation and a second import. import { describe, it, expect, beforeAll } from 'vitest'; import { readFileSync } from 'node:fs'; @@ -24,11 +21,6 @@ const FIXTURE = readFileSync( 'utf-8', ); -// The whole print: the start, the async respelling on the archive step, both -// loop variables declared, the review loop restructured as `do ... while` -// and no dropped edge. The assignment listener keeps its script body, -// printed like `on create` just above it; the listener carries no settings, -// so no parens precede the fenced body. const PRINTED = [ 'process invoice(label: "Invoice Receipt", versionTag: "V2.0", historyTimeToLive: "45") {', ' var clarified: any', diff --git a/tests/link-events.round-trip.test.ts b/tests/link-events.round-trip.test.ts index aeaf411..07a24fd 100644 --- a/tests/link-events.round-trip.test.ts +++ b/tests/link-events.round-trip.test.ts @@ -73,8 +73,34 @@ describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { }); it("the decompiled DSL' places each catch after a dead fall-through and keeps the rework chain structured", () => { - expect(rt.dslPrime).toContain( + expect(rt.dslPrime).toBe( [ + 'process claim-review {', + ' start Filed(label: "A claim is filed") {', + ' form {', + ' approved: boolean "Approved?"', + ' amount: number "Claim amount"', + ' receiptsComplete: boolean "Receipts complete?"', + ' }', + ' }', + ' user AssessClaim(label: "Assess the claim", assignee: "demo")', + ' if (!receiptsComplete) {', + ' goto ReworkMissingReceipts', + ' } else if (!approved) {', + ' goto ReworkRejected', + ' }', + ' subprocess Settlement(label: "Settle the claim") {', + ' start SettlementStart', + ' service PayClaim(label: "Pay the claim", class: "com.example.claims.PayClaimDelegate")', + ' if (amount > 1000) {', + ' goto ToAudit', + ' }', + ' end Settled', + ' emit link ToAudit("Audit")', + ' await link AtAudit("Audit")', + ' user AuditPayout(label: "Audit the payout", assignee: "manager")', + ' end Audited', + ' }', ' end Closed(label: "Claim closed")', ' emit link ReworkMissingReceipts("Rework")', ' emit link ReworkRejected("Rework")', @@ -86,15 +112,8 @@ describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { ' user NoteRework(label: "Note the rework", assignee: "demo")', ' }', ' goto AssessClaim', - ].join('\n'), - ); - expect(rt.dslPrime).toContain( - [ - ' end Settled', - ' emit link ToAudit("Audit")', - ' await link AtAudit("Audit")', - ' user AuditPayout(label: "Audit the payout", assignee: "manager")', - ' end Audited', + '}', + '', ].join('\n'), ); }); diff --git a/tests/listeners.round-trip.test.ts b/tests/listeners.round-trip.test.ts index 84dd897..4f39a58 100644 --- a/tests/listeners.round-trip.test.ts +++ b/tests/listeners.round-trip.test.ts @@ -51,8 +51,6 @@ const EXPECTED_FIELDS = [ 'InspectVehicle complete reviewedBy=${task.assignee}', ]; -// Every binding an element or a listener can hold, so the walk below reaches -// each one without asking what kind of element it came off. type AnyBinding = | ListenerBinding | VersionBinding @@ -165,8 +163,9 @@ describe("idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3", () => { }); describe('golden generation: the pipeline output matches the frozen .bpmn', () => { - it('the timeout listener carries the id BpmnParse.parseTimeoutTaskListener requires and its timer as a bpmn:timerEventDefinition child', () => { - expect(rt.frozenXml).toContain( + it.each<[title: string, fragment: string]>([ + [ + 'the timeout listener carries the id BpmnParse.parseTimeoutTaskListener requires and its timer as a bpmn:timerEventDefinition child', [ '', ' ', @@ -174,23 +173,20 @@ describe('golden generation: the pipeline output matches the frozen .bpmn', () = ' ', '', ].join('\n '), - ); - }); - - it('the inline-script listener writes its body verbatim under its language tag', () => { - expect(rt.frozenXml).toContain( + ], + [ + 'the inline-script listener writes its body verbatim under its language tag', `${SCRIPT_BODY}`, - ); - }); - - it('a listener binding is written unprefixed on its already-qualified element', () => { - expect(rt.frozenXml).toContain( + ], + [ + 'a listener binding is written unprefixed on its already-qualified element', '', - ); + ], + ])('%s', (_title, fragment) => { + expect(rt.frozenXml).toContain(fragment); }); }); -// The nested shapes are named so the walk cannot pass on an empty tree. describeDiContainment(rt, [ 'AssessDamage', 'InspectVehicle', diff --git a/tests/mail-and-shell.round-trip.test.ts b/tests/mail-and-shell.round-trip.test.ts index 36c5b87..d61b77a 100644 --- a/tests/mail-and-shell.round-trip.test.ts +++ b/tests/mail-and-shell.round-trip.test.ts @@ -17,8 +17,6 @@ const rt = roundTripFixture('mail-and-shell', { recompile: 'clean', }); -// Revert: any direction dropping a field or the type, or `xmlToIr` reading the -// type through the external branch. const BUILTIN_BINDINGS: Record = { RateSeverity: { kind: 'builtin', @@ -68,8 +66,6 @@ describe('the frozen mail-and-shell binding contract', () => { } }); - // Revert: `codeBindingFields` in `irToXml` not treating `builtin` as a - // carrier -> every field child gone. it('the frozen artifact writes the type on each tag and the fields in both value slots', () => { expect(rt.frozenXml).toContain( '\n' + @@ -103,10 +99,8 @@ describe('the frozen mail-and-shell binding contract', () => { ); }); - // The whole set of heads and field lines, so a field printed on a tag that - // carries none fails too. The harness re-parses and validates DSL' as a - // whole. Revert: `fieldMembers` in `irToDsl` not treating `builtin` as a - // carrier -> every field line gone. + // Every head and field line, so a field printed on a tag that carries none + // fails too; the fixture harness re-parses and validates DSL'. it('the printed script carries the type on the head and the fields as members', () => { const lines = rt.dslPrime .split('\n') diff --git a/tests/nested-subprocess.round-trip.test.ts b/tests/nested-subprocess.round-trip.test.ts index af93125..222e780 100644 --- a/tests/nested-subprocess.round-trip.test.ts +++ b/tests/nested-subprocess.round-trip.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'; import type { FlowContainer } from '@bpmn-script/transform'; import { describeDiContainment } from './helpers/di-bounds.js'; -import { kindOf, idsOf, subProcess } from './helpers/ir-query.js'; +import { idsOf } from './helpers/ir-query.js'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; const rt = roundTripFixture('nested-subprocess', { @@ -15,6 +15,50 @@ const rt = roundTripFixture('nested-subprocess', { ], }); +// ` ` per container, at any depth, in element order. +function containment(container: FlowContainer): Record { + return Object.assign( + { + [container.id]: container.flowElements.map((fe) => `${fe.kind} ${fe.id}`), + }, + ...container.flowElements + .filter((fe) => fe.kind === 'subProcess') + .map(containment), + ); +} + +const CONTAINMENT: Record = { + 'order-fulfillment': [ + 'startEvent OrderReceived', + 'userTask RecordOrder', + 'subProcess Payment', + 'subProcess Fulfillment', + 'userTask CloseOrder', + 'endEvent OrderClosed', + ], + Payment: [ + 'startEvent StartEvent_Payment', + 'exclusiveGateway Gateway_order-fulfillment_2_0_split', + 'exclusiveGateway Gateway_order-fulfillment_2_0_join', + 'userTask ManualReview', + 'serviceTask AutoCharge', + 'endEvent EndEvent_Payment', + ], + Fulfillment: [ + 'startEvent FulfillmentStart', + 'exclusiveGateway Gateway_order-fulfillment_3_1_loop', + 'serviceTask ReserveStock', + 'subProcess Shipping', + 'endEvent FulfillmentDone', + ], + Shipping: [ + 'startEvent StartEvent_Shipping', + 'userTask PackParcel', + 'serviceTask DispatchParcel', + 'endEvent EndEvent_Shipping', + ], +}; + // No sequence flow may reference an element outside its own container. That // invariant is what lets a parent treat a sub-process as one opaque activity. function assertNoBoundaryCrossingFlows(container: FlowContainer): void { @@ -35,90 +79,66 @@ function assertNoBoundaryCrossingFlows(container: FlowContainer): void { } describe("idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3", () => { - it("the restructured DSL' reconstructs both sub-processes as `subprocess` blocks", () => { - expect(rt.dslPrime).toContain( - 'subprocess Payment(label: "Handle payment") {', - ); - expect(rt.dslPrime).toContain( - 'subprocess Fulfillment(label: "Fulfill the order") {', - ); - expect(rt.dslPrime).toContain( - 'subprocess Shipping(label: "Ship the parcel") {', + it("the restructured DSL' reconstructs every sub-process as a `subprocess` block around its own steps", () => { + expect(rt.dslPrime).toBe( + [ + 'process order-fulfillment {', + ' var amount: any', + ' var retries: any', + ' start OrderReceived', + ' user RecordOrder(label: "Record order", assignee: "demo")', + ' subprocess Payment(label: "Handle payment") {', + ' if (amount > 1000) {', + ' user ManualReview(label: "Manual review", assignee: "manager")', + ' } else {', + ' service AutoCharge(label: "Auto-charge card", class: "com.example.demo.LogDelegate")', + ' }', + ' }', + ' subprocess Fulfillment(label: "Fulfill the order") {', + ' start FulfillmentStart', + ' while (retries < 3) {', + ' service ReserveStock(label: "Reserve stock", class: "com.example.demo.LogDelegate")', + ' }', + ' subprocess Shipping(label: "Ship the parcel") {', + ' user PackParcel(label: "Pack parcel", assignee: "demo")', + ' service DispatchParcel(label: "Dispatch parcel", class: "com.example.demo.LogDelegate")', + ' }', + ' end FulfillmentDone', + ' }', + ' user CloseOrder(label: "Close order", assignee: "demo")', + ' end OrderClosed', + '}', + '', + ].join('\n'), ); - expect(rt.dslPrime).toContain('if (amount > 1000)'); - expect(rt.dslPrime).toContain('while (retries < 3)'); - }); - - it('authored ids survive verbatim at their correct container depth', () => { - expect(kindOf(rt.ir3, 'OrderReceived')).toBe('startEvent'); - expect(kindOf(rt.ir3, 'RecordOrder')).toBe('userTask'); - expect(kindOf(rt.ir3, 'Payment')).toBe('subProcess'); - expect(kindOf(rt.ir3, 'Fulfillment')).toBe('subProcess'); - expect(kindOf(rt.ir3, 'CloseOrder')).toBe('userTask'); - expect(kindOf(rt.ir3, 'OrderClosed')).toBe('endEvent'); - - const payment = subProcess(rt.ir3, 'Payment'); - expect(kindOf(payment, 'ManualReview')).toBe('userTask'); - expect(kindOf(payment, 'AutoCharge')).toBe('serviceTask'); - expect(idsOf(rt.ir3).has('ManualReview')).toBe(false); - - const fulfillment = subProcess(rt.ir3, 'Fulfillment'); - expect(kindOf(fulfillment, 'FulfillmentStart')).toBe('startEvent'); - expect(kindOf(fulfillment, 'FulfillmentDone')).toBe('endEvent'); - expect(kindOf(fulfillment, 'ReserveStock')).toBe('serviceTask'); - expect(kindOf(fulfillment, 'Shipping')).toBe('subProcess'); - - const shipping = subProcess(fulfillment, 'Shipping'); - expect(kindOf(shipping, 'PackParcel')).toBe('userTask'); - expect(kindOf(shipping, 'DispatchParcel')).toBe('serviceTask'); - expect(idsOf(fulfillment).has('PackParcel')).toBe(false); - }); -}); - -// The nested shapes are named so the walk cannot pass on an empty tree. -describeDiContainment(rt, ['Payment', 'Fulfillment', 'Shipping', 'PackParcel']); - -describe('structure: IR1 pins the containment shape', () => { - it('the parent chain threads start -> RecordOrder -> Payment -> Fulfillment -> CloseOrder -> end', () => { - const edge = (source: string) => - rt.ir1.sequenceFlows.find((f) => f.sourceRef === source)?.targetRef; - expect(edge('OrderReceived')).toBe('RecordOrder'); - expect(edge('RecordOrder')).toBe('Payment'); - expect(edge('Payment')).toBe('Fulfillment'); - expect(edge('Fulfillment')).toBe('CloseOrder'); - expect(edge('CloseOrder')).toBe('OrderClosed'); }); - it('nested elements do not leak into the parent container', () => { - const top = idsOf(rt.ir1); - for (const nested of [ - 'ManualReview', - 'AutoCharge', - 'ReserveStock', - 'PackParcel', - 'DispatchParcel', - 'FulfillmentStart', - 'FulfillmentDone', - ]) { - expect(top.has(nested)).toBe(false); + it('every container holds exactly its own elements, at every depth and every hop', () => { + for (const [label, ir] of rt.hops) { + expect(containment(ir), `containment differs in ${label}`).toEqual( + CONTAINMENT, + ); } }); - it('each sub-process holds its own body elements', () => { - const payment = subProcess(rt.ir1, 'Payment'); - expect(idsOf(payment)).toContain('ManualReview'); - expect(idsOf(payment)).toContain('AutoCharge'); - - const fulfillment = subProcess(rt.ir1, 'Fulfillment'); - expect(idsOf(fulfillment)).toContain('ReserveStock'); - expect(idsOf(fulfillment)).toContain('Shipping'); - - const shipping = subProcess(fulfillment, 'Shipping'); - expect(idsOf(shipping)).toContain('PackParcel'); - expect(idsOf(shipping)).toContain('DispatchParcel'); + it('the parent chain threads start -> RecordOrder -> Payment -> Fulfillment -> CloseOrder -> end at every hop', () => { + for (const [label, ir] of rt.hops) { + expect( + ir.sequenceFlows.map((f) => `${f.sourceRef}->${f.targetRef}`), + `top-level flows differ in ${label}`, + ).toEqual([ + 'OrderReceived->RecordOrder', + 'RecordOrder->Payment', + 'Payment->Fulfillment', + 'Fulfillment->CloseOrder', + 'CloseOrder->OrderClosed', + ]); + } }); - it('no sequence flow crosses a container boundary, at any depth', () => { - assertNoBoundaryCrossingFlows(rt.ir1); + it('no sequence flow crosses a container boundary, at any depth or hop', () => { + for (const [, ir] of rt.hops) assertNoBoundaryCrossingFlows(ir); }); }); + +describeDiContainment(rt, ['Payment', 'Fulfillment', 'Shipping', 'PackParcel']); diff --git a/tests/new-constructs.round-trip.test.ts b/tests/new-constructs.round-trip.test.ts index 26d807b..d3ecc8a 100644 --- a/tests/new-constructs.round-trip.test.ts +++ b/tests/new-constructs.round-trip.test.ts @@ -1,448 +1,243 @@ // What the single-stage transform tests cannot catch is a field-name or // binding-kind mismatch between stages, so one minimal program per construct -// goes through the whole pipeline here. +// goes through the whole pipeline here. Every row pins the same value at all +// three IR hops, the whole XML tag or line that carries it, and the printed +// statement that writes it back. import { describe, it, expect } from 'vitest'; import type { BpmnProcess } from '@bpmn-script/transform'; import { theOnly } from './helpers/ir-query.js'; -import { parse, roundTripOf, validate } from './helpers/pipeline.js'; -import type { RoundTripRun } from './helpers/pipeline.js'; +import { roundTrip, validate } from './helpers/pipeline.js'; -// Labelled so a value that stops travelling at one hop names the hop it stopped -// at rather than failing as a bare inequality. -function hops(run: RoundTripRun): readonly (readonly [string, BpmnProcess])[] { - return [ - ['IR1', run.ir1], - ['IR2', run.ir2], - ['IR3', run.ir3], - ]; -} - -const SERVICE_EXPRESSION_SRC = - 'process shipping-quote {\n' + - ' start OrderPlaced\n' + - ' service QuoteShipping(expression: "${shippingBean.quote(order)}")\n' + - ' end Done\n' + - '}\n'; - -const SERVICE_DELEGATE_SRC = - 'process payment-charge {\n' + - ' start OrderPlaced\n' + - ' service ChargeCustomer(delegate: "${chargeService}")\n' + - ' end Done\n' + - '}\n'; - -const SERVICE_TOPIC_SRC = - 'process shipment-label {\n' + - ' start OrderPlaced\n' + - ' service PrintLabel(topic: "print-label")\n' + - ' end Done\n' + - '}\n'; - -const SCRIPT_TASK_SRC = - 'process order-discount {\n' + - ' start OrderPlaced\n' + - ' script ComputeDiscount ```js\n' + +const SCRIPT_CODE = 'var discount = amount * 0.1;\n' + - 'execution.setVariable("discount", discount);\n' + - '```\n' + - ' end Done\n' + - '}\n'; - -const TIMER_START_SRC = - 'process nightly-audit {\n' + - ' start AuditWindowOpens timer(at: "2099-01-01T00:00:00", label: "The audit window opens")\n' + - ' user ReviewAudit(assignee: "demo")\n' + - ' end AuditFiled\n' + - '}\n'; - -const SIGNAL_START_SRC = - 'process stock-watch {\n' + - ' start StockRunningLow signal("StockRunningLow")\n' + - ' user ReorderStock(assignee: "demo")\n' + - ' end Restocked\n' + - '}\n'; - -// The condition reads a variable, and the form on the start is what declares it, -// so the printed source validates without a `var` the import hop would have had -// nowhere to put. -const CONDITION_START_SRC = - 'process stock-watch {\n' + - ' start StockRanLow condition(stockLevel < 5) {\n' + - ' form {\n' + - ' stockLevel: number "Stock on hand"\n' + - ' }\n' + - ' }\n' + - ' user ReorderStock(assignee: "demo")\n' + - ' end Restocked\n' + - '}\n'; - -// Both value forms on the element and one on a listener, so the two carriers -// and the two slots are in one program. -const FIELD_INJECTION_SRC = - 'process shipment-dispatch {\n' + - ' start OrderPacked\n' + - ' service PrintLabel(class: "com.acme.PrintLabelDelegate") {\n' + - ' field printer = "warehouse-north"\n' + - ' field copies = "${order.parcelCount}"\n' + - ' on start(delegate: "${dispatchAudit}") {\n' + - ' field stage = "label-printing"\n' + - ' }\n' + - ' }\n' + - ' end LabelPrinted\n' + - '}\n'; - -const FORM_REF_SRC = - 'process refund-approval {\n' + - ' start RefundRequested\n' + - ' user ApproveRefund(assignee: "demo", formRef: "refund-form", binding: latest)\n' + - ' end RefundApproved\n' + - '}\n'; - -const TERMINATE_END_SRC = - 'process order-abandon {\n' + - ' start OrderPlaced\n' + - ' user ReviewOrder(assignee: "demo")\n' + - ' end OrderAbandoned terminate(label: "Abandon every path")\n' + - '}\n'; - -describe('round-trip: service task with an `expression` binding', () => { - const run = roundTripOf(SERVICE_EXPRESSION_SRC); - - it('desugars to an `expression` binding carrying the raw ${...} text', () => { - const binding = theOnly(run.ir1, 'serviceTask').binding; - expect(binding).toEqual({ - kind: 'expression', - expression: '${shippingBean.quote(order)}', - }); - }); - - it('generates `operaton:expression` in the BPMN XML', () => { - expect(run.xml).toContain( - 'operaton:expression="${shippingBean.quote(order)}"', - ); - }); - - it('re-imports to the same `expression` binding', () => { - expect(theOnly(run.ir2, 'serviceTask').binding).toEqual({ - kind: 'expression', - expression: '${shippingBean.quote(order)}', - }); - }); - - it('re-emits `expression = "${...}"` and re-parses with zero errors', async () => { - expect(run.dsl).toContain('expression: "${shippingBean.quote(order)}"'); - const document = await parse(run.dsl); - expect(document.parseResult.parserErrors).toHaveLength(0); - }); -}); - -describe('round-trip: service task with a `delegate` binding (delegateExpression alias)', () => { - const run = roundTripOf(SERVICE_DELEGATE_SRC); - - it('desugars `delegate = "${...}"` to a `delegateExpression` binding', () => { - expect(theOnly(run.ir1, 'serviceTask').binding).toEqual({ - kind: 'delegateExpression', - expression: '${chargeService}', - }); - }); - - it('both directions: DSL `delegate` generates XML `operaton:delegateExpression`, and the re-emitted DSL is `delegate` again', () => { - // DSL -> XML: the generated attribute is the real Operaton name, never the - // DSL-only alias. - expect(run.xml).toContain('operaton:delegateExpression="${chargeService}"'); - expect(run.xml).not.toContain('operaton:delegate='); - - // XML -> DSL: the importer reads `delegateExpression` back to the same - // binding kind and the printer emits the `delegate` alias. - expect(theOnly(run.ir2, 'serviceTask').binding).toEqual({ - kind: 'delegateExpression', - expression: '${chargeService}', - }); - expect(run.dsl).toContain('delegate: "${chargeService}"'); - expect(run.dsl).not.toContain('delegateExpression'); - }); - - it('the re-emitted DSL re-parses with zero errors and re-desugars to the same binding', () => { - expect(theOnly(run.ir3, 'serviceTask').binding).toEqual({ - kind: 'delegateExpression', - expression: '${chargeService}', - }); - }); -}); - -describe('round-trip: service task with a `topic` binding', () => { - const run = roundTripOf(SERVICE_TOPIC_SRC); - - it('desugars to an `external` binding carrying the topic', () => { - expect(theOnly(run.ir1, 'serviceTask').binding).toEqual({ - kind: 'external', - topic: 'print-label', - }); - }); - - it('generates `operaton:type="external"` and `operaton:topic` in the BPMN XML', () => { - expect(run.xml).toContain('operaton:type="external"'); - expect(run.xml).toContain('operaton:topic="print-label"'); - }); - - it('re-imports to the same `external` binding', () => { - expect(theOnly(run.ir2, 'serviceTask').binding).toEqual({ - kind: 'external', - topic: 'print-label', - }); - }); - - it('re-emits `service ...(topic: "...")` and re-parses with zero errors', async () => { - expect(run.dsl).toContain('service PrintLabel'); - expect(run.dsl).toContain('topic: "print-label"'); - const document = await parse(run.dsl); - expect(document.parseResult.parserErrors).toHaveLength(0); - }); -}); - -describe('round-trip: `script` task with a fenced body', () => { - const EXPECTED_CODE = - 'var discount = amount * 0.1;\n' + - 'execution.setVariable("discount", discount);\n'; - - const run = roundTripOf(SCRIPT_TASK_SRC); - - it('desugars the `js` fence tag to canonical scriptFormat "javascript" and keeps the body verbatim', () => { - const scriptTask = theOnly(run.ir1, 'scriptTask'); - expect(scriptTask.format).toBe('javascript'); - expect(scriptTask.code).toBe(EXPECTED_CODE); - }); - - it('generates `scriptFormat="javascript"` and the script body in the BPMN XML', () => { - expect(run.xml).toContain('scriptFormat="javascript"'); - expect(run.xml).toContain('var discount = amount * 0.1;'); - }); - - it('re-imports to the same scriptFormat and body', () => { - const scriptTask = theOnly(run.ir2, 'scriptTask'); - expect(scriptTask.format).toBe('javascript'); - expect(scriptTask.code).toBe(EXPECTED_CODE); - }); - - it('re-emits a fenced `script ... ```javascript ... ``` ` block and re-parses with zero errors', async () => { - expect(run.dsl).toContain( - `script ComputeDiscount \`\`\`javascript\n${EXPECTED_CODE}\`\`\``, - ); - const document = await parse(run.dsl); - expect(document.parseResult.parserErrors).toHaveLength(0); - }); - - it('the decompiled DSL recompiles without validation errors', async () => { - const { diagnostics } = await validate(run.dsl); - expect(diagnostics.filter((d) => d.severity === 1)).toEqual([]); - }); -}); - -describe('round-trip: process start event with a timer trigger', () => { - const run = roundTripOf(TIMER_START_SRC); - - it('desugars to a timer trigger on the start event', () => { - const start = theOnly(run.ir1, 'startEvent'); - expect(start.eventDefinition).toEqual({ - kind: 'timer', - timerKind: 'date', - expression: '2099-01-01T00:00:00', - }); - }); - - it('generates a `bpmn:timeDate` element carrying the date', () => { - expect(run.xml).toContain(' { - const start = theOnly(run.ir2, 'startEvent'); - expect(start.eventDefinition).toEqual({ - kind: 'timer', - timerKind: 'date', - expression: '2099-01-01T00:00:00', - }); - }); - - it('re-emits the trigger head on `start` and re-parses with zero errors', async () => { - expect(run.dsl).toContain( - 'start AuditWindowOpens timer(at: "2099-01-01T00:00:00", ' + - 'label: "The audit window opens")', - ); - const document = await parse(run.dsl); - expect(document.parseResult.parserErrors).toHaveLength(0); - }); -}); - -describe('round-trip: process start event with a signal trigger', () => { - const run = roundTripOf(SIGNAL_START_SRC); - - it('desugars to a signal trigger on the start event', () => { - const start = theOnly(run.ir1, 'startEvent'); - expect(start.eventDefinition).toEqual({ - kind: 'signal', - signalName: 'StockRunningLow', - }); - }); - - it('generates one `bpmn:signal` root and a `signalRef` on the start event', () => { - expect(run.xml.match(/ { - const start = theOnly(run.ir2, 'startEvent'); - expect(start.eventDefinition).toEqual({ - kind: 'signal', - signalName: 'StockRunningLow', - }); - }); - - it('re-emits the trigger head on `start` and re-parses with zero errors', async () => { - expect(run.dsl).toContain( - 'start StockRunningLow signal("StockRunningLow")', - ); - const document = await parse(run.dsl); - expect(document.parseResult.parserErrors).toHaveLength(0); - }); -}); - -describe('round-trip: process start event with a condition trigger', () => { - const run = roundTripOf(CONDITION_START_SRC); - - it('keeps the condition through every hop and re-emits a start that validates clean', async () => { - const conditional = { - kind: 'conditional', - condition: '${stockLevel < 5}', - }; - - expect(theOnly(run.ir1, 'startEvent').eventDefinition, 'IR1').toEqual( - conditional, - ); - - expect(run.xml, 'XML').toContain( - '' + - '${stockLevel < 5}', - ); - - expect(theOnly(run.ir2, 'startEvent').eventDefinition, 'IR2').toEqual( - conditional, - ); - - expect(run.dsl, 'DSL').toContain( - 'start StockRanLow condition(stockLevel < 5)', - ); - const { diagnostics } = await validate(run.dsl); - expect(diagnostics).toEqual([]); - }); -}); - -describe('round-trip: `end ... terminate`', () => { - const run = roundTripOf(TERMINATE_END_SRC); - - it('desugars to a terminate end event that keeps its label', () => { - const end = theOnly(run.ir1, 'endEvent'); - expect(end.eventDefinition).toEqual({ kind: 'terminate' }); - expect(end.name).toBe('Abandon every path'); - }); - - it('generates `bpmn:terminateEventDefinition` and keeps the label as the `name` attribute', () => { - expect(run.xml).toContain(' { - const end = theOnly(run.ir2, 'endEvent'); - expect(end.eventDefinition).toEqual({ kind: 'terminate' }); - expect(end.name).toBe('Abandon every path'); - expect(run.warnings.filter((w) => w.category === 'label')).toEqual([]); - }); - - it('re-emits `end ... terminate`, never eliding it, and re-parses with zero errors', async () => { - expect(run.dsl).toContain( - 'end OrderAbandoned terminate(label: "Abandon every path")', - ); - const document = await parse(run.dsl); - expect(document.parseResult.parserErrors).toHaveLength(0); - }); -}); - -describe('round-trip: `field` on a class binding and on a listener binding', () => { - const run = roundTripOf(FIELD_INJECTION_SRC); - - it('every field keeps its carrier, name, and value through every hop, and the re-emitted DSL validates clean', async () => { - const taskBinding = { - kind: 'class', - className: 'com.acme.PrintLabelDelegate', - fields: [ - { name: 'printer', value: 'warehouse-north' }, - { name: 'copies', value: '${order.parcelCount}' }, + 'execution.setVariable("discount", discount);\n'; + +describe('round-trip: one minimal program per construct', () => { + it.each([ + [ + 'a service task with an `expression` binding', + 'process shipping-quote {\n' + + ' start OrderPlaced\n' + + ' service QuoteShipping(expression: "${shippingBean.quote(order)}")\n' + + ' end Done\n' + + '}\n', + (ir: BpmnProcess) => theOnly(ir, 'serviceTask').binding, + { kind: 'expression', expression: '${shippingBean.quote(order)}' }, + [ + '', + ], + [' service QuoteShipping(expression: "${shippingBean.quote(order)}")\n'], + ], + [ + 'a service task with a `delegate` binding writes the real Operaton attribute and prints the alias again', + 'process payment-charge {\n' + + ' start OrderPlaced\n' + + ' service ChargeCustomer(delegate: "${chargeService}")\n' + + ' end Done\n' + + '}\n', + (ir: BpmnProcess) => theOnly(ir, 'serviceTask').binding, + { kind: 'delegateExpression', expression: '${chargeService}' }, + [ + '', + ], + [' service ChargeCustomer(delegate: "${chargeService}")\n'], + ], + [ + 'a service task with a `topic` binding', + 'process shipment-label {\n' + + ' start OrderPlaced\n' + + ' service PrintLabel(topic: "print-label")\n' + + ' end Done\n' + + '}\n', + (ir: BpmnProcess) => theOnly(ir, 'serviceTask').binding, + { kind: 'external', topic: 'print-label' }, + [ + '', ], - }; - const listener = { - event: 'start', - binding: { - kind: 'delegateExpression', - expression: '${dispatchAudit}', - fields: [{ name: 'stage', value: 'label-printing' }], + [' service PrintLabel(topic: "print-label")\n'], + ], + [ + 'a `script` task with a fenced body keeps the body verbatim and canonicalizes the `js` tag', + 'process order-discount {\n' + + ' start OrderPlaced\n' + + ' script ComputeDiscount ```js\n' + + SCRIPT_CODE + + '```\n' + + ' end Done\n' + + '}\n', + (ir: BpmnProcess) => { + const { format, code } = theOnly(ir, 'scriptTask'); + return { format, code }; }, - }; - - for (const [label, ir] of hops(run)) { - const task = theOnly(ir, 'serviceTask'); - expect(task.binding, `the task binding differs in ${label}`).toEqual( - taskBinding, - ); - expect( - task.executionListeners, - `the listener differs in ${label}`, - ).toEqual([listener]); - } - - // The literal takes the attribute slot and the `${...}` an expression - // child. Written the other way round the engine injects the text of the - // expression instead of what it evaluates to. - expect(run.xml, 'XML').toContain( - '', - ); - expect(run.xml, 'XML').toContain( - '${order.parcelCount}', - ); - expect(run.warnings, 'import warnings').toEqual([]); - - expect(run.dsl, 'DSL').toContain('field printer = "warehouse-north"'); - expect(run.dsl, 'DSL').toContain('field copies = "${order.parcelCount}"'); - expect(run.dsl, 'DSL').toContain('field stage = "label-printing"'); - const { diagnostics } = await validate(run.dsl); - expect(diagnostics).toEqual([]); - }); -}); - -describe('round-trip: `formRef` on a user task', () => { - const run = roundTripOf(FORM_REF_SRC); - - it('the form reference keeps its key and binding through every hop, and the re-emitted DSL validates clean', async () => { - for (const [label, ir] of hops(run)) { - expect( - theOnly(ir, 'userTask').formRef, - `the form reference differs in ${label}`, - ).toEqual({ key: 'refund-form', binding: { kind: 'latest' } }); - } - - // Operaton refuses to deploy a form reference carrying no binding, so the - // binding attribute is written even where it names the engine's own - // default, and the version attribute only where a version was pinned. - expect(run.xml, 'XML').toContain( - 'operaton:formRef="refund-form" operaton:formRefBinding="latest"', - ); - expect(run.xml, 'XML').not.toContain('formRefVersion'); - expect(run.warnings, 'import warnings').toEqual([]); - - expect(run.dsl, 'DSL').toContain('formRef: "refund-form", binding: latest'); - const { diagnostics } = await validate(run.dsl); - expect(diagnostics).toEqual([]); - }); + { format: 'javascript', code: SCRIPT_CODE }, + [ + '', + `${SCRIPT_CODE}`, + ], + [` script ComputeDiscount \`\`\`javascript\n${SCRIPT_CODE}\`\`\`\n`], + ], + [ + 'a process start with a timer trigger', + 'process nightly-audit {\n' + + ' start AuditWindowOpens timer(at: "2099-01-01T00:00:00", label: "The audit window opens")\n' + + ' user ReviewAudit(assignee: "demo")\n' + + ' end AuditFiled\n' + + '}\n', + (ir: BpmnProcess) => theOnly(ir, 'startEvent').eventDefinition, + { kind: 'timer', timerKind: 'date', expression: '2099-01-01T00:00:00' }, + [ + '2099-01-01T00:00:00', + ], + [ + ' start AuditWindowOpens timer(at: "2099-01-01T00:00:00", label: "The audit window opens")\n', + ], + ], + [ + 'a process start with a signal trigger derives exactly one signal root', + 'process stock-watch {\n' + + ' start StockRunningLow signal("StockRunningLow")\n' + + ' user ReorderStock(assignee: "demo")\n' + + ' end Restocked\n' + + '}\n', + (ir: BpmnProcess) => theOnly(ir, 'startEvent').eventDefinition, + { kind: 'signal', signalName: 'StockRunningLow' }, + [ + '', + '\n' + + ' \n' + + ' theOnly(ir, 'startEvent').eventDefinition, + { kind: 'conditional', condition: '${stockLevel < 5}' }, + [ + '${stockLevel < 5}', + ], + [' start StockRanLow condition(stockLevel < 5) {\n'], + ], + [ + 'an `end ... terminate` keeps its trigger and its label, and is never elided', + 'process order-abandon {\n' + + ' start OrderPlaced\n' + + ' user ReviewOrder(assignee: "demo")\n' + + ' end OrderAbandoned terminate(label: "Abandon every path")\n' + + '}\n', + (ir: BpmnProcess) => { + const { eventDefinition, name } = theOnly(ir, 'endEvent'); + return { eventDefinition, name }; + }, + { eventDefinition: { kind: 'terminate' }, name: 'Abandon every path' }, + [ + '', + '', + ], + [' end OrderAbandoned terminate(label: "Abandon every path")\n'], + ], + [ + // The literal takes the attribute slot and the `${...}` an expression + // child. Written the other way round the engine injects the text of the + // expression instead of what it evaluates to. + 'a `field` on a class binding and on a listener binding keeps its carrier, name and value', + 'process shipment-dispatch {\n' + + ' start OrderPacked\n' + + ' service PrintLabel(class: "com.acme.PrintLabelDelegate") {\n' + + ' field printer = "warehouse-north"\n' + + ' field copies = "${order.parcelCount}"\n' + + ' on start(delegate: "${dispatchAudit}") {\n' + + ' field stage = "label-printing"\n' + + ' }\n' + + ' }\n' + + ' end LabelPrinted\n' + + '}\n', + (ir: BpmnProcess) => { + const { binding, executionListeners } = theOnly(ir, 'serviceTask'); + return { binding, executionListeners }; + }, + { + binding: { + kind: 'class', + className: 'com.acme.PrintLabelDelegate', + fields: [ + { name: 'printer', value: 'warehouse-north' }, + { name: 'copies', value: '${order.parcelCount}' }, + ], + }, + executionListeners: [ + { + event: 'start', + binding: { + kind: 'delegateExpression', + expression: '${dispatchAudit}', + fields: [{ name: 'stage', value: 'label-printing' }], + }, + }, + ], + }, + [ + '', + '${order.parcelCount}', + ], + [ + ' field printer = "warehouse-north"\n' + + ' field copies = "${order.parcelCount}"\n' + + ' on start(delegate: "${dispatchAudit}") {\n' + + ' field stage = "label-printing"\n', + ], + ], + [ + // Operaton refuses to deploy a form reference carrying no binding, so + // the binding attribute is written even where it names the engine's own + // default, and the version attribute only where a version was pinned. + 'a `formRef` on a user task keeps its key and binding', + 'process refund-approval {\n' + + ' start RefundRequested\n' + + ' user ApproveRefund(assignee: "demo", formRef: "refund-form", binding: latest)\n' + + ' end RefundApproved\n' + + '}\n', + (ir: BpmnProcess) => theOnly(ir, 'userTask').formRef, + { key: 'refund-form', binding: { kind: 'latest' } }, + [ + '', + ], + [ + ' user ApproveRefund(assignee: "demo", formRef: "refund-form", binding: latest)\n', + ], + ], + ])( + '%s', + async (_title, source, pick, expected, xmlFragments, dslFragments) => { + const run = await roundTrip(source); + + for (const [hop, ir] of run.hops) { + expect(pick(ir), `the value differs in ${hop}`).toEqual(expected); + } + for (const fragment of xmlFragments) { + expect(run.xml).toContain(fragment); + } + expect(run.warnings, 'import warnings').toEqual([]); + for (const fragment of dslFragments) { + expect(run.dsl).toContain(fragment); + } + const { diagnostics } = await validate(run.dsl); + expect(diagnostics).toEqual([]); + }, + ); }); diff --git a/tests/package.json b/tests/package.json index 9ce5323..2b297cc 100644 --- a/tests/package.json +++ b/tests/package.json @@ -2,10 +2,17 @@ "name": "tests", "description": "Integration and E2E tests for BPMNscript", "version": "0.0.1", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/BPMNscript/bpmnscript.git", + "directory": "tests" + }, "type": "module", "private": true, "engines": { - "node": ">=22.0.0" + "node": ">=22.0.0", + "npm": ">=10.2.3" }, "scripts": { "typecheck": "tsc -b tsconfig.json", diff --git a/tests/parse-performance.test.ts b/tests/parse-performance.test.ts index 1d62c4c..79b2223 100644 --- a/tests/parse-performance.test.ts +++ b/tests/parse-performance.test.ts @@ -1,23 +1,14 @@ -/** - * `xmlToIr` followed by `irToDsl` must stay linear in the number of flow - * nodes. Each of `tryWhile` and `tryDoWhileEntry` in `ir-to-dsl.ts` asks - * `CfgAnalysis.backEdges()` once per emitted node; if that recomputes the - * whole flow list by walking the dominator chain per edge, a straight chain - * of n tasks costs O(n^3) and a Modeler-style export of a few thousand tasks - * hangs with no output. - * - * Two guards in `cfg-analysis.ts` hold this together: `backEdges()` returns - * a list computed once, and `dominates` answers in two map lookups. Reverting - * either one alone leaves the chain at O(n^2), which still finishes inside - * the budget; only reverting both turns this test red. - */ +// `tryWhile` and `tryDoWhileEntry` in `ir-to-dsl.ts` ask +// `CfgAnalysis.backEdges()` once per emitted node, so a straight chain of n +// tasks costs O(n^3) unless `backEdges()` is computed once and `dominates` +// answers in two map lookups. Reverting either guard alone leaves O(n^2), +// which still finishes inside the budget; only reverting both turns this red. import { describe, expect, it } from 'vitest'; import { xmlToIr } from '@bpmn-script/transform'; import { parseToAst, printDsl } from './helpers/pipeline.js'; const TASK_COUNT = 1000; -/** A Modeler-style straight-line chain: start -> N plain tasks -> end. */ function chainBpmn(taskCount: number): string { const flowElements: string[] = [ 'Flow_0', @@ -28,8 +19,7 @@ function chainBpmn(taskCount: number): string { for (let i = 1; i <= taskCount; i++) { const taskId = `Task_${i}`; const flowId = `Flow_${i - 1}`; - // `name` matches `humanize(taskId)` exactly, so the printer drops it as - // a redundant label and each line below reads as plain `step Task_`. + // `name` matches `humanize(taskId)`, so the printer drops the label. flowElements.push( `${flowId}`, ); diff --git a/tests/readme-fences.test.ts b/tests/readme-fences.test.ts index 5d2df90..3070298 100644 --- a/tests/readme-fences.test.ts +++ b/tests/readme-fences.test.ts @@ -1,8 +1,6 @@ -// Every `bpmnscript` sample published in a Markdown file is a program a reader -// copies out and runs, so each one is parsed, validated and compiled here. -// Directory-driven rather than a hand-kept list, so a new document is covered -// the moment it lands, and a sample that stops compiling fails under the file -// and line it was written at. +// Every `bpmnscript` fence in a Markdown file is a program a reader copies out +// and runs, so each one is parsed, validated and compiled here, directory-driven +// so a new document is covered the moment it lands. import { describe, it, expect } from 'vitest'; import { readdirSync, readFileSync } from 'node:fs'; @@ -17,20 +15,15 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); -/** Directories holding no authored prose, so nothing under them is a sample. */ const UNVISITED = new Set(['node_modules', 'out', '.git', '.claude', 'target']); -/** - * A fence opened by at least three backticks and tagged `bpmnscript`, closed by - * a run at least as long at the start of a line. The length is captured so a - * sample carrying a fenced script body of its own, which opens on three - * backticks indented inside the block, does not close the block it sits in. - */ +// The opening run's length is captured so a sample carrying a fenced script +// body of its own, three backticks indented inside a four-backtick block, does +// not close the block it sits in. const FENCE = /^(`{3,})bpmnscript[ \t]*\r?\n([\s\S]*?)^\1`*[ \t]*$/gm; interface Sample { - /** `packages/language/README.md:144`, the form a failure report is greppable by. */ - where: string; + where: string; // `packages/language/README.md:144` source: string; } @@ -65,8 +58,8 @@ describe('every bpmnscript sample published in the documentation', () => { '%s parses, validates and compiles', async (_where, source) => { const document = await parse(source); - // Messages rather than the errors themselves: a Chevrotain error carries - // its whole token table, which buries the sentence naming the slot. + // A Chevrotain error carries its whole token table, which would bury + // the sentence naming the slot. expect(document.parseResult.parserErrors.map((e) => e.message)).toEqual( [], ); diff --git a/tests/repetition.round-trip.test.ts b/tests/repetition.round-trip.test.ts index 6462b5c..ec6131b 100644 --- a/tests/repetition.round-trip.test.ts +++ b/tests/repetition.round-trip.test.ts @@ -12,95 +12,82 @@ const rt = roundTripFixture('repetition', { recompile: 'clean', }); -interface Loop { - tag: string; - id: string; - /** The attributes of the opening `multiInstanceLoopCharacteristics` tag. */ - open: string; -} - -/** - * Every multi-instance element of the frozen artifact in document order, each - * paired with the tag and id it sits under: a loop is always written ahead of - * any nested child, so the last opening tag seen is its host. - */ -function loops(): Loop[] { - const found: Loop[] = []; +// Every multi-instance element of the frozen artifact in document order, each +// under the tag and id it sits in: a loop is always written ahead of any nested +// child, so the last opening tag seen is its host. Whitespace inside the loop +// element is collapsed so a row reads as one line. +function loops(): [tag: string, id: string, loop: string][] { + const found: [string, string, string][] = []; let host: [tag: string, id: string] | undefined; const token = - /]*)>/g; + /]*?(?:\/>|>[\s\S]*?<\/bpmn:multiInstanceLoopCharacteristics>)/g; for (const match of rt.frozenXml.matchAll(token)) { if (match[1] !== undefined) { host = [match[1], match[2]!]; } else { - found.push({ tag: host![0], id: host![1], open: match[3]! }); + found.push([host![0], host![1], match[0].replace(/\s+/g, ' ')]); } } return found; } -function loopOf(id: string): Loop { - const found = loops().find((loop) => loop.id === id); - expect(found, `no multi-instance element on '${id}'`).toBeDefined(); - return found!; -} - describe('multi-instance placement on the frozen .bpmn', () => { - it('every repeated statement carries a loop, under its own tag', () => { - expect(loops().map((loop) => [loop.tag, loop.id])).toEqual([ - ['userTask', 'ApproveLines'], - ['serviceTask', 'ReserveStock'], - ['serviceTask', 'WarmPricing'], - ['task', 'RecordLine'], - ['receiveTask', 'AwaitBatch'], - ['callActivity', 'RegionalReport'], - ['scriptTask', 'LabelParcel'], - ['subProcess', 'DispatchParcels'], - ]); - }); - - it('isSequential marks the two statements that wrote it and nothing else', () => { - const sequential = loops().filter((loop) => - loop.open.includes('isSequential="true"'), - ); - expect(sequential.map((loop) => loop.id)).toEqual([ - 'ReserveStock', - 'DispatchParcels', + it('every repeated statement carries its whole loop element under its own tag, and no other tag carries one', () => { + expect(loops()).toEqual([ + [ + 'userTask', + 'ApproveLines', + '', + ], + [ + 'serviceTask', + 'ReserveStock', + ' ' + + '${nrOfCompletedInstances >= 2} ' + + '', + ], + [ + 'serviceTask', + 'WarmPricing', + ' ' + + ' ' + + 'R2/PT1M ' + + ' ' + + '3 ' + + '', + ], + [ + 'task', + 'RecordLine', + ' ' + + '2 ' + + '', + ], + [ + 'receiveTask', + 'AwaitBatch', + '', + ], + [ + 'callActivity', + 'RegionalReport', + '', + ], + [ + 'scriptTask', + 'LabelParcel', + '', + ], + [ + 'subProcess', + 'DispatchParcels', + '', + ], ]); - expect(rt.frozenXml).not.toContain('isSequential="false"'); - }); - - it('a collection expression keeps its ${} and a variable name stays bare', () => { - expect(loopOf('ReserveStock').open).toContain( - 'operaton:collection="${order.lines}"', - ); - expect(loopOf('ApproveLines').open).toContain( - 'operaton:collection="approvers"', - ); - expect(loopOf('AwaitBatch').open).not.toContain('operaton:elementVariable'); - }); - - it('the two counts are a bare one and one beside a collection', () => { - const bodies = [ - ...rt.frozenXml.matchAll(/]*>([^<]*) match[1]); - expect(bodies).toEqual(['3', '2']); - expect(loopOf('WarmPricing').open).not.toContain('operaton:collection'); - expect(loopOf('RecordLine').open).toContain('operaton:collection="lines"'); - }); - - it('one completion condition, rendered as a formal expression', () => { - expect(rt.frozenXml.match(/' + - '${nrOfCompletedInstances >= 2}', - ); }); // The step's own settings sit on the task tag and make one job around the // repetition; the `run*` keys sit on the loop element and make one per run. - // Revert: any direction dropping a loop setting, or `irToXml` writing a run - // key onto the task tag. it("WarmPricing carries the step's job around the repetition and one per run", () => { expect( rt.frozenXml.match( @@ -122,56 +109,24 @@ describe('multi-instance placement on the frozen .bpmn', () => { ' ', ); }); - - it('the run settings sit on the one loop element that wrote them and on no other', () => { - const runSettings = (open: string): string[] => - [...open.matchAll(/operaton:(?:async\w+|exclusive)="[^"]*"/g)].map( - (match) => match[0], - ); - expect( - Object.fromEntries( - loops().map((loop) => [loop.id, runSettings(loop.open)]), - ), - ).toEqual({ - ApproveLines: [], - ReserveStock: [], - WarmPricing: [ - 'operaton:asyncBefore="true"', - 'operaton:asyncAfter="true"', - 'operaton:exclusive="false"', - ], - RecordLine: [], - AwaitBatch: [], - RegionalReport: [], - LabelParcel: [], - DispatchParcels: [], - }); - }); }); describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { - it.each([ - 'user ApproveLines for each approver in approvers(' + - 'label: "Approve the order lines", assignee: "demo")', - 'service ReserveStock for each line in "${order.lines}" ' + - 'sequentially until (nrOfCompletedInstances >= 2)(' + - 'label: "Reserve the stock", ' + - 'class: "com.example.orders.ReserveStockDelegate")', - 'service WarmPricing for 3(label: "Warm the pricing cache", ' + - 'class: "com.example.orders.WarmPricingDelegate", ' + - 'asyncBefore: true, retryCycle: "R3/PT10M", ' + - 'runAsyncBefore: true, runAsyncAfter: true, runExclusive: false, ' + - 'runRetryCycle: "R2/PT1M")', - 'step RecordLine for 2 each line in lines(label: "Record each line")', - 'receive AwaitBatch for each in batches(label: "Wait for each batch")', - 'call RegionalReport for each region in regions(' + - 'label: "Run the regional report", process: "regional-report")', - 'script LabelParcel for each parcel in parcels(' + - 'label: "Label each parcel", resultVariable: "parcelLabel")', - 'subprocess DispatchParcels for each parcel in parcels sequentially(' + - 'label: "Dispatch each parcel", asyncBefore: true)', - ])("the decompiled DSL' writes back `%s`", (clause) => { - expect(rt.dslPrime).toContain(clause); + it("the decompiled DSL' writes every repeat clause back, and no other statement gains one", () => { + const repeated = rt.dslPrime + .split('\n') + .map((line) => line.trim()) + .filter((line) => /^\w+ \w+ for /.test(line)); + expect(repeated).toEqual([ + 'user ApproveLines for each approver in approvers(label: "Approve the order lines", assignee: "demo")', + 'service ReserveStock for each line in "${order.lines}" sequentially until (nrOfCompletedInstances >= 2)(label: "Reserve the stock", class: "com.example.orders.ReserveStockDelegate")', + 'service WarmPricing for 3(label: "Warm the pricing cache", class: "com.example.orders.WarmPricingDelegate", asyncBefore: true, retryCycle: "R3/PT10M", runAsyncBefore: true, runAsyncAfter: true, runExclusive: false, runRetryCycle: "R2/PT1M")', + 'step RecordLine for 2 each line in lines(label: "Record each line")', + 'receive AwaitBatch for each in batches(label: "Wait for each batch")', + 'call RegionalReport for each region in regions(label: "Run the regional report", process: "regional-report")', + 'script LabelParcel for each parcel in parcels(label: "Label each parcel", resultVariable: "parcelLabel") ```javascript', + 'subprocess DispatchParcels for each parcel in parcels sequentially(label: "Dispatch each parcel", asyncBefore: true) {', + ]); }); }); diff --git a/tests/review-loop.round-trip.test.ts b/tests/review-loop.round-trip.test.ts index 97808fe..61902ad 100644 --- a/tests/review-loop.round-trip.test.ts +++ b/tests/review-loop.round-trip.test.ts @@ -1,8 +1,6 @@ -// The approve-review loop, frozen: a `do ... while` whose body splits under a -// condition on every route, one route leaving the loop. What this suite catches -// is the printer hoisting the staying route out of the loop, which the IR -// comparison sees as a lost edge into the loop gateway, and the compiled split -// losing the fallback the else-less chain gives it. +// A `do ... while` whose body splits on every route, one route leaving the +// loop. The pin below catches the printer hoisting the staying route out of +// the loop, which the IR comparison would only report as a lost edge. import { describe, it, expect } from 'vitest'; diff --git a/tests/round-trip-constructs.test.ts b/tests/round-trip-constructs.test.ts index 1c23a35..239d6c8 100644 --- a/tests/round-trip-constructs.test.ts +++ b/tests/round-trip-constructs.test.ts @@ -9,7 +9,6 @@ import type { BpmnProcess } from '@bpmn-script/transform'; import { normalizeIr } from './helpers/normalize-ir.js'; import { realNodeReachability } from './helpers/real-node-reachability.js'; import { - parse, parseToAst, printDsl, roundTripOf, @@ -36,83 +35,71 @@ const UNSTRUCTURED_BPMN_PATH = resolve( describe('structured idempotence (invoice-approval, if/else)', () => { const run = roundTripOf(readFileSync(INVOICE_DSL_PATH, 'utf-8')); - let dsl2: string; - - beforeAll(() => { - dsl2 = printDsl(run.ir3); - }); it('final IR equals initial IR up to documented id normalization', () => { expect(normalizeIr(run.ir3)).toEqual(normalizeIr(run.ir1)); }); - it('re-emitted DSL is byte-identical to the first emitted DSL', () => { - // Deterministic structural ids make the emission byte-stable, so a second - // irToDsl over the re-desugared IR reproduces the first exactly. - expect(dsl2).toBe(run.dsl); - }); - - it('the emitted DSL is structured syntax (if/else, no gateway/edge form)', () => { - expect(run.dsl).toContain('process invoice-approval'); - expect(run.dsl).toContain('if (amount > 1000)'); - expect(run.dsl).toContain('else'); - expect(run.dsl).not.toContain('gateway'); - expect(run.dsl).not.toContain('->'); - }); - - it('the if-condition survives as a conditional flow in the final IR', () => { - const conditional = run.ir3.sequenceFlows.find( - (sf) => sf.conditionExpression !== undefined, + it('the emitted DSL is structured syntax, and a second emission reproduces it byte for byte', () => { + expect(run.dsl).toBe( + [ + 'process invoice-approval {', + ' start ReviewStart {', + ' form {', + ' amount: number "Invoice amount"', + ' }', + ' }', + ' user ReviewInvoice(assignee: "demo")', + ' if (amount > 1000) {', + ' user SeniorApproval(assignee: "manager")', + ' } else {', + ' service AutoApprove(class: "com.example.invoice.AutoApproveDelegate")', + ' }', + ' end Done', + '}', + '', + ].join('\n'), ); - expect(conditional).toBeDefined(); - expect(conditional!.conditionExpression).toBe('${amount > 1000}'); + expect(printDsl(run.ir3)).toBe(run.dsl); }); }); -describe('loop round-trip (while => conditioned back-edge, never standardLoopCharacteristics)', () => { +describe('while and parallel round-trip (structured-control-flow)', () => { const run = roundTripOf(readFileSync(STRUCTURED_DSL_PATH, 'utf-8')); - it('the BPMN XML contains no standardLoopCharacteristics', () => { - // A `while` desugars to a conditioned back-edge, not a loop-marker task. + it('lowers `while` to a conditioned back-edge and `parallel` to one fork and one join', () => { expect(run.xml).not.toContain('standardLoopCharacteristics'); + expect(run.xml.match(/ { - expect(run.dsl).toMatch(/\bwhile\s*\(/); - expect(run.dsl).toContain('while (retries < 3)'); - expect(run.dsl).not.toContain('goto'); - }); - - it('the loop body task survives the round-trip verbatim', () => { - expect(run.dsl).toContain( - 'service RetryFetch(label: "Retry fetch", ' + - 'class: "com.example.flow.RetryFetchDelegate")', - ); - }); -}); - -describe('parallel round-trip (parallelGateway fork/join => parallel { { } { } })', () => { - const run = roundTripOf(readFileSync(STRUCTURED_DSL_PATH, 'utf-8')); - - it('the BPMN XML contains a parallelGateway fork and join (two parallelGateways)', () => { - expect(run.xml).toContain('bpmn:parallelGateway'); - const forkJoin = run.xml.match(/ { - expect(run.dsl).toMatch(/\bparallel\s*\{/); - expect(run.dsl).not.toContain('} and {'); - expect(run.dsl).not.toMatch(/\band\b/); - }); - - it('both parallel branch tasks survive the round-trip verbatim', () => { - expect(run.dsl).toContain( - 'user NotifyOwner(label: "Notify owner", assignee: "demo")', - ); - expect(run.dsl).toContain( - 'service AuditLog(label: "Write audit log", ' + - 'class: "com.example.flow.AuditLogDelegate")', + it('the re-emitted DSL reconstructs the loop as `while` and the fork as `parallel`, with no goto', () => { + expect(run.dsl).toBe( + [ + 'process structured-control-flow {', + ' var priority: any', + ' var retries: any', + ' start Begin', + ' user Triage(label: "Triage request", assignee: "demo")', + ' if (priority > 5) {', + ' user EscalateReview(label: "Escalate review", assignee: "manager")', + ' } else {', + ' service AutoTriage(label: "Auto-triage", class: "com.example.flow.AutoTriageDelegate")', + ' }', + ' while (retries < 3) {', + ' service RetryFetch(label: "Retry fetch", class: "com.example.flow.RetryFetchDelegate")', + ' }', + ' parallel {', + ' {', + ' user NotifyOwner(label: "Notify owner", assignee: "demo")', + ' }', + ' {', + ' service AuditLog(label: "Write audit log", class: "com.example.flow.AuditLogDelegate")', + ' }', + ' }', + ' end Finish', + '}', + '', + ].join('\n'), ); }); }); @@ -121,10 +108,10 @@ describe('parallel round-trip (parallelGateway fork/join => parallel { { } { } } // between authored nodes survives, over a second round trip too. Edges with no // form at all belong to the goto-fallback suite. describe('goto-degradation preserves the edges that have a goto form', () => { - let irImport: BpmnProcess; // from xmlToIr(unstructured.bpmn) - let degradedDsl: string; // printDsl(irImport), contains goto(s) - let irReDesugared: BpmnProcess; // astToIr(parse(degradedDsl)) - let irSecondRound: BpmnProcess; // astToIr(parse(printDsl(irReDesugared))) + let irImport: BpmnProcess; + let degradedDsl: string; + let irReDesugared: BpmnProcess; + let irSecondRound: BpmnProcess; beforeAll(async () => { const xml = readFileSync(UNSTRUCTURED_BPMN_PATH, 'utf-8'); @@ -132,27 +119,33 @@ describe('goto-degradation preserves the edges that have a goto form', () => { ({ ir: irImport } = await xmlToIr(xml)); degradedDsl = printDsl(irImport); irReDesugared = astToIr(await parseToAst(degradedDsl)); - - const dsl2 = printDsl(irReDesugared); - irSecondRound = astToIr(await parseToAst(dsl2)); + irSecondRound = astToIr(await parseToAst(printDsl(irReDesugared))); }); - it('importing the unstructured fixture and re-emitting never throws', () => { - // The beforeAll ran the whole chain, so reaching here is most of the - // assertion. Pinning the import shape keeps it from being vacuous. - expect(irImport.id).toBe('unstructured-goto'); - expect(irImport.sequenceFlows.length).toBeGreaterThan(0); - }); - - it('the degraded DSL falls back to at least one `goto`', () => { - expect(degradedDsl).toContain('goto'); - const gotos = degradedDsl.match(/\bgoto\b/g) ?? []; - expect(gotos.length).toBeGreaterThanOrEqual(1); - }); - - it('the re-desugared DSL re-parses with zero parser errors', async () => { - const document = await parse(degradedDsl); - expect(document.parseResult.parserErrors).toHaveLength(0); + it('the degraded DSL prints every edge with no structured form as a `goto`', () => { + expect(degradedDsl).toBe( + [ + 'process unstructured-goto {', + ' var route: any', + ' var retry: any', + ' start Start', + ' user Intake(assignee: "demo")', + ' if (route == "A") {', + ' goto Alpha', + ' }', + ' user Beta(assignee: "manager")', + ' end DoneBeta', + ' user Alpha(assignee: "demo")', + ' if (retry == true) {', + ' goto Beta', + ' } else {', + ' end Done', + ' }', + ' goto DoneBeta', + '}', + '', + ].join('\n'), + ); }); it('the real-node reachability is identical after the round-trip', () => { @@ -191,12 +184,18 @@ describe('goto-degradation preserves the edges that have a goto form', () => { .filter((fe) => !isGateway(fe)) .map((fe) => fe.id) .sort(); + expect(realIds(irImport)).toEqual([ + 'Alpha', + 'Beta', + 'Done', + 'DoneBeta', + 'Intake', + 'Start', + ]); expect(realIds(irReDesugared)).toEqual(realIds(irImport)); }); it('the meaningfulness guard: a dropped edge would make reachability differ', () => { - // Removing one import flow changes the relation, so the equality above is - // load-bearing rather than always-true. const corrupt: BpmnProcess = { ...irImport, sequenceFlows: irImport.sequenceFlows.slice(1), @@ -211,50 +210,34 @@ describe('goto-degradation preserves the edges that have a goto form', () => { // leaves tokens unconsumed, so it takes the raw fallback and has to survive the // round trip as the same quoted raw form. describe('bean-call condition stays quoted-raw end-to-end', () => { - const BEAN_DSL = [ - 'process bean-cond(label: "Bean Cond") {', - ' start S', - ' if ("${myBean.check()}") {', - ' user Approve(label: "Approve", assignee: "demo")', - ' } else {', - ' user Reject(label: "Reject", assignee: "demo")', - ' }', - ' end E', - '}', - '', - ].join('\n'); - - const run = roundTripOf(BEAN_DSL); - - const condition = (ir: BpmnProcess) => - ir.sequenceFlows.find((sf) => sf.conditionExpression !== undefined) - ?.conditionExpression; - - it('the bean call is preserved verbatim in the IR condition expression', () => { - expect(condition(run.ir1)).toBe('${myBean.check()}'); - expect(condition(run.ir2)).toBe('${myBean.check()}'); - }); - - it('the re-emitted DSL keeps the condition as the quoted raw `"${...}"` form', () => { - expect(run.dsl).toContain('if ("${myBean.check()}")'); - // The bare (unquoted) form would signal a spurious parse-into-subset. - expect(run.dsl).not.toContain('if (myBean.check())'); - }); - - it('the re-emitted DSL re-parses, and re-desugars to the same raw condition', () => { - expect(condition(run.ir3)).toBe('${myBean.check()}'); + const run = roundTripOf( + [ + 'process bean-cond(label: "Bean Cond") {', + ' start S', + ' if ("${myBean.check()}") {', + ' user Approve(label: "Approve", assignee: "demo")', + ' } else {', + ' user Reject(label: "Reject", assignee: "demo")', + ' }', + ' end E', + '}', + '', + ].join('\n'), + ); + + it('the bean call is preserved verbatim at every hop and re-emitted as the quoted raw form', () => { + const condition = (ir: BpmnProcess) => + ir.sequenceFlows.find((sf) => sf.conditionExpression !== undefined) + ?.conditionExpression; + for (const [hop, ir] of run.hops) { + expect(condition(ir), `differs in ${hop}`).toBe('${myBean.check()}'); + } + expect(run.dsl).toContain(' if ("${myBean.check()}") {\n'); }); }); -// Every row below prints, rebuilds and prints again; the suites elsewhere in -// this repo stop after one hop each direction and never see a value that is -// stable on the first print but drifts on the second. `xml2`/`dsl2` come from -// feeding `dsl1` back through the same two hops, so a row pins idempotence, -// not just a single compile. -// -// Two attribute values below carry a literal `"`, which this pipeline's XML -// writer cannot place directly in an attribute and instead numeric-escapes -// (`"`, ` `); decoding those before the substring check lets a +// Two attribute values in the table carry a literal `"`, which this pipeline's +// XML writer numeric-escapes (`"`, ` `); decoding them first lets a // fragment read the same whether it landed in an attribute or an element body. function decodeXmlEntities(xml: string): string { return xml.replace(/"/g, '"').replace(/ /g, '\n'); diff --git a/tests/round-trip.test.ts b/tests/round-trip.test.ts index a84538c..b6172f6 100644 --- a/tests/round-trip.test.ts +++ b/tests/round-trip.test.ts @@ -18,7 +18,7 @@ import type { BpmnProcess } from '@bpmn-script/transform'; import { normalizeIr } from './helpers/normalize-ir.js'; import { theOnly } from './helpers/ir-query.js'; -import { parse, parseToAst, printDsl } from './helpers/pipeline.js'; +import { parseToAst, printDsl } from './helpers/pipeline.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -43,136 +43,83 @@ describe('Round-trip equivalence: BPMN -> IR -> DSL -> IR -> XML -> IR', () => { expect(normalizeIr(ir3)).toEqual(normalizeIr(ir1)); }); - it('process metadata (id, name, isExecutable) survives the round-trip', () => { - expect(ir3.id).toBe(ir1.id); - expect(ir3.name).toBe(ir1.name); - expect(ir3.isExecutable).toBe(true); - }); - - it('all flow element kinds survive the round-trip (after normalization)', () => { - // irToDsl collapses the hand-named gateway into `if/else` and astToIr - // re-synthesizes a split plus a join the handwritten IR never had, so the - // raw flow-element set cannot match. Inlining the join makes them comparable. - const kinds1 = normalizeIr(ir1) - .flowElements.map((fe) => fe.kind) - .sort(); - const kinds3 = normalizeIr(ir3) - .flowElements.map((fe) => fe.kind) - .sort(); - expect(kinds3).toEqual(kinds1); - }); - - it('sequence flow count is preserved across the round-trip (after normalization)', () => { - // Same reason: `branch -> join -> Done` is two flows, the handwritten IR - // has one `branch -> Done`. - expect(normalizeIr(ir3).sequenceFlows).toHaveLength( - normalizeIr(ir1).sequenceFlows.length, - ); - }); - - it('operaton attributes (assignee, class binding) survive the round-trip', () => { - const review = theOnly(ir3, 'userTask', (t) => t.id === 'ReviewInvoice'); - expect(review.assignee).toBe('demo'); - - expect(theOnly(ir3, 'serviceTask').binding).toEqual({ - kind: 'class', - className: 'com.example.invoice.AutoApproveDelegate', - }); - }); - - it('conditionExpression survives the round-trip', () => { - const conditionalFlow = ir3.sequenceFlows.find( - (sf) => sf.conditionExpression !== undefined, + it('the hand-named gateway prints as if/else and comes back with a synthesized default flow', () => { + // The language has no edge-id syntax, so the hand-named `AutoApprovePath` + // comes back as `Flow__default`. + expect(dslSource).toBe( + [ + 'process invoice-approval {', + ' var amount: any', + ' start ReviewStart', + ' user ReviewInvoice(label: "Review invoice", assignee: "demo")', + ' if (amount > 1000) {', + ' user SeniorApproval(label: "Senior approval", assignee: "manager")', + ' } else {', + ' service AutoApprove(label: "Auto-approve", class: "com.example.invoice.AutoApproveDelegate")', + ' }', + ' end Done', + '}', + '', + ].join('\n'), ); - expect(conditionalFlow).toBeDefined(); - expect(conditionalFlow!.conditionExpression).toBe('${amount > 1000}'); - }); - it('gateway has a synthesized default flow that points at the AutoApprove branch', () => { - // The language has no edge-id syntax, so the hand-named `AutoApprovePath` - // comes back as `Flow__default`. Assert the behavior, not the - // literal id. const gw = theOnly( ir3, 'exclusiveGateway', (g) => gatewayDefaultFlowId(g) !== undefined, ); expect(gw.defaultFlowId).toMatch(/_default$/); - - const defaultFlow = ir3.sequenceFlows.find( - (sf) => sf.id === gw.defaultFlowId, - ); - expect(defaultFlow).toBeDefined(); - expect(defaultFlow!.targetRef).toBe('AutoApprove'); - }); - - it('DSL intermediate output parses without errors', async () => { - const document = await parse(dslSource); - - expect(document.parseResult.parserErrors).toHaveLength(0); - expect(dslSource).toContain('process invoice-approval'); - }); - - it('intermediate DSL is structured syntax (if/else blocks, no gateway/edge)', () => { - expect(dslSource).toContain('if ('); - expect(dslSource).toContain('else'); - expect(dslSource).toContain('{'); - - // Keyword plus whitespace, not a bare substring: an element named - // "...gateway..." would otherwise make this vacuous. - expect(dslSource).not.toMatch(/\bgateway\s/); - expect(dslSource).not.toContain('->'); + expect( + ir3.sequenceFlows.find((sf) => sf.id === gw.defaultFlowId)?.targetRef, + ).toBe('AutoApprove'); }); }); -// Each case corrupts ir3 and asserts normalizeIr still reports a difference: +// Each row corrupts ir3 and asserts normalizeIr still reports a difference: // the re-key rules canonicalize generated ids only, never structure. describe('normalizeIr preserves structural differences', () => { - it('dropping a sequence flow from ir3 makes the comparison FAIL', () => { - const ir3Corrupt: BpmnProcess = { - ...ir3, - sequenceFlows: ir3.sequenceFlows.slice(1), - }; - expect(normalizeIr(ir3Corrupt)).not.toEqual(normalizeIr(ir1)); - }); - - it('removing the real split gateway from ir3 makes the comparison FAIL', () => { - const ir3Corrupt: BpmnProcess = { - ...ir3, - flowElements: ir3.flowElements.filter( - (fe) => !(fe.kind === 'exclusiveGateway' && fe.id.endsWith('_split')), - ), - }; - expect(normalizeIr(ir3Corrupt)).not.toEqual(normalizeIr(ir1)); - }); - - it('re-targeting a branch flow in ir3 makes the comparison FAIL', () => { - const ir3Corrupt: BpmnProcess = { - ...ir3, - sequenceFlows: ir3.sequenceFlows.map((sf) => - sf.targetRef === 'SeniorApproval' - ? { ...sf, targetRef: 'AutoApprove' } - : sf, - ), - }; - expect(normalizeIr(ir3Corrupt)).not.toEqual(normalizeIr(ir1)); - }); - - it('stripping the split gateway default flow makes the comparison FAIL', () => { - // A gateway's default flow is structure, so normalizeIr must not erase it. - const splitGw = ir3.flowElements.find( - (fe) => isGateway(fe) && gatewayDefaultFlowId(fe) !== undefined, - ); - expect(splitGw).toBeDefined(); - - const stripped: BpmnProcess = { - ...ir3, - flowElements: ir3.flowElements.map((fe) => - isGateway(fe) && gatewayDefaultFlowId(fe) !== undefined - ? { kind: fe.kind, id: fe.id, name: fe.name } - : fe, - ), - }; - expect(normalizeIr(stripped)).not.toEqual(normalizeIr(ir1)); + it.each([ + [ + 'dropping a sequence flow', + (ir: BpmnProcess): BpmnProcess => ({ + ...ir, + sequenceFlows: ir.sequenceFlows.slice(1), + }), + ], + [ + 'removing the real split gateway', + (ir: BpmnProcess): BpmnProcess => ({ + ...ir, + flowElements: ir.flowElements.filter( + (fe) => !(fe.kind === 'exclusiveGateway' && fe.id.endsWith('_split')), + ), + }), + ], + [ + 're-targeting a branch flow', + (ir: BpmnProcess): BpmnProcess => ({ + ...ir, + sequenceFlows: ir.sequenceFlows.map((sf) => + sf.targetRef === 'SeniorApproval' + ? { ...sf, targetRef: 'AutoApprove' } + : sf, + ), + }), + ], + [ + 'stripping the split gateway default flow', + (ir: BpmnProcess): BpmnProcess => ({ + ...ir, + flowElements: ir.flowElements.map((fe) => + isGateway(fe) && gatewayDefaultFlowId(fe) !== undefined + ? { kind: fe.kind, id: fe.id, name: fe.name } + : fe, + ), + }), + ], + ])('%s from ir3 makes the comparison fail', (_title, corrupt) => { + const corrupted = corrupt(ir3); + expect(corrupted).not.toEqual(ir3); + expect(normalizeIr(corrupted)).not.toEqual(normalizeIr(ir1)); }); }); diff --git a/tests/task-kinds.round-trip.test.ts b/tests/task-kinds.round-trip.test.ts index 15dfe75..b933844 100644 --- a/tests/task-kinds.round-trip.test.ts +++ b/tests/task-kinds.round-trip.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect } from 'vitest'; import { describeNoOverlappingShapes } from './helpers/di-bounds.js'; -import { definitionRefOf, messageRoots } from './helpers/xml-query.js'; +import { + definitionRefOf, + idsOfTag, + messageRoots, +} from './helpers/xml-query.js'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; const rt = roundTripFixture('task-kinds', { @@ -10,10 +14,6 @@ const rt = roundTripFixture('task-kinds', { recompile: 'clean', }); -function countOf(tag: string): number { - return rt.frozenXml.split(`]*>`).exec(rt.frozenXml); expect( @@ -24,72 +24,59 @@ function openingTag(tag: string, id: string): string { } describe('tag choice on the frozen .bpmn', () => { - it.each<[tag: string, count: number]>([ - ['task', 1], - ['sendTask', 1], - ['receiveTask', 2], - ['businessRuleTask', 3], - ])('bpmn:%s appears %i time(s)', (tag, count) => { - expect(countOf(tag)).toBe(count); + it('every statement kind is written under its own tag', () => { + expect( + Object.fromEntries( + ['task', 'sendTask', 'receiveTask', 'businessRuleTask'].map((tag) => [ + tag, + idsOfTag(rt.frozenXml, tag), + ]), + ), + ).toEqual({ + task: ['RecordOrder'], + sendTask: ['NotifyWarehouse'], + receiveTask: ['AwaitSettlement', 'AwaitPickingSlot'], + businessRuleTask: ['RateRisk', 'ChooseCarrier', 'PriceShipping'], + }); }); }); describe('root derivation on the frozen .bpmn', () => { - it('carries one bpmn:Message per distinct name, in first-appearance order', () => { - expect(messageRoots(rt.frozenXml).map((root) => root.name)).toEqual([ - 'PaymentSettled', - 'PickingSlotReady', + it('carries one bpmn:Message per distinct name, and every carrier that names it references that root', () => { + expect(messageRoots(rt.frozenXml)).toEqual([ + { id: 'Message_PaymentSettled', name: 'PaymentSettled' }, + { id: 'Message_PickingSlotReady', name: 'PickingSlotReady' }, ]); - }); - - it('the receive task and the message end share the root their name derives', () => { - const root = messageRoots(rt.frozenXml)[0]!.id; - expect(openingTag('receiveTask', 'AwaitSettlement')).toContain( - `messageRef="${root}"`, + expect(openingTag('receiveTask', 'AwaitSettlement')).toBe( + '', ); expect(definitionRefOf(rt.frozenXml, 'ForwardSettlement', 'message')).toBe( - root, + 'Message_PaymentSettled', ); - }); - - it('the receive task naming no message carries no messageRef', () => { - expect(openingTag('receiveTask', 'AwaitPickingSlot')).not.toContain( - 'messageRef', + expect(openingTag('receiveTask', 'AwaitPickingSlot')).toBe( + '', ); }); }); describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { it("the decompiled DSL' writes each kind back on its own statement", () => { - expect(rt.dslPrime).toContain( - 'step RecordOrder(label: "Record the order")', - ); - expect(rt.dslPrime).toContain( - 'send NotifyWarehouse(label: "Tell the warehouse", ' + - 'class: "com.example.orders.NotifyWarehouseDelegate")', - ); - expect(rt.dslPrime).toContain( - 'receive AwaitSettlement(label: "Wait for the payment", message: "PaymentSettled")', - ); - expect(rt.dslPrime).toContain( - 'receive AwaitPickingSlot(label: "Wait for a picking slot")', - ); - expect(rt.dslPrime).toContain( - 'decide RateRisk(label: "Rate the order risk", decision: "riskRating", ' + - 'binding: latest, mapDecisionResult: singleEntry, ' + - 'resultVariable: "risk")', - ); - expect(rt.dslPrime).toContain( - 'decide ChooseCarrier(label: "Choose a carrier", ' + - 'class: "com.example.orders.ChooseCarrierDelegate")', - ); - expect(rt.dslPrime).toContain( - 'decide PriceShipping(label: "Price the shipping", ' + - 'decision: "shippingTariff", version: 3)', - ); - expect(rt.dslPrime).toContain( - 'throw message ForwardSettlement("PaymentSettled", ' + - 'class: "com.example.orders.PublishSettlementDelegate")', + expect(rt.dslPrime).toBe( + [ + 'process order-settlement {', + ' start OrderReceived', + ' step RecordOrder(label: "Record the order")', + ' send NotifyWarehouse(label: "Tell the warehouse", class: "com.example.orders.NotifyWarehouseDelegate")', + ' receive AwaitSettlement(label: "Wait for the payment", message: "PaymentSettled")', + ' receive AwaitPickingSlot(label: "Wait for a picking slot")', + ' emit message ConfirmPicking("PickingSlotReady", class: "com.example.orders.ConfirmPickingDelegate")', + ' decide RateRisk(label: "Rate the order risk", decision: "riskRating", binding: latest, mapDecisionResult: singleEntry, resultVariable: "risk")', + ' decide ChooseCarrier(label: "Choose a carrier", class: "com.example.orders.ChooseCarrierDelegate")', + ' decide PriceShipping(label: "Price the shipping", decision: "shippingTariff", version: 3)', + ' throw message ForwardSettlement("PaymentSettled", class: "com.example.orders.PublishSettlementDelegate")', + '}', + '', + ].join('\n'), ); }); }); diff --git a/tests/transactions.round-trip.test.ts b/tests/transactions.round-trip.test.ts index 6cf8882..31adc14 100644 --- a/tests/transactions.round-trip.test.ts +++ b/tests/transactions.round-trip.test.ts @@ -9,6 +9,7 @@ import { import { endEvent, subProcess, theOnly } from './helpers/ir-query.js'; import { idsOfTag } from './helpers/xml-query.js'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; +import type { BpmnProcess } from '@bpmn-script/transform'; const rt = roundTripFixture('transactions', { dslPrimeFrom: 'frozen', @@ -30,76 +31,71 @@ function blockOf(xml: string, tag: string, id: string): string { } describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { - it('both blocks that can be given up keep their element at every hop', () => { - for (const [label, ir] of rt.hops) { - expect( - subProcess(ir, 'BookAndPay').element, - `BookAndPay differs in ${label}`, - ).toBe('transaction'); - expect( + it.each([ + [ + 'the outer block that can be given up keeps its element', + (ir: BpmnProcess) => subProcess(ir, 'BookAndPay').element, + 'transaction', + ], + [ + 'the nested block that can be given up keeps its element', + (ir: BpmnProcess) => subProcess(subProcess(ir, 'PrepareDeparture'), 'AssignSeatRows') .element, - `AssignSeatRows differs in ${label}`, - ).toBe('transaction'); - } - }); - - it('the ordinary blocks carry no element at any hop', () => { - for (const [label, ir] of rt.hops) { - expect( - subProcess(ir, 'PrepareDeparture').element, - `PrepareDeparture differs in ${label}`, - ).toBeUndefined(); - expect( + 'transaction', + ], + [ + 'the ordinary outer block carries no element', + (ir: BpmnProcess) => subProcess(ir, 'PrepareDeparture').element, + undefined, + ], + [ + 'the ordinary nested block carries no element', + (ir: BpmnProcess) => subProcess(subProcess(ir, 'BookAndPay'), 'HoldSeats').element, - `HoldSeats differs in ${label}`, - ).toBeUndefined(); - } - }); - - it('the cancel end keeps its trigger and its label at every hop', () => { - for (const [label, ir] of rt.hops) { - const abandoned = endEvent(ir, 'BookingAbandoned'); - expect(abandoned.eventDefinition, `trigger differs in ${label}`).toEqual({ - kind: 'cancel', - }); - expect(abandoned.name, `label differs in ${label}`).toBe( - 'Give up the booking', - ); - } - }); - - it('the cancel handler stays attached to the block it gives up at every hop', () => { - for (const [label, ir] of rt.hops) { - const boundary = theOnly( - ir, - 'boundaryEvent', - (el) => el.eventDefinition.kind === 'cancel', - ); - expect(boundary.attachedToRef, `host differs in ${label}`).toBe( - 'BookAndPay', - ); - expect( - boundary.cancelActivity, - `interruption differs in ${label}`, - ).toBeUndefined(); - } - }); - - it('the repeated block keeps its clause and its setting at every hop', () => { + undefined, + ], + [ + 'the cancel end keeps its trigger and its label', + (ir: BpmnProcess) => { + const { eventDefinition, name } = endEvent(ir, 'BookingAbandoned'); + return { eventDefinition, name }; + }, + { eventDefinition: { kind: 'cancel' }, name: 'Give up the booking' }, + ], + [ + 'the cancel handler stays attached, interrupting, to the block it gives up', + (ir: BpmnProcess) => { + const { attachedToRef, cancelActivity } = theOnly( + ir, + 'boundaryEvent', + (el) => el.eventDefinition.kind === 'cancel', + ); + return { attachedToRef, cancelActivity }; + }, + { attachedToRef: 'BookAndPay', cancelActivity: undefined }, + ], + [ + 'the repeated block keeps its clause and its setting', + (ir: BpmnProcess) => { + const { loop, asyncBefore } = subProcess( + subProcess(ir, 'PrepareDeparture'), + 'AssignSeatRows', + ); + return { loop, asyncBefore }; + }, + { + loop: { + collection: 'seatRows', + elementVariable: 'row', + sequential: true, + }, + asyncBefore: true, + }, + ], + ] as const)('%s at every hop', (_title, pick, expected) => { for (const [label, ir] of rt.hops) { - const repeated = subProcess( - subProcess(ir, 'PrepareDeparture'), - 'AssignSeatRows', - ); - expect(repeated.loop, `repeat clause differs in ${label}`).toEqual({ - collection: 'seatRows', - elementVariable: 'row', - sequential: true, - }); - expect(repeated.asyncBefore, `asyncBefore differs in ${label}`).toBe( - true, - ); + expect(pick(ir), `differs in ${label}`).toEqual(expected); } }); @@ -130,9 +126,11 @@ describe('block shape pins on the frozen .bpmn', () => { 'BookAndPay', 'AssignSeatRows', ]); - expect(idsOfTag(rt.frozenXml, 'subProcess')).toEqual( - expect.arrayContaining(['HoldSeats', 'PrepareDeparture']), - ); + expect(idsOfTag(rt.frozenXml, 'subProcess')).toEqual([ + 'HoldSeats', + 'EventSubProcess_seat-booking_2_1_3', + 'PrepareDeparture', + ]); }); it('the cancel end sits inside the block it gives up and carries a bare definition', () => { @@ -142,42 +140,40 @@ describe('block shape pins on the frozen .bpmn', () => { ]); const block = blockOf(rt.frozenXml, 'transaction', 'BookAndPay'); - const end = blockOf(block, 'endEvent', 'BookingAbandoned'); - expect(end).toContain(''); + expect(blockOf(block, 'endEvent', 'BookingAbandoned')).toBe( + '\n' + + ' Flow_Gateway_seat-booking_2_3_split_BookingAbandoned\n' + + ' \n' + + ' ', + ); expect(block.match(/ { - const boundary = blockOf( - rt.frozenXml, - 'boundaryEvent', - 'Boundary_BookAndPay_cancel', + it('the cancel and error handlers are both boundary events attached to that same block', () => { + expect( + blockOf(rt.frozenXml, 'boundaryEvent', 'Boundary_BookAndPay_cancel'), + ).toBe( + '\n' + + ' Flow_Boundary_BookAndPay_cancel_ApologizeToTraveler\n' + + ' \n' + + ' ', ); - expect(boundary).toContain('attachedToRef="BookAndPay"'); - expect(boundary).toContain(''); - expect(boundary).not.toContain('cancelActivity'); - }); - - it('the error handler still attaches to the block beside the cancel one', () => { - const boundary = blockOf( - rt.frozenXml, - 'boundaryEvent', - 'Boundary_BookAndPay_error', + expect( + blockOf(rt.frozenXml, 'boundaryEvent', 'Boundary_BookAndPay_error'), + ).toBe( + '\n' + + ' Flow_Boundary_BookAndPay_error_RecordProviderOutage\n' + + ' \n' + + ' ', ); - expect(boundary).toContain('attachedToRef="BookAndPay"'); - expect(boundary).toContain('errorRef="Error_PAYMENT_UNAVAILABLE"'); }); it('the undo block inside the block is a triggeredByEvent block on a compensate start', () => { - const handler = subProcess( - subProcess(rt.ir1, 'BookAndPay'), - 'HoldSeats', - ).flowElements.find( - (fe) => fe.kind === 'subProcess' && fe.triggeredByEvent === true, + const undo = blockOf( + rt.frozenXml, + 'subProcess', + 'EventSubProcess_seat-booking_2_1_3', ); - expect(handler?.kind).toBe('subProcess'); - - const undo = blockOf(rt.frozenXml, 'subProcess', handler!.id); expect(undo).toContain('triggeredByEvent="true"'); expect(undo).toContain(''); }); From 71c4398121512304535e89d7ed49bc4daf8bd1f4 Mon Sep 17 00:00:00 2001 From: Marlon Kranz Date: Sat, 26 Sep 2026 14:17:02 +0200 Subject: [PATCH 06/11] Run the engine suite in CI and consolidate the decision records --- .github/workflows/ci.yml | 6 +- README.md | 20 +- ...markdown-architectural-decision-records.md | 11 +- ...icense.md => 0001-use-apache-2-license.md} | 0 .../0002-use-langium-as-language-workbench.md | 122 ---------- ...ure.md => 0002-use-mono-repo-structure.md} | 0 ...003-auto-layout-for-diagram-interchange.md | 59 ----- .../0003-use-langium-as-language-workbench.md | 58 +++++ ... 0004-use-vscode-as-primary-ide-target.md} | 2 +- ...5-in-editor-conversion-webview-sidebar.md} | 11 +- ...006-target-users-without-bpmn-knowledge.md | 71 ++++++ ...7-intermediate-representation-ast-bpmn.md} | 9 +- ... => 0008-fork-camunda-moddle-extension.md} | 33 +-- docs/decisions/0008-structured-grammar.md | 55 ----- .../0009-auto-layout-and-expansion-hint.md | 90 +++++++ .../0009-dominator-based-restructuring.md | 88 ------- .../0010-deterministic-structural-ids.md | 96 -------- .../0010-deterministic-synthesized-ids.md | 124 ++++++++++ ...11-structured-grammar-one-bracket-shape.md | 110 +++++++++ ...-code-and-generate-operaton-application.md | 55 ----- docs/decisions/0012-honest-bpmn-import.md | 101 ++++++++ ...013-target-users-without-bpmn-knowledge.md | 57 ----- ...013-validator-mirrors-parse-time-rules.md} | 27 +-- .../0014-honest-bpmn-import-contract.md | 159 ------------- ...e-ir-into-a-dsl-with-dominator-analysis.md | 138 +++++++++++ .../0015-subprocess-di-expansion-hint.md | 85 ------- ...print-declares-every-variable-it-reads.md} | 54 +---- .../0016-derived-event-root-elements.md | 173 -------------- ...016-task-kinds-on-the-authoring-surface.md | 71 ++++++ .../0017-event-trigger-payload-surfaces.md | 125 ---------- docs/decisions/0017-service-task-bindings.md | 92 ++++++++ ...compensation-through-event-subprocesses.md | 115 --------- .../0018-forms-and-field-injection.md | 108 +++++++++ .../0019-a-call-activitys-variable-mapping.md | 80 +++++++ ...boundary-events-attached-to-an-activity.md | 221 ------------------ .../0020-intermediate-catch-events.md | 174 -------------- ...ers-reuse-on-inside-the-attribute-block.md | 90 +++++++ ...xternal-tasks-as-a-service-task-binding.md | 78 ------- ...on-engine-attributes-as-named-ir-fields.md | 99 ++++++++ ...22-engine-attributes-as-named-ir-fields.md | 76 ------ ...for-clause-and-its-per-run-job-settings.md | 91 ++++++++ ...ent-roots-from-usage-and-declared-codes.md | 120 ++++++++++ .../0023-listeners-on-the-attribute-block.md | 90 ------- .../0024-event-trigger-payloads-paren-slot.md | 73 ++++++ ...4-event-triggers-on-start-end-and-throw.md | 157 ------------- ...ring-inclusive-and-event-based-gateways.md | 171 -------------- .../0025-events-at-the-process-boundary.md | 113 +++++++++ .../0026-intermediate-catch-events.md | 67 ++++++ ...026-task-kinds-on-the-authoring-surface.md | 115 --------- ...boundary-events-attached-to-an-activity.md | 86 +++++++ ...027-repetition-on-the-authoring-surface.md | 137 ----------- ...8-attempt-blocks-and-the-cancel-trigger.md | 113 --------- ...8-compensation-as-subprocess-undo-block.md | 67 ++++++ ...029-one-bracket-shape-for-every-element.md | 150 ------------ .../0029-work-that-can-be-given-up.md | 73 ++++++ ...-and-escalation-codes-as-declared-names.md | 148 ------------ ...k-events-for-import-round-trip-symmetry.md | 91 ++++++++ .../0031-carry-bpmn-documentation.md | 79 ------- ...0031-inclusive-and-event-based-gateways.md | 114 +++++++++ ...2-expressions-raw-templates-and-escapes.md | 53 +++++ ...ield-injection-as-a-parameter-direction.md | 174 -------------- .../0033-call-activity-variable-mapping.md | 161 ------------- ...0034-multiple-start-events-on-a-process.md | 111 --------- ...k-events-for-import-round-trip-symmetry.md | 175 -------------- ...-synthesized-end-only-at-its-block-tail.md | 144 ------------ ...field-constraints-values-and-properties.md | 134 ----------- ...rnal-task-extras-ride-the-topic-binding.md | 132 ----------- ...ment-and-report-the-quantity-attributes.md | 82 ------- ...engine-settings-on-synthesized-gateways.md | 108 --------- ...41-per-run-job-settings-on-a-repetition.md | 114 --------- .../0042-mail-and-shell-as-a-type-binding.md | 124 ---------- ...t-variable-beside-an-expression-binding.md | 92 -------- ...es-read-and-printed-with-string-escapes.md | 65 ------ ...lback-only-where-the-engine-can-take-it.md | 64 ----- ...0046-listener-shapes-the-engine-deploys.md | 82 ------- ...ettings-on-the-element-the-engine-reads.md | 67 ------ .../0049-import-modeler-shaped-documents.md | 99 -------- ...minted-ids-and-respell-unspellable-ones.md | 104 --------- ...0052-loop-bodies-with-conditioned-exits.md | 93 -------- ...es-off-a-step-and-jumps-with-conditions.md | 107 --------- ...54-import-tiers-follow-the-engine-parse.md | 146 ------------ docs/decisions/README.md | 97 +++----- docs/glossary.md | 12 +- examples/spring-boot/compile-processes.sh | 2 +- packages/cli/README.md | 3 +- packages/cli/package.json | 2 +- packages/cli/src/build.ts | 18 +- packages/cli/src/parse.ts | 46 +--- packages/cli/test/build-parse.smoke.test.ts | 17 +- .../cli/test/decompile-contract.e2e.test.ts | 2 +- packages/extension/README.md | 6 +- packages/extension/media/sidebar.js | 10 +- packages/extension/package.json | 2 +- .../src/extension/conversion-core.ts | 18 +- .../extension/test/conversion-core.test.ts | 38 +++ packages/language/README.md | 18 +- packages/language/package.json | 2 +- packages/language/src/bpmn-script-hover.ts | 10 +- ...mn-script-parser-error-message-provider.ts | 2 +- .../language/src/bpmn-script-validator.ts | 31 +-- packages/language/src/paren-items.ts | 22 +- packages/language/src/vocabulary.ts | 19 +- packages/language/test/helpers/diagnostics.ts | 2 +- packages/language/test/validating.test.ts | 9 +- packages/transform/README.md | 87 +++---- packages/transform/src/ast-to-ir.ts | 7 +- packages/transform/src/host-messages.ts | 59 +++++ packages/transform/src/index.ts | 6 + packages/transform/src/ir-to-dsl.ts | 31 ++- packages/transform/src/xml-to-ir.ts | 2 +- packages/transform/test/ast-to-ir.test.ts | 34 +-- packages/transform/test/ir-to-xml.test.ts | 4 +- packages/transform/test/ir/types.test.ts | 33 ++- tests/boundary-events.round-trip.test.ts | 2 +- tests/golden/README.md | 6 +- tests/vitest.config.ts | 4 + 116 files changed, 2624 insertions(+), 5668 deletions(-) rename docs/decisions/{0004-use-apache-2-license.md => 0001-use-apache-2-license.md} (100%) delete mode 100644 docs/decisions/0002-use-langium-as-language-workbench.md rename docs/decisions/{0005-use-mono-repo-structure.md => 0002-use-mono-repo-structure.md} (100%) delete mode 100644 docs/decisions/0003-auto-layout-for-diagram-interchange.md create mode 100644 docs/decisions/0003-use-langium-as-language-workbench.md rename docs/decisions/{0001-vscode-as-primary-ide-target.md => 0004-use-vscode-as-primary-ide-target.md} (91%) rename docs/decisions/{0011-in-editor-conversion-sidebar.md => 0005-in-editor-conversion-webview-sidebar.md} (87%) create mode 100644 docs/decisions/0006-target-users-without-bpmn-knowledge.md rename docs/decisions/{0006-intermediate-representation-between-ast-and-bpmn.md => 0007-intermediate-representation-ast-bpmn.md} (84%) rename docs/decisions/{0007-operaton-moddle-extension-fork.md => 0008-fork-camunda-moddle-extension.md} (61%) delete mode 100644 docs/decisions/0008-structured-grammar.md create mode 100644 docs/decisions/0009-auto-layout-and-expansion-hint.md delete mode 100644 docs/decisions/0009-dominator-based-restructuring.md delete mode 100644 docs/decisions/0010-deterministic-structural-ids.md create mode 100644 docs/decisions/0010-deterministic-synthesized-ids.md create mode 100644 docs/decisions/0011-structured-grammar-one-bracket-shape.md delete mode 100644 docs/decisions/0012-embed-java-delegate-code-and-generate-operaton-application.md create mode 100644 docs/decisions/0012-honest-bpmn-import.md delete mode 100644 docs/decisions/0013-target-users-without-bpmn-knowledge.md rename docs/decisions/{0048-mirror-the-engines-parse-time-rules-in-the-validator.md => 0013-validator-mirrors-parse-time-rules.md} (71%) delete mode 100644 docs/decisions/0014-honest-bpmn-import-contract.md create mode 100644 docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md delete mode 100644 docs/decisions/0015-subprocess-di-expansion-hint.md rename docs/decisions/{0051-synthesize-variable-declarations-on-print.md => 0015-the-print-declares-every-variable-it-reads.md} (56%) delete mode 100644 docs/decisions/0016-derived-event-root-elements.md create mode 100644 docs/decisions/0016-task-kinds-on-the-authoring-surface.md delete mode 100644 docs/decisions/0017-event-trigger-payload-surfaces.md create mode 100644 docs/decisions/0017-service-task-bindings.md delete mode 100644 docs/decisions/0018-compensation-through-event-subprocesses.md create mode 100644 docs/decisions/0018-forms-and-field-injection.md create mode 100644 docs/decisions/0019-a-call-activitys-variable-mapping.md delete mode 100644 docs/decisions/0019-boundary-events-attached-to-an-activity.md delete mode 100644 docs/decisions/0020-intermediate-catch-events.md create mode 100644 docs/decisions/0020-listeners-reuse-on-inside-the-attribute-block.md delete mode 100644 docs/decisions/0021-external-tasks-as-a-service-task-binding.md create mode 100644 docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md delete mode 100644 docs/decisions/0022-engine-attributes-as-named-ir-fields.md create mode 100644 docs/decisions/0022-repetition-the-for-clause-and-its-per-run-job-settings.md create mode 100644 docs/decisions/0023-event-roots-from-usage-and-declared-codes.md delete mode 100644 docs/decisions/0023-listeners-on-the-attribute-block.md create mode 100644 docs/decisions/0024-event-trigger-payloads-paren-slot.md delete mode 100644 docs/decisions/0024-event-triggers-on-start-end-and-throw.md delete mode 100644 docs/decisions/0025-authoring-inclusive-and-event-based-gateways.md create mode 100644 docs/decisions/0025-events-at-the-process-boundary.md create mode 100644 docs/decisions/0026-intermediate-catch-events.md delete mode 100644 docs/decisions/0026-task-kinds-on-the-authoring-surface.md create mode 100644 docs/decisions/0027-boundary-events-attached-to-an-activity.md delete mode 100644 docs/decisions/0027-repetition-on-the-authoring-surface.md delete mode 100644 docs/decisions/0028-attempt-blocks-and-the-cancel-trigger.md create mode 100644 docs/decisions/0028-compensation-as-subprocess-undo-block.md delete mode 100644 docs/decisions/0029-one-bracket-shape-for-every-element.md create mode 100644 docs/decisions/0029-work-that-can-be-given-up.md delete mode 100644 docs/decisions/0030-error-and-escalation-codes-as-declared-names.md create mode 100644 docs/decisions/0030-link-events-for-import-round-trip-symmetry.md delete mode 100644 docs/decisions/0031-carry-bpmn-documentation.md create mode 100644 docs/decisions/0031-inclusive-and-event-based-gateways.md create mode 100644 docs/decisions/0032-expressions-raw-templates-and-escapes.md delete mode 100644 docs/decisions/0032-field-injection-as-a-parameter-direction.md delete mode 100644 docs/decisions/0033-call-activity-variable-mapping.md delete mode 100644 docs/decisions/0034-multiple-start-events-on-a-process.md delete mode 100644 docs/decisions/0035-link-events-for-import-round-trip-symmetry.md delete mode 100644 docs/decisions/0036-elide-a-synthesized-end-only-at-its-block-tail.md delete mode 100644 docs/decisions/0037-form-field-constraints-values-and-properties.md delete mode 100644 docs/decisions/0038-external-task-extras-ride-the-topic-binding.md delete mode 100644 docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md delete mode 100644 docs/decisions/0040-engine-settings-on-synthesized-gateways.md delete mode 100644 docs/decisions/0041-per-run-job-settings-on-a-repetition.md delete mode 100644 docs/decisions/0042-mail-and-shell-as-a-type-binding.md delete mode 100644 docs/decisions/0043-result-variable-beside-an-expression-binding.md delete mode 100644 docs/decisions/0044-raw-templates-read-and-printed-with-string-escapes.md delete mode 100644 docs/decisions/0045-reserve-an-inclusive-fallback-only-where-the-engine-can-take-it.md delete mode 100644 docs/decisions/0046-listener-shapes-the-engine-deploys.md delete mode 100644 docs/decisions/0047-timer-job-settings-on-the-element-the-engine-reads.md delete mode 100644 docs/decisions/0049-import-modeler-shaped-documents.md delete mode 100644 docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md delete mode 100644 docs/decisions/0052-loop-bodies-with-conditioned-exits.md delete mode 100644 docs/decisions/0053-routes-off-a-step-and-jumps-with-conditions.md delete mode 100644 docs/decisions/0054-import-tiers-follow-the-engine-parse.md create mode 100644 packages/transform/src/host-messages.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9df8bd..5de2ddd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: jobs: build: runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 steps: - uses: actions/checkout@v6 @@ -24,10 +24,10 @@ jobs: - name: Check for uncommitted generated files run: git diff --exit-code + # The end-to-end suite builds the Spring Boot image and boots Operaton + # through testcontainers; the runner ships Docker. - run: npm test if: success() || failure() - env: - SKIP_DOCKER_TESTS: 'true' lint: runs-on: ubuntu-latest diff --git a/README.md b/README.md index c48945c..f91ecfc 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ npm test ``` The test run includes end-to-end tests that boot a real Operaton engine in Docker via [testcontainers](https://testcontainers.com/), so it needs a running Docker daemon. -Without one, set `SKIP_DOCKER_TESTS=true` to skip them (CI does this automatically). +Without one, set `SKIP_DOCKER_TESTS=true` to skip them; CI runs them. ## Documentation @@ -90,7 +90,7 @@ npx bpmns build invoice-approval.bpmnscript -o out/invoice-approval.bpmn npx bpmns parse out/invoice-approval.bpmn -o out/invoice-approval.bpmnscript ``` -Exit codes are `0` for success, `1` for validation or parse errors, `2` for I/O errors. +Exit codes are `0` for success, `1` for validation errors on `build` or a refused BPMN construct on `parse`, and `2` for an I/O problem or input that cannot be read as BPMN XML. Both directions print non-fatal warnings to stderr without changing the exit code, so an undeclared variable reference on compile, or a dropped lane on import is warned about but still produces a file. `parse` also re-validates the script it wrote and lists every error `build` would draw from it, still with exit `0`. Both commands read a file from disk, never stdin, refuse to overwrite an existing output unless you pass `--force`, and refuse the input path itself even then; see [packages/cli/README.md](packages/cli/README.md) for the exact rules. @@ -158,7 +158,7 @@ The event statements `on`, `throw`, `emit` and `await` are the exception: their The `for` row is a modifier rather than a statement: every activity in the table takes it between the id and the parens, meaning `user`, `service`, `script`, `step`, `send`, `receive`, `decide`, `subprocess`, `attempt`, and `call`. The last three rows are members of an element rather than statements: an engine setting joins the rest in the `( )`, a parameter and a listener go in the `{ }`, and not every element takes every one. The process header's own settings are written with the other process declarations. -The listener row reuses `on` instead of opening a new event sub-process; the body block a handler always carries and a listener never does is what tells the two apart ([ADR-0023](docs/decisions/0023-listeners-on-the-attribute-block.md)). +The listener row reuses `on` instead of opening a new event sub-process; the body block a handler always carries and a listener never does is what tells the two apart ([ADR-0020](docs/decisions/0020-listeners-reuse-on-inside-the-attribute-block.md)). The `attempt` head and the `cancel` end are one construct: the end gives up the block it sits in, `on : cancel` beside the block catches it, and the block's finished steps that carry an `on compensation` block (the undo block) are undone in between. [packages/language/README.md](packages/language/README.md) is the full specification: which keys each element takes, which elements take parameters and listeners, and what a conditioned `parallel` branch compiles to. @@ -278,16 +278,16 @@ A decompile deals with that in three ways. A branch that only rejoins the block's tail may print after the block instead, reached by a `goto`, and the block's join may be replaced by direct edges into that tail; the set of steps and their order along each path is unchanged, so this restructuring prints with no warning at all. What each hop reports, item by item, is the import contract in [packages/transform/README.md](packages/transform/README.md#the-import-contract) and the print warnings in [packages/transform/README.md](packages/transform/README.md#print-warnings). -[ADR-0009](docs/decisions/0009-dominator-based-restructuring.md) covers which shapes degrade and why. +[ADR-0014](docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md) covers which shapes degrade and why. Every engine setting a modeler tunes imports warning-free; the IR shape in [packages/transform/README.md](packages/transform/README.md#ir-shape) lists each one. -A user task assigned in BPMN's own words, a `bpmn:humanPerformer` or a `bpmn:potentialOwner`, imports onto the same `assignee`, `candidateUsers` and `candidateGroups` the engine reads it into, with a warning naming the rewrite ([ADR-0039](docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md)). -A link throw is one exception: each engine setting and listener on one warns, because Operaton creates no activity for a link throw and so never reads them ([ADR-0035](docs/decisions/0035-link-events-for-import-round-trip-symmetry.md)). -`asyncAfter` on an event-based gateway is the other: the import refuses it, as Operaton refuses to deploy it ([ADR-0040](docs/decisions/0040-engine-settings-on-synthesized-gateways.md)). +A user task assigned in BPMN's own words, a `bpmn:humanPerformer` or a `bpmn:potentialOwner`, imports onto the same `assignee`, `candidateUsers` and `candidateGroups` the engine reads it into, with a warning naming the rewrite ([ADR-0012](docs/decisions/0012-honest-bpmn-import.md)). +A link throw is one exception: each engine setting and listener on one warns, because Operaton creates no activity for a link throw and so never reads them ([ADR-0030](docs/decisions/0030-link-events-for-import-round-trip-symmetry.md)). +`asyncAfter` on an event-based gateway is the other: the import refuses it, as Operaton refuses to deploy it ([ADR-0021](docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md)). What stays out of reach is a setting with nowhere to sit in the text. That is a listener or an input/output parameter on a synthesized gateway, a setting on the `bpmn:process` element, or a job priority on the repetition element, which the engine reads off the step alone. -The reasoning is in [ADR-0014](docs/decisions/0014-honest-bpmn-import-contract.md). +The reasoning is in [ADR-0012](docs/decisions/0012-honest-bpmn-import.md). The short version: a round trip that changes the model/workflow without warning is worse than one that refuses to run. ## Running a process on a real engine @@ -317,7 +317,7 @@ flowchart LR A source file is parsed into an AST, converted into the IR (a small set of plain TypeScript objects in `packages/transform/src/ir/types.ts` that describe a process in the engine's own terms, tied to no file format), and written out from there. Compiling is `.bpmnscript` -> AST -> IR -> `.bpmn`; decompiling is `.bpmn` -> IR -> `.bpmnscript`. -The IR carries Operaton's semantics under names with no vendor prefix: the engine's bindings, execution settings, input/output parameters and lifecycle listeners are all plain-named IR fields, and `operaton:` is applied only where `irToXml` builds the moddle element from a local [moddle extension](packages/transform/src/operaton-moddle.json) ([ADR-0006](docs/decisions/0006-intermediate-representation-between-ast-and-bpmn.md), [ADR-0022](docs/decisions/0022-engine-attributes-as-named-ir-fields.md), [ADR-0023](docs/decisions/0023-listeners-on-the-attribute-block.md)). +The IR carries Operaton's semantics under names with no vendor prefix: the engine's bindings, execution settings, input/output parameters and lifecycle listeners are all plain-named IR fields, and `operaton:` is applied only where `irToXml` builds the moddle element from a local [moddle extension](packages/transform/src/operaton-moddle.json) ([ADR-0007](docs/decisions/0007-intermediate-representation-ast-bpmn.md), [ADR-0021](docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md), [ADR-0020](docs/decisions/0020-listeners-reuse-on-inside-the-attribute-block.md)). | Library | Role | | --------------------------------------------------------------- | --------------------------------------------------- | @@ -353,7 +353,7 @@ A manual task and a definition-less intermediate throw are not authored; each im A compensation handler is written on a `subprocess` only, so BPMN's compensation boundary event with its association refuses the import and the message names the rewrite ([coverage table](docs/bpmn-coverage.md#boundary-events)). Forms are the `operaton:formData` fields on a start or a user task, or a `formKey`/`formRef` naming one deployed elsewhere; a form field typed outside `string`, `long`, `boolean`, `date`, and `enum` refuses the import ([import contract](packages/transform/README.md#refusals)). A `decide` step names a deployed decision table, and the language has no form for the table itself ([packages/language/README.md](packages/language/README.md#send-receive-and-decision-tasks)). -The diagram is generated on every compile, so hand-placed coordinates in a `.bpmn` file are discarded on import without a warning ([ADR-0003](docs/decisions/0003-auto-layout-for-diagram-interchange.md)). +The diagram is generated on every compile, so hand-placed coordinates in a `.bpmn` file are discarded on import without a warning ([ADR-0009](docs/decisions/0009-auto-layout-and-expansion-hint.md)). ## Project status diff --git a/docs/decisions/0000-use-markdown-architectural-decision-records.md b/docs/decisions/0000-use-markdown-architectural-decision-records.md index e8345f0..ef5a77a 100644 --- a/docs/decisions/0000-use-markdown-architectural-decision-records.md +++ b/docs/decisions/0000-use-markdown-architectural-decision-records.md @@ -12,11 +12,11 @@ Which format and structure should these records follow? ## Considered Options -- [MADR](https://adr.github.io/madr/) 4.0.0 – The Markdown Architectural Decision Records -- [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) – The first incarnation of the term "ADR" -- [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) – The Y-Statements +- [MADR](https://adr.github.io/madr/) 4.0.0: the Markdown Architectural Decision Records +- [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions): the first incarnation of the term "ADR" +- [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions): the Y-Statements - Other templates listed at -- Formless – No conventions for file format and structure +- Formless, no conventions for file format and structure ## Decision Outcome @@ -29,3 +29,6 @@ Chosen option: "MADR 4.0.0", because - The MADR format is lean and fits our development style. - The MADR structure is comprehensible and facilitates usage & maintenance. - The MADR project is vivid. + +Every decision record in this directory follows the MADR 4.0.0 headings from `adr-template.md`, in that order: Context and Problem Statement, Decision Drivers, Considered Options, Decision Outcome, Consequences, Confirmation, Pros and Cons of the Options, More Information. +Each optional heading is dropped when it has no content, and one sentence stays on one line. diff --git a/docs/decisions/0004-use-apache-2-license.md b/docs/decisions/0001-use-apache-2-license.md similarity index 100% rename from docs/decisions/0004-use-apache-2-license.md rename to docs/decisions/0001-use-apache-2-license.md diff --git a/docs/decisions/0002-use-langium-as-language-workbench.md b/docs/decisions/0002-use-langium-as-language-workbench.md deleted file mode 100644 index cc81098..0000000 --- a/docs/decisions/0002-use-langium-as-language-workbench.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -status: accepted -date: 2026-04-13 -decision-makers: Marlon Kranz ---- - -# Use Langium as Language Workbench - -## Context and Problem Statement - -BPMNscript is a textual DSL that requires a parser, AST, validation, scoping, and IDE integration (syntax highlighting, autocomplete, jump-to-definition, inline errors) delivered as a VS Code extension. -Which language workbench should be used to implement the DSL infrastructure? - -## Decision Drivers - -- The primary IDE target had to be Visual Studio Code or IntelliJ, the author's two familiar, high-adoption editors, with first-class extension support -- IDE features (syntax highlighting, autocomplete, jump-to-definition, inline errors) are a core value proposition of a textual DSL -- A 15-week thesis timeline requires fast bootstrapping and minimal boilerplate -- A post-thesis browser-based playground should be architecturally feasible -- The workbench must support custom scoping, validation, and code generation to BPMN 2.0 XML -- Error-tolerant incremental parsing is required for real-time editor feedback - -## Considered Options - -- Langium -- Eclipse Xtext -- MontiCore -- ANTLR with manual LSP server -- JetBrains MPS - -## Decision Outcome - -Chosen option: "Langium", primarily for its own ergonomics: a smaller, more approachable toolchain than Xtext's, and native access to the existing TypeScript BPMN tooling this project builds on: `bpmn-moddle` to read and write BPMN 2.0 XML, and `bpmn-auto-layout` to generate the Diagram Interchange (DI) data a textual syntax has no coordinate system to produce by hand (see ADR-0003). -The IDE target followed from this choice rather than preceding it: Langium's TypeScript toolchain generates an LSP server and VS Code extension directly from the grammar, which made Visual Studio Code the easier of the two viable targets to reach, whereas an IntelliJ plugin for Xtext would not have benefited from that same generation path. -Browser deployment for a future playground was a secondary factor, all within a timeline-feasible learning curve. - -### Consequences - -- Good, because the entire stack is TypeScript, eliminating context-switching between languages -- Good, because Langium generates parser, AST, syntax highlighting, completion, go-to-definition, and diagnostics from the grammar -- Good, because the language server can run in a web worker, enabling a browser-based playground with approximately 95% code reuse -- Good, because `bpmn-moddle` (the standard TypeScript library for BPMN 2.0 XML) integrates naturally -- Good, because Langium is actively maintained by TypeFox as an Eclipse Foundation mature project -- Bad, because Langium has no built-in AST-to-text serializer, requiring a handwritten emitter for the BPMN-to-DSL reverse transformation -- Bad, because documentation has gaps for advanced topics such as custom scoping patterns -- Neutral, because Langium is younger (5 years) than Xtext (20 years), meaning some edge cases are less tested - -## Pros and Cons of the Options - -### Langium - - - -- Good, because it generates a full LSP server and VS Code extension from a single grammar file -- Good, because it uses TypeScript throughout: the same language for grammar processing, validation, code generation, and the VS Code extension -- Good, because it shares the TypeScript/npm ecosystem with `bpmn-moddle` and `bpmn-auto-layout`, the libraries this project uses to read/write and lay out BPMN 2.0 XML, with no cross-language bridge needed to reach them -- Good, because it natively supports browser deployment via web workers and Monaco Editor -- Good, because it provides built-in scoping, cross-reference resolution, and validation infrastructure -- Good, because it is actively developed (v4.2, February 2026) with TypeFox as primary maintainer -- Good, because the Chevrotain parser uses the ALL(\*) algorithm for unbounded lookahead -- Bad, because there is no built-in AST-to-text serializer for round-tripping -- Bad, because documentation is thinner than Xtext's, especially for advanced scoping and Sprotty integration -- Bad, because it has no grammar-level enum construct - -### Eclipse Xtext - - - -- Good, because it is the most mature DSL workbench (20 years, v2.42) -- Good, because it has a built-in serializer for AST-to-text round-tripping -- Good, because its EMF integration provides native access to the BPMN 2.0 metamodel -- Good, because it has extensive documentation, books, and community resources -- Bad, because its maintenance future is at risk (declining contributors, open sustainability discussion since 2020) -- Bad, because VS Code support requires a hybrid Java/TypeScript stack with a JVM backend process -- Bad, because no viable browser deployment path exists (Java server required) -- Bad, because the learning curve is steep (EMF, Guice, Xtend, Eclipse, MWE2) -- Bad, because the build system (Maven Tycho, Eclipse target platforms) is frequently reported as fragile - -### MontiCore - - - -- Good, because it has an existing BPMN Workflow DSL grammar -- Good, because it auto-generates pretty printers from grammars -- Good, because it has the strongest language composition capabilities (inheritance, embedding, aggregation) -- Bad, because it has no LSP support and no VS Code integration path -- Bad, because it has no browser deployment path (19 MB JAR, reflection-heavy) -- Bad, because its community is effectively limited to RWTH Aachen -- Bad, because documentation is academic and sparse outside the handbook - -### ANTLR with manual LSP server - - - -- Good, because ANTLR is the most widely used and cited parser generator -- Good, because it provides full control with no framework lock-in -- Good, because the `antlr4-c3` library provides grammar-aware code completion -- Bad, because every IDE feature (scoping, validation, completion, go-to-definition) must be implemented manually -- Bad, because the estimated time to a working LSP server is 3-5 weeks, consuming most of the implementation timeline -- Bad, because there is no generated VS Code extension scaffolding - -### JetBrains MPS - - - -- Good, because it is backed by JetBrains and actively maintained -- Good, because language composition is trivial in a projectional editor -- Bad, because it is a projectional editor rather than a textual DSL workbench, storing files as XML instead of human-readable text -- Bad, because it has no VS Code support and no LSP integration -- Bad, because it is architecturally incompatible with the thesis goal of a textual DSL - -## More Information - -Twelve workbenches were evaluated in total. -Beyond the five listed above, Spoofax, Rascal, Racket, Neverlang, textX, Chevrotain, and Kotlin+ANTLR+lsp4j were assessed and excluded for reasons including: lack of production-ready VS Code support, abandoned IDE tooling, architectural mismatch (S-expression syntax, projectional editing), or infeasible learning curves for a 15-week timeline. - -Key references: - -- [TypeFox: Xtext, Langium, what next?](https://www.typefox.io/blog/xtext-langium-what-next/) -- [Langium 4.0 Release](https://www.typefox.io/blog/langium-release-4.0/) -- [Call To Action: Secure the future maintenance of Xtext (GitHub #1721)](https://github.com/eclipse-xtext/xtext/issues/1721) -- [Jordan & Zib: A Langium-based approach to BigER (TU Wien, 2024)](https://model-engineering.info/publications/theses/thesis-jordan-zib.pdf) diff --git a/docs/decisions/0005-use-mono-repo-structure.md b/docs/decisions/0002-use-mono-repo-structure.md similarity index 100% rename from docs/decisions/0005-use-mono-repo-structure.md rename to docs/decisions/0002-use-mono-repo-structure.md diff --git a/docs/decisions/0003-auto-layout-for-diagram-interchange.md b/docs/decisions/0003-auto-layout-for-diagram-interchange.md deleted file mode 100644 index 64c8320..0000000 --- a/docs/decisions/0003-auto-layout-for-diagram-interchange.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -status: accepted -date: 2026-04-13 -decision-makers: Marlon Kranz ---- - -# Use Auto-layout for Diagram Interchange Data - -## Context and Problem Statement - -BPMN 2.0 XML includes Diagram Interchange (DI) data that specifies the graphical layout of process elements (positions, dimensions, edge waypoints). -How should BPMNscript handle DI data during DSL-to-XML export and XML-to-DSL import? - -## Decision Drivers - -- A textual DSL has no inherent coordinate system for graphical layout -- Preserving original DI data through a text-based round-trip is impractical -- Generated BPMN XML must be renderable by standard BPMN tools - -## Considered Options - -- Auto-layout on export, discard DI on import -- Preserve DI data in a side-channel (annotations or separate file) -- Include layout hints in the DSL syntax - -## Decision Outcome - -Chosen option: "Auto-layout on export, discard DI on import", because a textual DSL has no meaningful way to represent or preserve graphical coordinates, and auto-layout produces adequate results for generated BPMN XML. - -### Consequences - -- Good, because the DSL syntax remains clean and focused on process semantics -- Good, because `bpmn-auto-layout` (the bpmn.io layout library) produces consistent, readable diagrams -- Bad, because manually arranged layouts in imported BPMN files are lost after a round-trip -- Neutral, because users who need precise layout control can adjust the generated BPMN in a graphical editor after export - -## More Information - -When `bpmn-auto-layout`'s grid solver throws on a graph it cannot place, `irToXml` raises `LayoutError`. -It carries the document serialized just before the layout call, with no `bpmndi:` diagram. -The CLI and the extension catch it, write that diagram-less document, and warn the user instead of losing the output. -The engine deploys it unchanged: `BpmnParse.parseDiagramInterchangeElements` reads a diagram only when the document's `BPMNDiagram` list is non-empty. -A document with none skips diagram parsing and deploys on its process content alone. - -The layout as it stands has three known defects, each measured on the current tree. -The measurement walks every flow node for a shape, every sequence flow for an edge, and every edge segment for an activity shape it crosses that is neither its source nor its target. -First, a `goto` edge into a sub-process is routed across the sub-process's own children instead of around them. -The example `order-handling`'s `Flow_MarkAutoApproved_Payment` crosses both the `Payment` sub-process's start event and its first task. -The golden `boundary-events`' `Flow_MarkAddressVerified_PackGoods` crosses the `PackGoods` sub-process's start event and its first task the same way. - -Second, the spread of several boundary events on one host scales with the host's width rather than with the events' own. -The library places the `i`-th of `n` attachers at `host.x + (i + 1) * host.width / (n + 1) - 18`, all on the host's lower edge. -Two events on a 100 px task therefore sit 33 px apart at their centers while each is 36 px wide. -In the golden `boundary-events`, the two events on `CheckAddress` (x 625 to 725) span x 640.3 to 676.3 and x 673.7 to 709.7, overlapping by 2.7 px. -Two events overlap on any host narrower than 108 px, three times an event's width, and every further event narrows the spacing again; the two on the 850 px wide `PackGoods` sub-process land 283 px apart and clear each other. -Third, a guard clause such as `if (chargeDeclined) { end BookingAbandoned }` keeps the join that only its default flow reaches (ADR-0045). -The layouter puts the end event in the cell between the split and that join on the same row, so the default edge runs straight through it. -The example `booking-attempt`'s `Flow_Gateway_booking-attempt_1_2_split_default` crosses `BookingAbandoned` this way, and the goldens `event-handlers` (`PaymentFailed`) and `transactions` (`BookingAbandoned`) carry the same shape. -A border-termination pass for the first defect and a boundary-spread pass for the second are both considered options, not committed work; the third goes away with the join elision ADR-0045 defers. diff --git a/docs/decisions/0003-use-langium-as-language-workbench.md b/docs/decisions/0003-use-langium-as-language-workbench.md new file mode 100644 index 0000000..7b23faf --- /dev/null +++ b/docs/decisions/0003-use-langium-as-language-workbench.md @@ -0,0 +1,58 @@ +--- +status: accepted +date: 2026-04-13 +decision-makers: Marlon Kranz +--- + +# Use Langium as Language Workbench + +## Context and Problem Statement + +BPMNscript is a textual DSL that requires a parser, AST, validation, scoping, and IDE integration (syntax highlighting, autocomplete, jump-to-definition, inline errors) delivered as a VS Code extension. +Which language workbench should be used to implement the DSL infrastructure? + +## Decision Drivers + +- The primary IDE target had to be Visual Studio Code or IntelliJ, the author's two familiar, high-adoption editors, with first-class extension support +- IDE features (syntax highlighting, autocomplete, jump-to-definition, inline errors) are a core value proposition of a textual DSL +- A 15-week thesis timeline requires fast bootstrapping and minimal boilerplate +- A post-thesis browser-based playground should be architecturally feasible +- The workbench must support custom scoping, validation, and code generation to BPMN 2.0 XML +- Error-tolerant incremental parsing is required for real-time editor feedback + +## Considered Options + +- Langium: TypeScript throughout, with built-in scoping, cross-reference resolution and validation infrastructure, and a Chevrotain parser with unbounded (ALL(\*)) lookahead. +- Eclipse Xtext: the most mature workbench (20 years), with EMF integration giving native access to the BPMN 2.0 metamodel and a built-in AST-to-text serializer, but VS Code support needs a hybrid Java/TypeScript stack with a JVM backend, no browser deployment path exists, the learning curve is steep (EMF, Guice, Xtend, Eclipse, MWE2), and its maintenance future has been in question since a 2020 sustainability discussion. +- MontiCore: has an existing BPMN Workflow DSL grammar, auto-generates pretty printers from grammars, and has strong language composition (inheritance, embedding, aggregation), but has no LSP support, no VS Code integration path, and no browser deployment path. +- ANTLR with a manual LSP server: the most widely used parser generator, with full control and no framework lock-in, and grammar-aware completion via `antlr4-c3`, but every IDE feature would need to be implemented by hand, an estimated 3-5 weeks of the timeline, with no generated VS Code extension scaffolding. +- JetBrains MPS: backed by JetBrains, with trivial language composition in its projectional editor, but it stores files as XML rather than human-readable text, has no VS Code or LSP integration, and is architecturally incompatible with a textual DSL. + +## Decision Outcome + +Chosen option: "Langium", primarily for its own ergonomics: a smaller, more approachable toolchain than Xtext's, and native access to the existing TypeScript BPMN tooling this project builds on: `bpmn-moddle` to read and write BPMN 2.0 XML, and `bpmn-auto-layout` to generate the Diagram Interchange (DI) data a textual syntax has no coordinate system to produce by hand. +The IDE target followed from this choice rather than preceding it: Langium's TypeScript toolchain generates an LSP server and VS Code extension directly from the grammar, which made Visual Studio Code the easier of the two viable targets to reach, whereas an IntelliJ plugin for Xtext would not have benefited from that same generation path. +Browser deployment for a future playground was a secondary factor, all within a timeline-feasible learning curve. + +### Consequences + +- Good, because the entire stack is TypeScript, eliminating context-switching between languages +- Good, because Langium generates parser, AST, syntax highlighting, completion, go-to-definition, and diagnostics from the grammar +- Good, because the language server can run in a web worker, enabling a browser-based playground with approximately 95% code reuse +- Good, because `bpmn-moddle` (the standard TypeScript library for BPMN 2.0 XML) integrates naturally +- Good, because Langium is actively maintained by TypeFox as an Eclipse Foundation mature project +- Bad, because Langium has no built-in AST-to-text serializer, requiring a handwritten emitter for the BPMN-to-DSL reverse transformation +- Bad, because documentation has gaps for advanced topics such as custom scoping patterns +- Neutral, because Langium is younger (5 years) than Xtext (20 years), meaning some edge cases are less tested + +## More Information + +Twelve workbenches were evaluated in total. +Beyond the five listed above, Spoofax, Rascal, Racket, Neverlang, textX, Chevrotain, and Kotlin+ANTLR+lsp4j were assessed and excluded for reasons including: lack of production-ready VS Code support, abandoned IDE tooling, architectural mismatch (S-expression syntax, projectional editing), or infeasible learning curves for a 15-week timeline. + +Sources for the comparison: + +- [TypeFox: Xtext, Langium, what next?](https://www.typefox.io/blog/xtext-langium-what-next/) +- [Langium 4.0 Release](https://www.typefox.io/blog/langium-release-4.0/) +- [Call To Action: Secure the future maintenance of Xtext (GitHub #1721)](https://github.com/eclipse-xtext/xtext/issues/1721) +- [Jordan & Zib: A Langium-based approach to BigER (TU Wien, 2024)](https://model-engineering.info/publications/theses/thesis-jordan-zib.pdf) diff --git a/docs/decisions/0001-vscode-as-primary-ide-target.md b/docs/decisions/0004-use-vscode-as-primary-ide-target.md similarity index 91% rename from docs/decisions/0001-vscode-as-primary-ide-target.md rename to docs/decisions/0004-use-vscode-as-primary-ide-target.md index e482094..ef7911d 100644 --- a/docs/decisions/0001-vscode-as-primary-ide-target.md +++ b/docs/decisions/0004-use-vscode-as-primary-ide-target.md @@ -14,7 +14,7 @@ Which IDE should be the primary target? ## Decision Drivers - IDE support is the core value proposition of a textual DSL -- The chosen language workbench (Langium, see [ADR-0002](0002-use-langium-as-language-workbench.md)) natively targets VS Code +- The chosen language workbench (ADR-0003) natively targets VS Code - VS Code is the most widely used editor among developers - The Language Server Protocol (LSP) enables secondary IDE support without full reimplementation diff --git a/docs/decisions/0011-in-editor-conversion-sidebar.md b/docs/decisions/0005-in-editor-conversion-webview-sidebar.md similarity index 87% rename from docs/decisions/0011-in-editor-conversion-sidebar.md rename to docs/decisions/0005-in-editor-conversion-webview-sidebar.md index 407d120..85cd107 100644 --- a/docs/decisions/0011-in-editor-conversion-sidebar.md +++ b/docs/decisions/0005-in-editor-conversion-webview-sidebar.md @@ -4,18 +4,17 @@ date: 2026-06-28 decision-makers: Marlon Kranz --- -# In-Editor Conversion: Webview Sidebar, Two-Layer Architecture, and CJS Bundling +# In-Editor Conversion: Webview Sidebar and Two Layers ## Context and Problem Statement The only way to compile `.bpmnscript` to `.bpmn` or decompile `.bpmn` to `.bpmnscript` was the `bpmns` CLI. The CLI entry points (`buildAction`/`parseAction`) bake in `process.exit()` and `console.log`, making them unusable inside the VS Code extension host. - Three related questions arose. Which surface should the in-editor conversion use, a native VS Code tree view or a webview panel in a dedicated activity-bar container? How should the conversion logic be structured so it can be tested without the VS Code host, which is unavailable under plain vitest? And how should the `operaton-moddle.json` lookup be resolved for the bundled extension? -`@bpmn-script/transform` calls `fileURLToPath(import.meta.url)` at module-init time to locate that file, and esbuild's CJS output sets `import.meta.url` to `undefined`, causing `ERR_INVALID_ARG_TYPE` on extension activation, the same JSON-asset footgun seen in `packages/transform/src/`. +`@bpmn-script/transform` calls `fileURLToPath(import.meta.url)` at module-init time to locate that file, and esbuild's CJS output sets `import.meta.url` to `undefined`, causing `ERR_INVALID_ARG_TYPE` on extension activation. ## Decision Drivers @@ -39,14 +38,14 @@ For the bundling fix: ## Decision Outcome The UI is a webview panel in a dedicated "BPMNscript" activity-bar container (view id `bpmnscript.sidebar`, name "Convert"). -The panel renders a small custom layout for the active file: its name, one convert button, a link to the counterpart file when it exists, and a "pick a BPMN file" action. +A webview gives direct control over the panel layout and leaves room to grow it, for example with an inline diagram preview, in a way a tree view's fixed row model would not. It uses only `--vscode-*` CSS custom properties, so it tracks the host theme without any external toolkit dependency. -A webview gives direct control over that layout and leaves room to grow it, for example with an inline diagram preview, in a way a tree view's fixed row model would not. The architecture is a two-layer split: a pure core (`conversion-core.ts`, no `vscode` import) and a thin VS Code adapter (`conversion.ts`). The core contains all interesting logic (severity gating, error classification, unsupported-element handling) and is unit-testable under vitest. The adapter owns only VS Code I/O: URI resolution, file reads and writes, focusing the Problems panel, overwrite confirmation, and opening the result. -The commands (`bpmnscript.compile`, `bpmnscript.decompile`, and `bpmnscript.openAndDecompile`, which picks a BPMN file and runs the decompile handler on it) are the single execution path. +The panel renders a small layout for the active file: its name, one convert button, a link to the counterpart file when it exists, and an action that picks a BPMN file. +The commands `bpmnscript.compile`, `bpmnscript.decompile` and `bpmnscript.openAndDecompile`, the last of which picks a BPMN file and runs the decompile handler on it, are the single execution path. The sidebar and the command palette call the same commands with a URI argument, so behavior is identical across entry points. The bundling fix is an esbuild CJS shim plus an `onEnd` asset copy, keeping the transform package unmodified. diff --git a/docs/decisions/0006-target-users-without-bpmn-knowledge.md b/docs/decisions/0006-target-users-without-bpmn-knowledge.md new file mode 100644 index 0000000..6f45ef7 --- /dev/null +++ b/docs/decisions/0006-target-users-without-bpmn-knowledge.md @@ -0,0 +1,71 @@ +--- +status: accepted +date: 2026-06-30 +decision-makers: Marlon Kranz, Oliver Kopp +--- + +# Target users without BPMN knowledge, and minimize boilerplate + +## Context and Problem Statement + +ADR-0011 gave BPMNscript a structured, code-like surface aimed at developers who prefer working in code. +It left two questions open: how much BPMN literacy the language may assume, and how much required syntax is acceptable. +Keywords, settings, and event declarations could still presuppose familiarity with BPMN's element types and vocabulary, and the grammar could still demand explicit text where the compiler could supply a default. +A third question came with a proposal to push the boilerplate rule past the model: write the body of a `JavaDelegate`'s `execute` method directly in the `.bpmnscript` file, generate the complete Java class from it, scaffold the surrounding Operaton application (build configuration, Spring Boot wiring, resources), and generate tests for the result. + +How much BPMN knowledge may the language assume, how much required syntax is acceptable, and does the host application's boilerplate belong to the language too? + +Decided in the supervision meeting of 2026-06-30. + +## Decision Drivers + +- BPMN-literate users already have a well-supported option: the graphical modeler. + The textual language is motivated by the other population, for whom BPMN vocabulary is a barrier rather than a help. +- Required syntax that carries no process information raises the entry barrier without paying for itself. +- ADR-0011's structured constructs already hide gateway mechanics behind `if`/`while`/`parallel`. + Without an explicit audience decision, future grammar work has no tiebreaker between "closer to BPMN" and "simpler for newcomers". +- Bidirectional conversion between DSL and BPMN XML is a core property of the tool, so text the target format has no slot for cannot enter the surface. +- ADR-0007 keeps the IR a clean graph of process semantics, not a store for host-language code. + +## Considered Options + +- Assume BPMN literacy: mirror BPMN terminology and structure, keeping the text close to the XML it compiles to +- Assume general programming literacy but no BPMN knowledge, and minimize required syntax +- Go further and take the host application's boilerplate too: embed Java method bodies in the DSL and generate the delegate classes, a complete Operaton application, and tests + +## Decision Outcome + +Chosen option: "Assume general programming literacy but no BPMN knowledge, and minimize required syntax". +Concretely, two rules bind future grammar decisions: + +1. No BPMN prerequisite. + Reading or writing a `.bpmnscript` file must not require knowing BPMN. + Keywords name what the user means (a step a person performs, a step the system performs, a decision), not the BPMN element behind it. +2. Defaults over declarations. + Whenever the compiler can infer or synthesize a detail, the grammar must not require the user to write it. + When two designs express the same process, the one with less required text wins. + +Implicit sequence flow (ADR-0011) and synthesized structural ids (ADR-0010) already follow both rules; this decision makes them binding for what comes next. + +The second rule stops at the compiler's output boundary, which is BPMN XML. +`bpmns build` compiles a `.bpmnscript` file to a `.bpmn` file, `bpmns parse` decompiles it back, and delegate classes are hand-written in a separate host application, as in `examples/spring-boot/`. +Embedding a method body would give up the two properties the design rests on: BPMN XML has no representation for Java source, so an embedded body is lost on compile and cannot be recovered on decompile, and carrying it through the IR pierces ADR-0007's boundary. +It would also change what deployment means. +BPMN XML alone hot-deploys through a single REST call (`POST /engine-rest/deployment/create`), while a Java class has to be on the engine's classpath, so every change to an embedded body forces a rebuild and restart of the host application and the deployment unit becomes the application rather than the model. +The generated scaffold is a long-lived support surface of its own, in dependency versions, upgrade path and build tooling, beyond the thesis time budget. + +### Consequences + +- Good, because the entry barrier drops: a newcomer can write a running process without first learning BPMN's vocabulary or diagram semantics. +- Good, because grammar discussions get a tiebreaker instead of re-arguing the audience each time. +- Good, because no lossy construct enters the language and the IR stays free of host-language code, so the round-trip guarantees hold for everything the surface admits. +- Good, because a deploy command stays a thin REST client, the mechanics the end-to-end test adapters (`tests/fixtures/adapters/`) already exercise. +- Bad, because hiding BPMN vocabulary makes the DSL-to-XML correspondence less obvious for BPMN-literate readers; documentation has to carry that mapping, and the README glossary is a start. +- Bad, because every default the grammar adopts is a rule the round trip must invert, synthesized on compile and elided on decompile, growing the mapping surface that ADR-0010 normalization already covers for ids. +- Bad, because delegates are written and kept in sync by hand; the `class: "..."` string is not checked against any Java source, so a mismatch surfaces only at engine runtime. +- Bad, because the one-file authoring experience is not available: a process and the code its steps call live in separate files. + +## More Information + +The engine tuning keys (`asyncBefore`, `asyncAfter`, `exclusive`, `jobPriority`, `retryCycle`) and the listener block are engine-side settings outside the no-BPMN-knowledge promise: they name what Operaton does with a step, not a BPMN element, and a reader who never tunes the engine never writes them. +Generating tests for the compiled `.bpmn` artifact stands on its own: it consumes the compiler's existing output rather than adding Java source to the language, so none of the round-trip or IR concerns apply to it, and it is set aside as possible later work. diff --git a/docs/decisions/0006-intermediate-representation-between-ast-and-bpmn.md b/docs/decisions/0007-intermediate-representation-ast-bpmn.md similarity index 84% rename from docs/decisions/0006-intermediate-representation-between-ast-and-bpmn.md rename to docs/decisions/0007-intermediate-representation-ast-bpmn.md index f939ece..637ed83 100644 --- a/docs/decisions/0006-intermediate-representation-between-ast-and-bpmn.md +++ b/docs/decisions/0007-intermediate-representation-ast-bpmn.md @@ -4,7 +4,7 @@ date: 2026-04-13 decision-makers: Marlon Kranz --- -# Use an Intermediate Representation between the AST and BPMN XML +# An intermediate representation between the AST and BPMN XML ## Context and Problem Statement @@ -17,7 +17,7 @@ Should both directions route through a shared intermediate representation (IR), ## Decision Drivers -- The two transforms are the hardest code in the project: gateway synthesis on the way down, dominator-based restructuring on the way up (ADR-0009, ADR-0010). +- The two transforms are the hardest code in the project: gateway synthesis on the way down, dominator-based restructuring on the way up. Both need a graph model to run against, and neither the block-structured AST nor moddle's serialization-bound object model is one. - A shared model lets the two directions meet in the middle, so the round-trip is one model with four transforms instead of two independent converters. Round-trip idempotence then becomes a property of a single representation. @@ -37,9 +37,8 @@ Gateway synthesis (`astToIr`) and structural recovery (`irToDsl`) both operate o Because both directions share it, the round-trip is verified against one representation rather than two. Portability across execution engines is a non-goal. -The project targets Operaton (ADR-0007), so the IR is not abstracted for other engines. -It models the semantics Operaton executes under field names that carry no vendor prefix: executable processes, service tasks bound to a Java class, a JUEL expression, a delegate expression, or an external topic, script tasks, and process variables. -Attributes that vary only at serialization, such as `targetNamespace`, are attached at the IR-to-XML boundary and are not stored in the IR. +The project targets Operaton (ADR-0008), so the IR is not abstracted for other engines. +It models the semantics Operaton executes under field names that carry no vendor prefix, with attributes that vary only at serialization, such as `targetNamespace`, attached at the IR-to-XML boundary rather than stored in the IR. ### Consequences diff --git a/docs/decisions/0007-operaton-moddle-extension-fork.md b/docs/decisions/0008-fork-camunda-moddle-extension.md similarity index 61% rename from docs/decisions/0007-operaton-moddle-extension-fork.md rename to docs/decisions/0008-fork-camunda-moddle-extension.md index be9b826..256ad1b 100644 --- a/docs/decisions/0007-operaton-moddle-extension-fork.md +++ b/docs/decisions/0008-fork-camunda-moddle-extension.md @@ -4,7 +4,7 @@ date: 2026-05-21 decision-makers: Marlon Kranz --- -# Fork the Camunda Moddle Extension as a Local Operaton Extension +# Fork the Camunda moddle extension as a local Operaton one ## Context and Problem Statement @@ -20,40 +20,23 @@ How should BPMNscript register Operaton's extension attributes with `bpmn-moddle - Emitted XML must carry the `operaton:` prefix and namespace. `camunda:` is accepted for legacy support, but Operaton officially recommends using `operaton:`. - Registering two extensions that define the same attribute names on the same BPMN type causes a name collision in `bpmn-moddle`'s extension registry. - That might break compatibility with future extensions supporting Camunda. - Only a subset of Operaton's extension attributes is needed as of now; carrying the full Camunda descriptor adds unnecessary complexity. - Minimizing external dependencies reduces maintenance burden. ## Considered Options -- Load `camunda-bpmn-moddle` and post-process the serialized XML to rewrite the namespace +- Load `camunda-bpmn-moddle` and post-process the serialized XML to rewrite the namespace, which reuses an established, maintained package but is fragile: string-replacing prefixes after serialization risks corrupting `xmlns` declarations or attribute values that contain the prefix string, and the `camunda:` and `operaton:` extensions cannot be loaded side by side because `bpmn-moddle` collides on identically named attributes - Ship a trimmed fork of the Camunda descriptor with prefix and URI replaced ## Decision Outcome Chosen option: "Ship a trimmed fork", because it avoids namespace collisions, eliminates fragile string-replacement post-processing, and keeps the descriptor small and auditable. +The fork is based on `camunda-bpmn-moddle/resources/camunda.json` from the bpmn-io GitHub organization, with the prefix and namespace URI replaced and only the attributes BPMNscript uses kept. ### Consequences -- Good, because emitted XML correctly carries `xmlns:operaton` and `operaton:`-prefixed attributes without post-processing -- Good, because the descriptor defines only the attributes actually used, which keeps it easy to review and extend -- Good, because no external dependency is needed for the Operaton namespace -- Bad, because if Operaton publishes an official moddle extension in the future, the local fork must be replaced - -## Pros and Cons of the Options - -### Load `camunda-bpmn-moddle` and rewrite the XML - -- Good, because it reuses an established, maintained package -- Bad, because string-replacing namespace prefixes after serialization is fragile: it can corrupt `xmlns` declarations or attribute values that happen to contain the prefix string -- Bad, because the `camunda:` and `operaton:` extensions cannot be loaded side-by-side due to `bpmn-moddle`'s property name collision on identically named attributes - -### Ship a trimmed local fork - -- Good, because the descriptor is self-contained and carries no runtime dependency -- Good, because only the needed attributes are defined, keeping the extension surface minimal -- Bad, because it must be maintained manually when new Operaton attributes are needed - -## More Information - -The fork is based on `camunda-bpmn-moddle/resources/camunda.json` from the bpmn-io GitHub organization. +- Good, because emitted XML correctly carries `xmlns:operaton` and `operaton:`-prefixed attributes without post-processing. +- Good, because the descriptor defines only the attributes actually used, which keeps it easy to review and extend. +- Good, because no external dependency is needed for the Operaton namespace. +- Bad, because if Operaton publishes an official moddle extension in the future, the local fork must be replaced. +- Bad, because the fork must be maintained manually when new Operaton attributes are needed. diff --git a/docs/decisions/0008-structured-grammar.md b/docs/decisions/0008-structured-grammar.md deleted file mode 100644 index d4b852f..0000000 --- a/docs/decisions/0008-structured-grammar.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -status: accepted -date: 2026-06-12 -decision-makers: Marlon Kranz ---- - -# Use a Structured, Code-Like Grammar - -## Context and Problem Statement - -A BPMN DSL can expose its surface syntax in two broad shapes. -One is a flat declaration-plus-edges form: every flow node (`start`, `user`, `gateway`, `service`, `end`) is declared at the top level, and explicit `->` edges (with `when:`, `as:`, `default:`) connect them. -This maps directly to the BPMN graph model, but it reads like a diagram description rather than code, and offers little advantage over editing BPMN XML for developers who think in control-flow terms. -The other is a structured, code-like language with implicit sequence flow and block-scoped control statements. - -Which surface should BPMNscript use: a flat graph declaration, or a structured, code-like language with implicit sequence flow and block-scoped control statements? - -## Decision Drivers - -- The thesis goal is a textual DSL that serves developers who prefer working in code -- IDE support, meaning inline errors, type-aware validation, and jump-to-definition, is far richer for a real expression AST than for opaque condition strings -- Authoring a flat graph requires the same mental model as a BPMN diagram; a structured language is closer to how developers already write sequential logic -- The round-trip direction (BPMN XML to DSL) needs a `goto`-capable fallback for unstructured graphs, and the structured surface accommodates this naturally -- A JUEL-subset expression sub-language parsed to a real AST is needed to enable type-check diagnostics on condition expressions - -## Considered Options - -- Structured code-like syntax (keyword + braces, implicit flow, `if`/`while`/`parallel`/`goto`) -- A flat node/edge syntax (explicit declarations + `->` edges, mapping 1:1 to the BPMN graph) -- Hybrid: named blocks for common patterns, with a fallback to explicit edges - -## Decision Outcome - -Chosen option: "Structured code-like syntax", because it matches how developers already write sequential logic. -Conditions become a real expression AST, which is what makes type-check diagnostics possible; `goto` slots into the same surface as the decompilation fallback for unstructured BPMN graphs. - -### Consequences - -- Good, because the DSL reads and writes like program code (`if`/`while`/`parallel`) -- Good, because conditions are a first-class expression AST, enabling type-check validation and jump-to-definition for variable references -- Good, because `parallel { { } { } }` maps directly to AND fork/join pairs, making parallel-gateway support natural to author -- Good, because `goto` as a residual form keeps decompilation total: every valid BPMN graph has a valid DSL representation -- Bad, because the desugaring (`astToIr`) must synthesize gateway pairs from block structure, adding complexity relative to a flat pass-through that mirrors the graph directly - -### Confirmation - -The construct round-trip idempotence test (`tests/round-trip-constructs.test.ts`) verifies that `if`/`else`, `while` and `parallel` survive `astToIr -> irToXml -> xmlToIr -> irToDsl` without losing structure. -The conditioned `parallel` branch and the multi-branch `await` survive the same four hops in the frozen pair `tests/golden/branch-and-race.bpmnscript` and the BPMN generated from it. -The goto-degradation path is exercised by `tests/golden/unstructured-goto.bpmn` in the same suite. -Neither the conditioned branch nor the multi-branch `await` spent a reserved word, so the grammar header's claim that there is no `gateway` keyword and that every gateway is derived from a control-flow construct holds word for word. - -## More Information - -The JUEL-subset boundary, meaning what parses natively and what falls back to `"${...}"`, is fixed by the grammar's expression sub-rules in `packages/language/src/bpmn-script.langium` and mirrored by the hand-rolled parser in `packages/transform/src/juel.ts`. -The completion provider offers at a caret only what the validator accepts there, and `packages/language/test/completion.test.ts` holds that with a table that inserts every offered item and validates the result. diff --git a/docs/decisions/0009-auto-layout-and-expansion-hint.md b/docs/decisions/0009-auto-layout-and-expansion-hint.md new file mode 100644 index 0000000..46e050b --- /dev/null +++ b/docs/decisions/0009-auto-layout-and-expansion-hint.md @@ -0,0 +1,90 @@ +--- +status: accepted +date: 2026-04-13 +decision-makers: Marlon Kranz +--- + +# Diagram interchange: auto-layout and the sub-process expansion hint + +## Context and Problem Statement + +BPMN 2.0 XML carries Diagram Interchange (DI) data that fixes the graphical layout of process elements: positions, dimensions and edge waypoints. +A textual DSL has no coordinate system of its own, so it has to settle what happens to DI on export from the DSL and on import of an existing diagram. +A nested container is part of the same question, since an embedded sub-process holds a plane of its own. + +## Decision Drivers + +- A textual DSL has no inherent coordinate system for graphical layout. +- Preserving original DI data through a text-based round-trip is impractical. +- Generated BPMN XML must be renderable by standard BPMN tools. +- No layout computation of our own, since a bespoke box-and-offset algorithm would duplicate what a layout library already does. +- DI is presentation only, so nothing about it may touch the semantic element tree or its ids. +- A process without any sub-process must keep producing byte-identical output, which is the path the export pipeline is tested and deployed against. + +## Considered Options + +- Auto-layout the whole diagram on export, discard DI on import, and pre-seed a minimal `isExpanded` shape hint per sub-process so the library expands nested containers +- Preserve DI data in a side channel, as annotations in the source or as a separate file, impractical for a text-based round-trip +- Include layout hints in the DSL syntax, which would put graphical coordinates into a language meant to describe process semantics only +- Accept the collapsed sub-process rendering and post-process the output to strip the misplaced child shapes, which still hides an embedded sub-process's children from a viewer and is more code than the hint for a worse result +- Compute sub-process bounds and child placement manually, bypassing the layout library for nested containers, which reintroduces the custom layout code this decision exists to avoid for one case and is an open-ended burden to keep visually consistent with the library + +## Decision Outcome + +Chosen option: auto-layout on export with a pre-seeded expansion hint, and DI discarded on import, because a textual DSL has no meaningful way to represent or preserve graphical coordinates, and `bpmn-auto-layout` (the bpmn.io layout library) produces adequate diagrams from the process content alone. + +`irToXml` computes no coordinates. +It serializes DI-less BPMN XML and hands it to `bpmn-auto-layout`, which lays out every element and injects the `bpmndi:` diagram-interchange data. + +Fed DI-less XML containing a `bpmn:subProcess`, the library does not throw, and it does not produce a degraded-but-sane result either: it renders the sub-process as a collapsed box and scatters shapes for its nested children into the root plane, at coordinates that duplicate unrelated top-level elements. +The output parses and validates, so nothing signals the problem, while a viewer opening the generated diagram shows the sub-process's children floating on top of sibling top-level elements instead of nested inside their parent. +The library reads an existing shape's `isExpanded` flag before computing bounds, and simply had nothing to read when no shape pre-existed. +Supplying that one boolean per sub-process is the smallest input that makes it take the branch it already has for expanded containers. + +Before calling `moddle.toXML`, `irToXml` walks the built moddle tree for every `bpmn:SubProcess` element at any nesting depth. +Only when at least one exists, it attaches a `bpmndi:BPMNDiagram` holding a `bpmndi:BPMNPlane` rooted at the process, which in turn holds one `bpmndi:BPMNShape` per sub-process, each carrying `isExpanded="true"` and a `bpmnElement` reference to its sub-process. +No `dc:Bounds` are supplied, since the library recomputes and discards any bounds on a pre-existing shape and derives correct ones from the expanded layout. +What it does need, and what the shape carries for no other reason, is an `id`: it locates a pre-existing shape by looking it up in its own id-keyed element index while parsing the hinted XML, so a shape without one is invisible to it even though nothing in the final output ever references that id back. +`bpmn-auto-layout` then discards every pre-existing diagram (`this.diagram.diagrams = []`) after reading the `isExpanded` flags off it and regenerates the diagram it actually serializes, so the hint is a one-shot instruction rather than persisted diagram data. + +### Consequences + +- Good, because the DSL syntax stays focused on process semantics and carries no coordinates. +- Good, because the layout library produces consistent, readable diagrams and computes every bound and waypoint in the final document, with no coordinate math and no added dependency of ours. +- Good, because a process without a sub-process attaches no hint at all, so its output is byte-for-byte unchanged. +- Bad, because manually arranged layouts in imported BPMN files are lost after a round-trip. +- Bad, because the hint's correctness is pinned to one specific behavior of the installed `bpmn-auto-layout` version, namely reading `isExpanded` off an id-keyed pre-existing shape. + A library upgrade that changes how it detects expansion could regress nested layout without any XML-validity signal. +- Neutral, because users who need precise layout control can adjust the generated BPMN in a graphical editor after export. + +### Confirmation + +A regression tripwire in `ir-to-xml.test.ts` builds an IR whose sub-process has two or more children, runs it through the real `irToXml` with no mocks and the actual installed `bpmn-auto-layout`, and asserts every nested child's `bpmndi:BPMNShape` bounds fall strictly inside its parent sub-process's shape bounds. +The same suite pins two-level nesting (inner sub-process inside outer, inner children inside inner), exactly one `bpmndi:BPMNDiagram` in the final output rather than a duplicate of the hint, and that a sub-process-free process's output stays byte-identical to the frozen golden. + +## More Information + +The hint is built by `buildSubProcessExpansionHint` and `collectSubProcessElements` in `packages/transform/src/ir-to-xml.ts`, called from `irToXml` right before serialization. +The IR itself carries no layout information (ADR-0007), since the hint is derived purely from the moddle tree built for export and never stored. + +When `bpmn-auto-layout`'s grid solver throws on a graph it cannot place, `irToXml` raises `LayoutError`. +It carries the document serialized just before the layout call, with no `bpmndi:` diagram. +The CLI and the extension catch it, write that diagram-less document, and warn the user instead of losing the output. +The engine deploys it unchanged: `BpmnParse.parseDiagramInterchangeElements` reads a diagram only when the document's `BPMNDiagram` list is non-empty, and a document with none skips diagram parsing and deploys on its process content alone. + +The layout as it stands has three known defects, each measured on the current tree. +The measurement walks every flow node for a shape, every sequence flow for an edge, and every edge segment for an activity shape it crosses that is neither its source nor its target. +First, a `goto` edge into a sub-process is routed across the sub-process's own children instead of around them. +The example `order-handling`'s `Flow_MarkAutoApproved_Payment` crosses both the `Payment` sub-process's start event and its first task. +The golden `boundary-events`' `Flow_MarkAddressVerified_PackGoods` crosses the `PackGoods` sub-process's start event and its first task the same way. + +Second, the spread of several boundary events on one host scales with the host's width rather than with the events' own. +The library places the `i`-th of `n` attachers at `host.x + (i + 1) * host.width / (n + 1) - 18`, all on the host's lower edge. +Two events on a 100 px task therefore sit 33 px apart at their centers while each is 36 px wide. +In the golden `boundary-events`, the two events on `CheckAddress` (x 625 to 725) span x 640.3 to 676.3 and x 673.7 to 709.7, overlapping by 2.7 px. +Two events overlap on any host narrower than 108 px, three times an event's width, and every further event narrows the spacing again; the two on the 850 px wide `PackGoods` sub-process land 283 px apart and clear each other. + +Third, a guard clause such as `if (chargeDeclined) { end BookingAbandoned }` keeps the join that only its default flow reaches (ADR-0031). +The layouter puts the end event in the cell between the split and that join on the same row, so the default edge runs straight through it. +The example `booking-attempt`'s `Flow_Gateway_booking-attempt_1_2_split_default` crosses `BookingAbandoned` this way, and the goldens `event-handlers` (`PaymentFailed`) and `transactions` (`BookingAbandoned`) carry the same shape. +A border-termination pass for the first defect and a boundary-spread pass for the second are both considered options, not committed work; the third goes away with the join elision ADR-0031 defers. diff --git a/docs/decisions/0009-dominator-based-restructuring.md b/docs/decisions/0009-dominator-based-restructuring.md deleted file mode 100644 index 070c533..0000000 --- a/docs/decisions/0009-dominator-based-restructuring.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -status: accepted -date: 2026-06-12 -decision-makers: Marlon Kranz ---- - -# Use Dominator/Post-Dominator Analysis for IR-to-DSL Restructuring - -## Context and Problem Statement - -The decompile direction (`irToDsl`) must turn a flat BPMN graph (the IR) back into structured BPMNscript source. -The graph may have come from a graphical modeler and may be structured, partially structured, or entirely unstructured (irreducible). - -How should `irToDsl` identify which subgraphs can be expressed as `if`/`while`/`parallel` blocks, and what should happen for subgraphs that cannot? - -## Decision Drivers - -- The reconstruction must be total: every valid IR must produce a valid DSL string and never throw, because the CLI `parse` command must always produce output -- Structured constructs (`if`/`while`/`parallel`) should be recovered where possible so the decompiled output is readable and round-trips without information loss -- The algorithm must handle AND fork/join pairs (parallel gateways), not just XOR -- Unstructured graphs (irreducible control flow, cross-branch gotos) must not crash the decompiler; they degrade to `goto` statements -- The logic must be isolated and separately testable, not entangled with IR types or the grammar - -## Considered Options - -- Dominator/post-dominator analysis with a fixed pattern catalog and `goto` fallback -- RPST (Refined Program Structure Tree) decomposition -- Ad-hoc recursive pattern matching without formal CFG analysis - -## Decision Outcome - -Chosen option: "Dominator/post-dominator analysis with a fixed pattern catalog and `goto` fallback". -Dominator analysis gives a mechanically checkable criterion for each structured construct, and most edges the catalog cannot fold degrade to `goto`. -A statement with more than one route out prints as an `if` chain, because a statement is one position with one way on and a jump written beside the fall-through would end the block, cutting off the chain that fall-through carries. -Every route gets a form: a condition heads its branch, and a route carrying none heads a branch as `true`, takes the chain's `else`, or runs straight into the join as the chain's fall-through. -Heading that last route instead would put a `true` over an empty branch and leave the rest of the chain unreachable. -Where a split degrades instead, its routes leave as jumps, and each jump takes a branch of such a chain, since a jump ends its block and a second one written beside the first could never run. -Some edges have no `goto` form at all. -An edge arriving at a gateway that still chooses between branches cannot be named, because a `goto` names a statement and a gateway has none, so the jump is only expressible through the gateway's successor and only while the routing has a single outcome. -An edge whose target the printer elides cannot be named either, the elided element leaving no statement behind for a jump to spell. -Such an edge is dropped, a marker comment is printed where it would have gone naming the element it led into, and `irToDsl` reports the drop on a warnings channel of its own. -The marker comment stays in the printed source, where it is the reader's pointer to the place needing repair. - -The pattern catalog: - -- XOR split with a post-dominating join -> `if`/`else if`/`else` -- Statement with more than one route out -> the same `if` chain, a conditioned route heading a branch and an unconditioned route running straight into the join taken as the chain's fall-through -- Unconditioned back-edge from a body exit to the XOR head dominating it -> `while`, the loop condition read from the head's edge into the body -- Conditioned back-edge from an XOR head to the body entry dominating it -> `do...while`, the loop condition read from the back-edge itself -- AND fork with a matching AND join -> `parallel { { } { } }` -- OR fork with a matching OR join -> the same `parallel` block with each conditioned branch headed by its condition, and the fallback flow heading a branch as `else`. - A fallback that runs straight into the join is left out where enough branches remain to fill the block, and prints as an empty `else` where dropping it would leave too few. -- Event-based gateway whose every outgoing flow reaches an intermediate catch event -> `await { ... }` with one branch per catch, continuing at the exclusive merge the branches share when they have one -- Every other edge -> `goto `, or a dropped-edge marker where the edge has no name to jump to - -A gateway a pattern folds is never printed, which is what keeps the round trip idempotent. - -### Consequences - -- Good, because the algorithm terminates and produces parseable DSL for every IR (total over the supported scope) -- Good, because AND fork/join pairs are recovered as `parallel` blocks without special-casing the decompiler -- Good, because the CFG analysis (`cfg-analysis.ts`) is a pure, stateless utility with its own test suite, so it can be audited independently of the emitter -- Good, because the restructurer reports what it drops instead of leaving the caller to find it, including where the drop changes what a recompiled document runs. - A fallback re-derived on an imported OR fork that named none is one such report: Operaton's `InclusiveGatewayActivityBehavior` throws a stuck execution when every branch of such a fork carries a condition and none of them holds, while the printed block falls through, so a document recompiled from that script runs on where the model would have stopped. - A split whose branches the catalog cannot fold is reported too, its edges written as jumps, and a jump ends the path it sits on where the split opened several. -- Neutral, because RPST would recover more structured patterns (for example nested switch-like gotos) but is left for later, once the scope justifies the added machinery -- Bad, because topology-based back-edge disambiguation (while versus do-while) requires checking the `conditionExpression` field, not just graph shape -- Bad, because the decompiler always produces parseable source but not always source that validates, and not every edge survives -- Bad, because a statement whose own routes split prints as an `if` chain, which takes one route where the model takes every route it can at once, so what a recompiled document runs changes; the print hop reports it - -### Confirmation - -`irToDsl` is verified total by the unit test suite (`packages/transform/test/`): every test input produces source and its warnings, and never throws. -The goto-degradation path is confirmed by `tests/golden/unstructured-goto.bpmn` in `tests/round-trip-constructs.test.ts`. - -## More Information - -The CFG analysis utility lives at `packages/transform/src/cfg-analysis.ts` and exposes `analyzeCfg(process): CfgAnalysis` with `immediateDominator`, `immediatePostDominator`, `dominates`, `postDominates`, `backEdges`, `outgoing`, and `incoming` queries. -`VIRTUAL_ENTRY` and `VIRTUAL_EXIT` constants give the dominator algorithm a unique single entry and exit. - -Amended by ADR-0036, which lets a bare authored terminal print inline in a guard clause when the split's own route is its only incoming flow, rather than staying a `goto`. - -Amended by ADR-0052, which continues a split whose every route is conditioned at the enclosing construct's stop node when a route stays inside it, so only the leaving routes print as jumps. - -Amended by ADR-0053, which prints a statement with more than one route out as the fork block the engine runs it as, keeps each route's condition on its jump where a fork or race degrades, under a marker line naming the split, and reports a split with no route out, a split routing back into itself, and a jump across a branch border. - -RPST decomposition is left for later. -It would recover more structured patterns, but the dominator-based catalog with a `goto` fallback already covers the current scope, so the added machinery is not yet justified. diff --git a/docs/decisions/0010-deterministic-structural-ids.md b/docs/decisions/0010-deterministic-structural-ids.md deleted file mode 100644 index f066d42..0000000 --- a/docs/decisions/0010-deterministic-structural-ids.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -status: accepted -date: 2026-06-12 -decision-makers: Marlon Kranz ---- - -# Use Deterministic Structural Ids for Synthesized BPMN Elements - -## Context and Problem Statement - -The `astToIr` desugarer gives an id to every BPMN element the DSL source leaves unnamed: the exclusive gateway pair enclosing an `if`/`else`, the loop-head gateway for `while`/`do...while`, the fork/join pair for `parallel`, the event-based gateway and exclusive merge for a multi-branch `await`, the catch event each `await` waits at, the boundary event a hosted handler attaches with, the event sub-process a host-less handler becomes, an `emit` or `throw` written without a name, and implicit start/end events. -The `irToDsl` restructurer also synthesizes flow ids when emitting the DSL. - -How should these synthesized ids be generated? -The options are random/UUID, a sequential counter, or a deterministic function of the element's structural position in the source. - -## Decision Drivers - -- Re-compiling the same source must produce the same BPMN XML; non-deterministic ids would make the output non-reproducible and break byte-comparison golden tests -- The round-trip normalizer (`tests/helpers/normalize-ir.ts`) must be able to recognize and re-key synthesized ids, which is only practical if the id scheme is stable and documented -- Sequential counters depend on traversal order, making ids sensitive to unrelated source changes -- Ids derived from structural position are self-documenting and survive refactoring of unrelated parts of the process - -## Considered Options - -- UUIDs (random, non-deterministic) -- Sequential counter (deterministic but traversal-order-sensitive) -- Structural coordinate: id derived from the enclosing element's position in the process body - -## Decision Outcome - -Chosen option: "Structural coordinate". -Deriving each id from the element's position in the source makes recompilation reproducible, keeps golden tests stable, and lets a reader trace a synthesized id back to the statement that produced it. - -The frozen id templates (from `packages/transform/src/synthesize-ids.ts`): - -| Template | Produced by | Example | -| --------------------------- | ------------------------------ | ----------------------------------------------------- | -| `Gateway__split` | `makeGatewaySplitId` | `Gateway_invoice-approval_2_split` | -| `Gateway__join` | `makeGatewayJoinId` | `Gateway_invoice-approval_2_join` | -| `Gateway__fork` | `makeGatewayForkId` | `Gateway_invoice-approval_4_fork` | -| `Gateway__race` | `makeGatewayRaceId` | `Gateway_invoice-approval_5_race` | -| `Gateway__loop` | `makeGatewayLoopId` | `Gateway_invoice-approval_3_loop` | -| `Flow__default` | `makeDefaultFlowId` | `Flow_Gateway_invoice-approval_2_split_default` | -| `Flow__` | `makeSequenceFlowId` | `Flow_ReviewInvoice_Gateway_invoice-approval_2_split` | -| `StartEvent_` | `makeStartEventId` | `StartEvent_invoice-approval` | -| `EndEvent_` | `makeEndEventId` | `EndEvent_invoice-approval` | -| `Throw_` | `makeThrowEventId` | `Throw_invoice-approval_2` | -| `EventSubProcess_` | `makeEventSubProcessId` | `EventSubProcess_invoice-approval_1` | -| `Catch_` | `makeIntermediateCatchEventId` | `Catch_order-handling_5_b0` | -| `Boundary__` | `makeBoundaryEventId` | `Boundary_ApprovePayout_timer` | - -The structural coordinate `` for a compound statement at body index `i` inside a process with id `P` is `P_i`. -For nested compounds the parent coordinate is prepended (`P_i_j`). -Branch segments distinguish sibling blocks within a compound: `_t` for the if-then block, `_e` (0-based) for else-if branches, `_e` for the else block, and `_b` for the branches of a `parallel` and of a multi-branch `await`. -Loop and sub-process bodies carry no segment, since a single block has no sibling to disambiguate against. -A sub-process body's enclosing coordinate is the sub-process's own ``, so gateways inside it stay positional. -Implicit start/end events synthesized inside a container are seeded from that container's id, the process id at the top level and the sub-process name inside a sub-process, resolved against one process-wide taken set so every id is document-unique. - -Collision resolution: if a base id is already in the taken set, append `_2`, `_3`, and so on until a free slot is found. - -Gateway ids skip the `taken`/`resolveCollision` guard, because the position-path scheme never generates the same id twice. -What collides with them is an author-chosen statement name matching a synthesized-id pattern. -The `checkReservedNames` validator in `packages/language/src/bpmn-script-validator.ts` rejects such names at validation time: `Gateway_*_(split|join|fork|loop|race)`, `StartEvent_*`, `EndEvent_*`, `Throw_*`, `EventSubProcess_*`, `Boundary_*`, `Catch_*`, and the two-segment `Flow_*_*` form (`/^Flow_.+_.+$/`). -Single-segment names such as `Flow_Control` stay legal, because synthesized flow ids occupy only `SequenceFlow.id` and never node names, so they cannot collide. - -A flow id collides without any reserved name being written, because the flow into a statement is `Flow__` and a split that names a fallback holds `Flow__default` back for it: an `if` chain's fallback, an inclusive fork's fallback, and either loop's exit. -An AND fork and a race reserve nothing, having no fallback to reserve for. -A statement named `default` in an earlier branch of such a split spells that string, and the document would ship two sequence flows under one id, which is not valid BPMN. -`reserveDefaultFlowId` (`packages/transform/src/ast-to-ir.ts`) claims the reserved string before any branch is lowered, so the author's flow takes the next free slot instead. - -### Consequences - -- Good, because re-compiling the same source always produces the same BPMN ids -- Good, because golden files remain stable across unrelated source changes -- Good, because the id scheme is self-documenting: reading a gateway id reveals its position in the source -- Good, because `synthesize-ids.ts` is a pure, dependency-free module with its own test suite -- Neutral, because the structural coordinate is longer than a UUID, making gateway ids verbose in deep nesting, mitigated by the fact that deeply nested processes are rare in the current scope -- Bad, because renaming a process id or reordering top-level statements changes all synthesized ids in that process, which breaks deployed BPMN definitions. - That is a concern for production use and acceptable for a DSL-authoring workflow where recompile is expected to replace the definition. -- Bad, because a start or end whose own id matches one of the reserved patterns is left out of the printed source wherever the printer writes no statement for it, and its label comes back as an import warning instead. - An element the printer does write a statement for keeps such an id verbatim, and the print reports it, because the compiler turns that name down when the script is read back. - This is the status quo rather than a change in behavior, and resolving it at the source would mean narrowing the reserved patterns or renaming such an element on decompile. - -### Confirmation - -The `synthesize-ids.test.ts` suite verifies every template and collision-resolution rule. -The round-trip normalizer (`tests/helpers/normalize-ir.ts`) uses the id patterns to re-key synthesized ids before comparing IR snapshots. - -## More Information - -The id templates are frozen, because they are consumed by `astToIr`, `irToDsl`, and the round-trip normalizer. -Any change requires updating all three consumers and regenerating the `invoice-approval-generated.bpmn` golden file. -The frozen contract is documented in the header of `packages/transform/src/synthesize-ids.ts`. - -Amended by ADR-0050, which narrows the reserved `StartEvent_*` and `EndEvent_*` patterns to the exact `StartEvent_` and `EndEvent_` the desugarer mints, and respells an id the script cannot spell on print. diff --git a/docs/decisions/0010-deterministic-synthesized-ids.md b/docs/decisions/0010-deterministic-synthesized-ids.md new file mode 100644 index 0000000..d2f8179 --- /dev/null +++ b/docs/decisions/0010-deterministic-synthesized-ids.md @@ -0,0 +1,124 @@ +--- +status: accepted +date: 2026-06-12 +decision-makers: Marlon Kranz +--- + +# Deterministic ids for synthesized elements, reserved and respelled + +## Context and Problem Statement + +The `astToIr` desugarer gives an id to every BPMN element the DSL source leaves unnamed: the exclusive gateway pair enclosing an `if`/`else`, the loop-head gateway for `while`/`do...while`, the fork/join pair for `parallel`, the event-based gateway and exclusive merge for a multi-branch `await`, the catch event each `await` waits at, the boundary event a hosted handler attaches with, the event sub-process a host-less handler becomes, an `emit` or `throw` written without a name, and implicit start/end events. +The `irToDsl` restructurer also synthesizes flow ids when emitting the DSL. +How should these ids be generated, and what does an author's own name have to stay clear of so that the compiler never mints a second element under an id the document already carries? + +The spelling of an author's id is the other half of the same question. +A BPMN id is an `xs:ID`, while the script's name slot is the `ID` terminal: letters, digits and `_`, a `-` between them, and no keyword. +An id such as `Task.1`, `user`, `Review-` or `WFP-6-` (the MIWG reference models) is a legal id the script cannot spell. +The Modeler names its default start `StartEvent_1`, and 25 of 44 real exported diagrams sampled from public example repositories carry that id, so whatever the validator reserves for a start decides whether a typical Modeler diagram imports intact. +Renaming such an element is not a fix the user can apply without cost: the engine keys history (`HistoricActivityInstance.getActivityId`), migration plans (`MigrationPlanBuilder.mapActivities`) and a modification (`InstantiationBuilder.startBeforeActivity`) on the activity id. + +## Decision Drivers + +- Re-compiling the same source must produce the same BPMN XML; non-deterministic ids would make the output non-reproducible and break byte-comparison golden tests. +- The round-trip normalizer (`tests/helpers/normalize-ir.ts`) must be able to recognize and re-key synthesized ids, which is only practical if the id scheme is stable and documented. +- Sequential counters depend on traversal order, making ids sensitive to unrelated source changes. +- Ids derived from structural position are self-documenting and survive refactoring of unrelated parts of the process. +- The import contract (ADR-0012): a document that deploys imports, and every change to what runs is reported. +- An activity id is the key the engine's history and migration match on, so a changed id is a changed process, and the report has to say so. +- One fact, one home: the shape of a name is the `ID` terminal, and the transform spells it once for both directions. + +## Considered Options + +- Structural coordinate: an id derived from the element's position in the process body, with the exact minted ids reserved against author names and an unspellable id respelled on print, matching exactly what the compiler mints so every legal document prints as source that recompiles unchanged +- UUIDs, random and non-deterministic +- A sequential counter, deterministic but traversal-order-sensitive +- Reserving a `StartEvent_` and `EndEvent_` prefix rather than the exact minted ids, and renaming a Modeler default start on import: one rule for both directions without a per-container lookup, but renaming the one element every Modeler diagram has changes most imports' activity id, and without the rename that start is either dropped from the script with its label and initiator or printed and refused on read-back +- Refusing every id the script cannot spell: nothing then prints under a name the model does not carry, but the MIWG reference models and any diagram with a dotted or hyphen-terminated id would not import at all, although the engine deploys them + +## Decision Outcome + +Chosen option: the structural coordinate with exactly reserved names. +Deriving each id from the element's position in the source makes recompilation reproducible, keeps golden tests stable, and lets a reader trace a synthesized id back to the statement that produced it. +Reserving the exact forms the compiler mints, and respelling an id the script cannot spell, keeps every document importable while reporting the one change it makes. + +The frozen id templates (from `packages/transform/src/synthesize-ids.ts`): + +| Template | Produced by | Example | +| --------------------------- | ------------------------------ | ----------------------------------------------------- | +| `Gateway__split` | `makeGatewaySplitId` | `Gateway_invoice-approval_2_split` | +| `Gateway__join` | `makeGatewayJoinId` | `Gateway_invoice-approval_2_join` | +| `Gateway__fork` | `makeGatewayForkId` | `Gateway_invoice-approval_4_fork` | +| `Gateway__race` | `makeGatewayRaceId` | `Gateway_invoice-approval_5_race` | +| `Gateway__loop` | `makeGatewayLoopId` | `Gateway_invoice-approval_3_loop` | +| `Flow__default` | `makeDefaultFlowId` | `Flow_Gateway_invoice-approval_2_split_default` | +| `Flow__` | `makeSequenceFlowId` | `Flow_ReviewInvoice_Gateway_invoice-approval_2_split` | +| `StartEvent_` | `makeStartEventId` | `StartEvent_invoice-approval` | +| `EndEvent_` | `makeEndEventId` | `EndEvent_invoice-approval` | +| `Throw_` | `makeThrowEventId` | `Throw_invoice-approval_2` | +| `EventSubProcess_` | `makeEventSubProcessId` | `EventSubProcess_invoice-approval_1` | +| `Catch_` | `makeIntermediateCatchEventId` | `Catch_order-handling_5_b0` | +| `Boundary__` | `makeBoundaryEventId` | `Boundary_ApprovePayout_timer` | + +The structural coordinate `` for a compound statement at body index `i` inside a process with id `P` is `P_i`. +For nested compounds the parent coordinate is prepended (`P_i_j`). +Branch segments distinguish sibling blocks within a compound: `_t` for the if-then block, `_e` (0-based) for else-if branches, `_e` for the else block, and `_b` for the branches of a `parallel` and of a multi-branch `await`. +Loop and sub-process bodies carry no segment, since a single block has no sibling to disambiguate against. +A sub-process body's enclosing coordinate is the sub-process's own ``, so gateways inside it stay positional. +The implicit start and end of a container are seeded from that container's id, the process id at the top level and the sub-process name inside a sub-process, and a boundary escape's end is `EndEvent_`; all of them resolve against one process-wide taken set so every id is document-unique. +If a base id is already in the taken set, the resolution appends `_2`, `_3`, and so on until a free slot is found. + +Gateway ids skip the `taken`/`resolveCollision` guard, because the position-path scheme never generates the same id twice. +What collides with them is an author-chosen statement name matching a synthesized-id pattern. +The `checkReservedNames` validator in `packages/language/src/bpmn-script-validator.ts` rejects such a name at validation time. +It reserves by prefix `Gateway_*_(split|join|fork|loop|race)`, `Throw_*`, `EventSubProcess_*`, `Boundary_*`, `Catch_*`, and the two-segment `Flow_*_*` form (`/^Flow_.+_.+$/`). +Single-segment names such as `Flow_Control` stay legal, because synthesized flow ids occupy only `SequenceFlow.id` and never node names, so they cannot collide. +For a start and an end it reserves, for each statement, the exact `StartEvent_` and `EndEvent_` of the container `c` whose body holds it: the process, or the nearest `subprocess` or `attempt` block. +The message says the compiler generates that id for the container's implicit start or end. +`StartEvent_1` is an ordinary name. +A handler body's own implicit start and end, and a boundary escape's end, are minted off an id no author writes, so the validator reserves those three forms by prefix instead: `StartEvent_EventSubProcess_`, `EndEvent_EventSubProcess_` and `EndEvent_Boundary_`. + +A flow id collides without any reserved name being written, because the flow into a statement is `Flow__` and a split that names a fallback holds `Flow__default` back for it: an `if` chain's fallback, an inclusive fork's fallback, and either loop's exit. +An AND fork and a race reserve nothing, having no fallback to reserve for. +A statement named `default` in an earlier branch of such a split spells that string, and the document would ship two sequence flows under one id, which is not valid BPMN. +`reserveDefaultFlowId` (`packages/transform/src/ast-to-ir.ts`) claims the reserved string before any branch is lowered, so the author's flow takes the next free slot instead. + +The printer recognizes a minted start or end by the same exact forms, plus `EndEvent_` for a boundary in the same container and the `Throw_` prefix an unnamed throw lowers to. +Only those are left out, since the compiler re-derives them; a start or end under any other id prints with its label, its documentation and its initiator. + +The printer builds one map per print, from the process id and every element id that prints, to the name each is written under. +An id the `ID` terminal accepts maps to itself. +Any other maps to the id with every non-word character replaced by `_`, a leading `_` where the result would open on a digit or read as a keyword, resolved against every id in the document and every name minted before it. +Every site that writes an id reads the map: the process head, every statement head, a `goto`, a boundary's host, and a named throw or catch. +A gateway, a boundary and an event sub-process never write their id and keep it. +Each rename draws one `renamedId` warning naming the printed name and saying that the rebuilt document carries it as the activity id. + +A form field id or an `operaton:inputParameter`/`operaton:outputParameter` name outside the terminal refuses the import. +The engine sets the variable under that exact name (`FormFieldHandler.handleSubmit`, `InputParameter.execute`, `OutputParameter.execute`), so a minted name would change which variable is set rather than how it is written. + +### Consequences + +- Good, because re-compiling the same source always produces the same BPMN ids, and golden files stay stable across unrelated source changes. +- Good, because the id scheme is self-documenting: reading a gateway id reveals its position in the source. +- Good, because `synthesize-ids.ts` is a pure, dependency-free module with its own test suite. +- Good, because a Modeler default diagram imports, prints and rebuilds with its start's id, label and initiator intact, and a MIWG reference model prints as source that parses and validates, with one report per id it could not keep. +- Neutral, because the structural coordinate is longer than a UUID, making gateway ids verbose in deep nesting, mitigated by the fact that deeply nested processes are rare in the current scope. +- Bad, because renaming a process id or reordering top-level statements changes all synthesized ids in that process, which breaks deployed BPMN definitions. + That is a concern for production use and acceptable for a DSL-authoring workflow where recompile is expected to replace the definition. +- Bad, because a respelled id is a changed activity id in the rebuilt document, so a process rebuilt from such a script is not the process the history and migration APIs know; the warning says so, and the remedy is a rename in the model. +- Bad, because a statement written under another container's minted id, such as `user EndEvent_S` beside `subprocess S`, is legal and makes the compiler mint `EndEvent_S_2` for `S`, which the printer then writes as an ordinary `end EndEvent_S_2`; the round trip stays stable from the second print on, and nothing runs differently. + +### Confirmation + +`packages/transform/test/synthesize-ids.test.ts` verifies every template, the collision-resolution rule, and the exact predicates for a minted start and end. +The reserved-name table in `packages/language/test/validating.test.ts` pins the exact rule per container and the freed `StartEvent_1`. +The respelling table in `packages/transform/test/ir-to-dsl.test.ts` prints every id site under a minted name, asserts the whole warning list, and re-parses and validates the source; the Modeler-default row asserts the printed start, end and initiator with no warning. +The refusal matrix in `packages/transform/test/xml-to-ir.test.ts` pins the two import refusals. +The round-trip normalizer (`tests/helpers/normalize-ir.ts`) uses the id patterns to re-key synthesized ids before comparing IR snapshots, and the golden pair suites pass unchanged. + +## More Information + +The id templates are frozen, because they are consumed by `astToIr`, `irToDsl`, and the round-trip normalizer. +Any change requires updating all three consumers and regenerating the `invoice-approval-generated.bpmn` golden file. +The frozen contract is documented in the header of `packages/transform/src/synthesize-ids.ts`, which also holds the exact predicates and the minting scheme. +The name map is built by `printedNames` in `packages/transform/src/ir-to-dsl.ts`; the per-container check is `checkReservedNames` in `packages/language/src/bpmn-script-validator.ts`; the two import refusals go through `refuseUnspellableVariable` in `packages/transform/src/xml-to-ir.ts`. diff --git a/docs/decisions/0011-structured-grammar-one-bracket-shape.md b/docs/decisions/0011-structured-grammar-one-bracket-shape.md new file mode 100644 index 0000000..6db3cf0 --- /dev/null +++ b/docs/decisions/0011-structured-grammar-one-bracket-shape.md @@ -0,0 +1,110 @@ +--- +status: accepted +date: 2026-06-12 +decision-makers: Marlon Kranz +--- + +# A structured, code-like grammar with one bracket shape + +## Context and Problem Statement + +A BPMN DSL can expose its surface syntax in two broad shapes. +One is a flat declaration-plus-edges form: every flow node (`start`, `user`, `gateway`, `service`, `end`) is declared at the top level, and explicit `->` edges (with `when:`, `as:`, `default:`) connect them. +This maps directly to the BPMN graph model, but it reads like a diagram description rather than code, and offers little advantage over editing BPMN XML for developers who think in control-flow terms. +The other is a structured, code-like language with implicit sequence flow and block-scoped control statements. + +Inside a structured surface the same question returns one level down. +An element carries an engine setting, a type, a label, a trigger payload and, for a container, a body. +If the spelling of a value follows from where the value sits rather than from the shape of the element, an author has to memorize a rule per position, and the printer has to choose one per position instead of emitting one form. +A delimiter that carries two unrelated jobs has the same effect on a reader: a brace that holds engine settings on a task and the flow inside a subprocess cannot be told apart without reading its contents. + +Which surface should BPMNscript use, and which shape should every element in it share? + +## Decision Drivers + +- The thesis goal is a textual DSL that serves developers who prefer working in code. +- IDE support, meaning inline errors, type-aware validation, and jump-to-definition, is far richer for a real expression AST than for opaque condition strings. +- Authoring a flat graph requires the same mental model as a BPMN diagram; a structured language is closer to how developers already write sequential logic. +- The round-trip direction (BPMN XML to DSL) needs a `goto`-capable fallback for unstructured graphs, and the structured surface accommodates this naturally. +- A JUEL-subset expression sub-language parsed to a real AST is needed to enable type-check diagnostics on condition expressions. +- One rule an author can derive the next line from, rather than several rules indexed by position. +- A delimiter that says what kind of content it holds, so settings and flow are distinguishable before they are read. +- No new reserved words, since the grammar owns the vocabulary in one place. +- A shape the printer can emit mechanically, because every construct round-trips through `ir-to-dsl`. +- Diff behaviour, since a process under version control is edited attribute by attribute. + +## Considered Options + +- Structured code-like syntax with brackets and blocks, where keyword and braces carry implicit flow and control statements (`if`/`while`/`parallel`/`goto`), `( )` carries what an element is, and `{ }` carries what runs inside it +- A flat node/edge syntax, explicit declarations and `->` edges mapping 1:1 to the BPMN graph, which needs the same mental model as a diagram and leaves conditions as opaque strings with no expression AST behind them +- Hybrid: named blocks for common patterns, with a fallback to explicit edges +- Colon configuration, where `:` introduces every detail, type, value and label alike, needing no per-position rule but wordier, since every label becomes its own `label:` line, and still leaving the brace carrying two jobs +- Keyword prose, where small words such as `is`, `means`, `assigned` and `catching` replace the punctuation, reading close to English but trading punctuation the author can see for vocabulary to recall, and adding reserved words to a grammar kept structured on purpose +- Off-side, where indentation carries the structure and every detail is a `key: value` child, using the fewest separators but making whitespace meaningful, so a misindented paste changes the model and the printer's indentation on the way back is a risk in every generated file +- Decorators, where each attribute is its own `@name value` line above a bare `keyword id`, keeping diffs clean but read bottom-up, with the configuration before the element it belongs to +- Binding form, where every element is written as an assignment, `Id = kind(name = value)`, closest to the data and a builder API but reading like configuration rather than flow, where `if` and `on` stand out as foreign + +## Decision Outcome + +Chosen option: structured code-like syntax with brackets and blocks, because it matches how developers already write sequential logic, and because the two delimiters are what make a shape carry the distinction a position-indexed spelling fails to make. +Conditions become a real expression AST, which is what makes type-check diagnostics possible; `goto` slots into the same surface as the decompilation fallback for unstructured BPMN graphs. + +The shape is `verb Id(attributes) { children }`. +`( )` holds the scalar settings that describe the element. +`{ }` holds everything with internal structure, which means the flow inside a container and the member lists on a step. +An element with no members has no brace, so `user Review(label: "Review the order", assignee: "demo")` is a complete statement, and an element with no settings has no parens. +It adds no reserved words, and the `:` inside the parens is the separator the form block and the `var` declaration already use. + +### What the shape alone does not decide + +A handler keeps the colon after its host, as in `on Pack: error(OUT_OF_STOCK, code: c)`. +Dropping it makes a hostless handler carrying a code and a hosted handler carrying none both read as `on` followed by two identifiers. +No lookahead separates those, because the trigger words are soft identifiers and the host is a cross-reference the parser cannot resolve while it is parsing. +The same colon settles the same question for `throw` and `emit`. + +A trigger keeps its own slot, so an event-bearing element reads `verb Id trigger(payload, settings) { children }`. +The payload is positional, since `TriggerPayloadRule` gives every trigger at most one code, and a key would spell that code's role twice. + +Form blocks, listeners, io parameters and call mappings live in the braces rather than the parens, because those four are lists with their own members, the same half of the shape as flow. +This is why `{ }` is defined above as everything with internal structure rather than as what runs: a form does not run, and forcing it into a parenthesised value would nest a listener's own settings two levels deep. + +A setting with no value is written as a bare word inside the parens, as in `timer("PT2H", alongside)`. +The parens therefore hold a payload, then keyed settings, then bare flags, told apart by whether a `:` follows. + +### Consequences + +- Good, because the DSL reads and writes like program code (`if`/`while`/`parallel`), and an author who has seen one element can write any other. +- Good, because conditions are a first-class expression AST, enabling type-check validation and jump-to-definition for variable references. +- Good, because `parallel { { } { } }` maps directly to AND fork/join pairs, making parallel-gateway support natural to author. +- Good, because `goto` as a residual form keeps decompilation total: every valid BPMN graph has a valid DSL representation. +- Good, because the printer emits one shape rather than choosing a spelling per position, which removes a class of difference between an authored file and a generated one. +- Good, because a `{` opens children and nothing else, so the delimiter alone says whether a construct nests. +- Bad, because settings lists are longer and wrap. +- Bad, because `user Review(...)` reads like a function call rather than a step. +- Bad, because the desugaring (`astToIr`) must synthesize gateway pairs from block structure, adding complexity relative to a flat pass-through that mirrors the graph directly. +- Bad, because a bare timer payload means a duration, so `timer("P1D")` and `timer(every: "P1D")` differ by a word while their strings look identical. +- Bad, because an event binding written `code: c` is not distinguishable by shape from a setting, so the rule that a binding declares a variable while a setting reads one lives in the validator rather than in the grammar. +- Bad, because a lone identifier in parens is a payload rather than a flag, which `condition(ready)` needs an ordering rule to resolve rather than a shape. +- Bad, because a flag is only bare to the reader: `alongside`, `sequentially` and `local` are keywords and never lex as identifiers, so the grammar names the legal flags rather than accepting any word in that position. +- Bad, because the four TextMate grammars cannot import the vocabulary, so each needs a test that fails when it drifts. + +### Confirmation + +The construct round-trip idempotence test (`tests/round-trip-constructs.test.ts`) verifies that `if`/`else`, `while` and `parallel` survive `astToIr -> irToXml -> xmlToIr -> irToDsl` without losing structure. +The conditioned `parallel` branch and the multi-branch `await` survive the same four hops in the frozen pair `tests/golden/branch-and-race.bpmnscript` and the BPMN generated from it. +The goto-degradation path is exercised by `tests/golden/unstructured-goto.bpmn` in the same suite. +Neither the conditioned branch nor the multi-branch `await` spent a reserved word, so the grammar header's claim that there is no `gateway` keyword and that every gateway is derived from a control-flow construct holds word for word. + +Every construct prints through `ir-to-dsl` and re-parses through the compiler, so a shape the printer emits but the parser rejects fails the suite rather than reaching a file. +The golden fixtures under `tests/golden` and the examples under `examples/spring-boot/processes` compile in the suite, so the surface is confirmed by the same gate that confirms the language. + +A handler and a listener sit in the same brace, and what separates them is the handler's own body block, which is mandatory where a listener has none. +The word after `on` plays no part in it, so the parse stays unambiguous even if a trigger word and a listener event ever collide. +Such a collision would still leave `on ` meaning two things to a reader and to the validator, so the two vocabularies are held disjoint by a test rather than by the grammar. + +The TextMate keyword alternation must stay equal to the grammar's reserved words, since three of the four grammars are generated or copied and are gitignored, which makes a stale artifact invisible in review. + +## More Information + +The JUEL-subset boundary, meaning what parses natively and what falls back to `"${...}"`, is fixed by the grammar's expression sub-rules in `packages/language/src/bpmn-script.langium` and mirrored by the hand-rolled parser in `packages/transform/src/juel.ts`. +The completion provider offers at a caret only what the validator accepts there, and `packages/language/test/completion.test.ts` holds that with a table that inserts every offered item and validates the result. diff --git a/docs/decisions/0012-embed-java-delegate-code-and-generate-operaton-application.md b/docs/decisions/0012-embed-java-delegate-code-and-generate-operaton-application.md deleted file mode 100644 index e5ba0c5..0000000 --- a/docs/decisions/0012-embed-java-delegate-code-and-generate-operaton-application.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -status: rejected -date: 2026-07-04 -decision-makers: Marlon Kranz -consulted: thesis supervisor ---- - -# Embed Java Delegate Code and Generate the Operaton Application - -## Context and Problem Statement - -The thesis supervisor proposed a deploy feature: write the body of a `JavaDelegate`'s `execute` method directly in the `.bpmnscript` file, generate the complete Java class from it, scaffold the surrounding Operaton application (build configuration, Spring Boot wiring, resources), and generate tests for the result. - -Today the toolchain stops at BPMN XML. -`bpmns build` compiles a `.bpmnscript` file to a `.bpmn` file, `bpmns parse` decompiles it back, and `service X(class: "...")` carries only a class-name string (emitted as `operaton:class`). -Delegate classes are hand-written in a separate host application, as in `examples/spring-boot/`. - -Should the compiler's output boundary move from BPMN XML to a complete, runnable Operaton application? - -## Decision Drivers - -- Bidirectional conversion between DSL and BPMN XML is a core property of the tool. - BPMN XML has no representation for Java source, so embedded method bodies are lost on compile and cannot be recovered on decompile; the target format has no slot for the information. -- ADR-0006 keeps the IR a clean graph of process semantics, not a store for host-language code. - `JavaDelegate` bodies are Java source, and carrying them through the IR pierces that boundary. -- "Deploy" changes meaning. - BPMN XML alone hot-deploys through a single REST call (`POST /engine-rest/deployment/create`), but Java classes must be on the engine's classpath: every change to an embedded body forces a rebuild and restart of the host application. - The deployment unit becomes the application, not the model. -- A generated application scaffold is a long-lived support surface (dependency versions, upgrade path, build tooling) that exceeds the thesis time budget. - -## Considered Options - -- Embed Java method bodies in the DSL and generate delegate classes, a full Operaton application, and tests -- Keep BPMN XML as the compilation boundary; delegates remain hand-written in a host application - -## Decision Outcome - -The proposal is rejected; BPMN XML remains the compilation boundary. -Embedding Java gives up the two properties the design rests on: round-trippable conversion, and an IR that models process structure rather than host-language code. -It also turns deployment from a one-artifact REST call into an application build. -The scaffold's maintenance cost alone puts it outside the thesis scope. - -### Consequences - -- Good, because the existing round-trip guarantees are unchanged; no lossy construct enters the language. -- Good, because the DSL and IR stay free of host-language code per ADR-0006. -- Good, because a future deploy command stays a thin REST client, the mechanics the E2E test adapters (`tests/fixtures/adapters/`) already exercise. -- Bad, because delegates are written and kept in sync by hand; the `class: "..."` string is not checked against any Java source, so a mismatch surfaces only at engine runtime. -- Bad, because the one-file authoring experience the proposal aimed for is not available. - -## More Information - -One part of the proposal stands on its own: generating tests for the compiled `.bpmn` artifact. -Test generation consumes the compiler's existing output rather than adding Java source to the language, so none of the round-trip or IR concerns above apply. -It is set aside as possible later work, independent of this decision. diff --git a/docs/decisions/0012-honest-bpmn-import.md b/docs/decisions/0012-honest-bpmn-import.md new file mode 100644 index 0000000..f4d0362 --- /dev/null +++ b/docs/decisions/0012-honest-bpmn-import.md @@ -0,0 +1,101 @@ +--- +status: accepted +date: 2026-07-04 +decision-makers: Marlon Kranz +--- + +# Honest BPMN import: refusals, warnings, and the parse tiers + +## Context and Problem Statement + +`xmlToIr`'s docstring stated that silent semantic loss is impossible, while the transform dropped several kinds of content without any diagnostic: event definitions on start and end events, loop characteristics on tasks, whole collaborations, Operaton and camunda extension content, and lanes. +Some of those drops change what the imported process executes; others do not. +The import contract has to make good on its own claim: what should happen when `xmlToIr` meets content the IR cannot carry, and should every such case be treated the same way? + +A surface that answers from its own reading of the IR disagrees with `BpmnParse` in both directions, refusing files the engine deploys while carrying files it refuses, and it says nothing about what the engine does with what it drops. +Camunda Modeler's default export is the case that costs most: it writes a `bpmn:collaboration` with one participant for any diagram with a pool or a lane, an `isExecutable="false"` process for every empty pool, and `xmlns:camunda` on every file, which is also the shape of the Operaton invoice example and four MIWG reference files. +Document shapes the engine rejects before `BpmnParse` sees an element pass through instead, since moddle-xml and saxen read them where a validating parse does not. +`bpmn:documentation` has no place at all, which keeps the letter of the contract while working against everything the contract exists for. + +## Decision Drivers + +- The no-silent-semantic-loss claim must hold for content whose absence changes execution semantics: a dropped timer or a dropped loop is not a cosmetic loss. +- Not every unrepresentable construct is equally severe, and refusing content that causes no semantic loss, an extension attribute or a lane, would make the importer unusable on any file a real modeler exports, the Modeler's default output and the Operaton invoice example included. +- The engine's parser is the one reader whose verdict decides whether a document runs, so it is the one reference for where a tier sits, and a warning that names what the engine does with a dropped setting is the only form under which a reader can tell a cosmetic drop from one that changes a run. +- Whatever channel reports non-fatal drops must be impossible to ignore by accident, since a warning nobody reads is a silent drop. +- Consumers, the CLI and the VS Code extension, need one classification check for "is this an unsupported-construct refusal?" that does not enumerate every subclass by hand. +- One fact about the engine lives in one place, in the message or the comment beside the check, naming the method that reads it. + +## Considered Options + +- Two tiers drawn where `BpmnParse` draws them: refuse what the engine fails the deployment on, warn on what it deploys and this surface cannot spell. +- Refuse everything the IR cannot express, with no warning tier: nothing the engine runs is ever dropped and a listener on a gateway could not slip through, but it refuses every file a real modeler exports over content that costs the reader a warning rather than a run. +- Drop what the IR cannot carry in silence and correct the docstring to describe that. +- Draw the tiers from the surface's own reading of the IR and correct the wording of the drops alone. +- Refuse every setting the engine reads that this surface cannot spell. +- Refuse every collaboration and every multi-process document, on the reading that a pool means several linked processes: nothing is dropped, but the refusal blocks most real input on a shape the engine ignores, and its message called a lone pool "multiple linked processes". +- Write the pool back on export rather than warn that the round trip loses it: the frame would survive, but it needs a `bpmn:collaboration`, a participant shape whose bounds enclose every laid-out element, and a plane over the collaboration, none of which `bpmn-auto-layout` produces, for a frame the engine never reads. +- Read the camunda namespace through a second moddle package, or by rewriting the prefix in the document text: a second package would key every consumption table twice, and a prefix rewrite could hit a prefix spelled inside attribute text, where the URI cannot appear. +- Carry documentation as a list of `{ text, textFormat }` members per node, or rework the printer to give a setting a multi-line entry: nothing in the corpus writes two children on one element, so neither buys anything the single escaped string does not. +- Carry documentation on every element the transform touches rather than on every element that already carries a name. +- For BPMN's own resource roles, refuse the spelling, or carry both spellings in the IR and the script: the round trip would be byte-stable on the roles, but the language then has two spellings for one assignment and the IR two fields the engine merges into one. + +## Decision Outcome + +Chosen option: two tiers, drawn where `BpmnParse` draws them, because it is the only option consistent with the no-silent-semantic-loss claim that accepts exactly what the engine deploys and says what the engine does with what it drops. + +The rule has three clauses. +A shape `BpmnParse` fails the deployment on refuses, and the message names the method and quotes its sentence, since there is nothing that runs to write back. +A shape the engine deploys and this surface can spell is carried, with a warning where the spelling changes. +A shape the engine deploys and this surface cannot spell drops with a warning that names the method that reads it and says the document written back runs without it. +The one drop that refuses instead is a thrown message's result variable beside an `expression` binding, where the engine stores a value the throw has no slot for. + +A refusal throws a subclass of `UnsupportedConstructError` before any IR is produced, so there is never a partial IR, and a consumer classifies the whole family with a single `instanceof` check while each subclass still carries construct-specific metadata for a tailored message. +A warning is returned in a `warnings: ImportWarning[]` array alongside the IR, so `xmlToIr` returns `{ ir, warnings }` rather than a bare `BpmnProcess`, which makes the channel unignorable at the type level: every call site must destructure or explicitly discard `warnings`, where an optional collector parameter (`xmlToIr(xml, sink?)`) or a second `xmlToIrWithWarnings` function leaves it easy to skip. +What the engine deploys and then fails on at run time is carried as written, with a warning that the printed script draws an error there. + +No element leaves the transform unreported: whatever it cannot carry is named by the tag the document spells and by its own id, and attributed to the element it sat on, a construct being reported whole so that a dropped `bpmn:ioSpecification` names itself rather than each data input inside it. +What is dropped without a warning is the diagram interchange data ADR-0009 settles, a BPMN attribute the transform neither reads nor reports, such as a process's `processType` or `isClosed`, and an attribute in a foreign namespace written directly on a mapped BPMN element, which is where an editor parks its own bookkeeping; the same foreign attribute on an extension child the IR reads is reported. +`packages/transform/README.md` names every refusal class and every warning category with the conditions that reach it, and `docs/bpmn-coverage.md` places each BPMN element on one side of the boundary or the other. + +Three document-level shapes follow from the same rule rather than from any one element. +`selectProcess` imports the one process marked `isExecutable="true"` and pushes one warning per other process, since `parseProcessDefinitions` does not deploy those either. +A collaboration is diagram data to the engine, since `parseCollaboration` records a participant's `processRef` only so that `parseBPMNShape` can attach the pool's bounds and no method in `BpmnParse` reads a `bpmn:messageFlow`, so a pool warns rather than refuses. +The camunda namespace is read as the operaton one by replacing the URI in the document text before `moddle.fromXML`, with one warning per document: `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` falls back to the camunda URI only when the operaton lookup finds nothing, and moddle-xml binds a prefix to a package by URI rather than by spelling, so after the swap every `camunda:` element arrives typed `operaton:*` and every `camunda:` attribute reads through the one `operaton:` reader. + +BPMN's own resource roles on a user task import onto the Operaton assignment attributes, with one warning per role naming the rewrite, since `BpmnParse.parseTaskDefinition` reads both into the same assignee and candidate lists that `operaton:assignee`, `operaton:candidateUsers`, and `operaton:candidateGroups` fill. +The exported document carries the Operaton attributes alone, and the engine builds the same identity links from them. + +A single plaintext `bpmn:documentation` child imports onto one optional `documentation?: string`, carried wherever an IR node already carries a `name`, with its newline escaped so the printer's `Lines = string[]` model is untouched and Langium's default `convertString` is the exact inverse. +The body is carried verbatim, so a modeler's pretty-printed whitespace inside the element comes back as it was written rather than trimmed. +Tying documentation to the name means the positions that report it instead are already the positions a label is reported at: an element with no slot for a name has no slot for documentation either, so there is one rule rather than two. + +The contract covers two hops. +`irToDsl` reports in the same shape, returning `{ source, warnings }` for the reason `xmlToIr` returns `{ ir, warnings }`: a channel a caller can skip is a silent drop. +Some of what its warnings name merely drops, such as a gateway label no statement form can hold; some changes what a recompiled document runs, such as a fallback re-derived on an imported fork whose branches all carry conditions and that named none; and some is printed as the model spells it and draws an error when the source is read back, such as an `else` beside a branch that runs whatever the conditions do. +Every one of them is warned rather than refused, because a refusal has no meaning on this hop: the restructurer is total and always produces source. + +### Consequences + +- Good, because a document the engine deploys imports, among them the Modeler's default collaboration file, the Operaton invoice example, and the MIWG files with one executable process, and one the engine refuses is refused with the engine's own sentence rather than imported into a script that cannot deploy. +- Good, because every caller, the CLI, the VS Code extension, and the round-trip suite, surfaces both the refusal and the warning channel instead of only one or neither, and the shared `UnsupportedConstructError` base keeps classification to one `instanceof` check as new refusal categories are added. +- Good, because every warning on a setting the engine reads says which method reads it, so the reader knows whether the drop changes a run. +- Bad, because the engine's sentences are quoted in the importer and drift with the engine's wording. +- Bad, because a shape the engine deploys and then fails at run time, such as an empty listener `class` on a task, is carried into a script that draws an error at the step, which the reader has to repair by hand. +- Bad, because some warned items do bear on what runs, against the boundary this decision draws, among them a dropped `operaton:field`, which leaves the bound class without a value it was injected with, and `isExecutable="false"`, which imports as an executable process. + Refusing either would reject files that otherwise import cleanly, and carrying `isExecutable` through would mean an IR field, a serializer path, and a DSL surface for a flag this tool has no use for. +- Bad, because the round trip rewrites a document in places: the pool is gone, so a pooled diagram opens as a plain process, and a `bpmn:potentialOwner` comes back as `operaton:candidateGroups`. +- Bad, because an id the schema admits and the engine deploys, such as one with an umlaut, is refused until renamed, since moddle-xml cannot read it. +- Bad, because a handful of undeclared `operaton:` extension elements cannot be tied by `bpmn-moddle` to a specific owning element, so their warnings are attributed to the process id rather than the precise element. + +### Confirmation + +The refusal and warning tables in `packages/transform/test/xml-to-ir.test.ts` pin every documented row with the whole warning list or the refusal class and detail, including the document level, the `camunda: prefix alias` describe, the resource-role merge, and the quantity warning. +`tests/review-loop.round-trip.test.ts` and the invoice fixture in `tests/` pin the Modeler-shaped documents end to end, and `tests/e2e/forms-and-external-tasks.test.ts` deploys the re-export of a task assigned in BPMN's own words to a real engine and reads the identity links back. +The frozen `documentation` golden pair and a parameterized sweep per hop name every position that carries documentation and every position that reports it instead, so a position wired to neither branch fails the sweep rather than passing in silence. + +## More Information + +The refuse and warn boundary and the `ImportWarning` shape (`elementId`, `category`, `message`) live in `packages/transform/src/errors.ts` and `packages/transform/src/xml-to-ir.ts`, where the checks are `refuseDocumentShapes`, `refuseDroppedIds`, `refuseImports`, `selectProcess`, `warnCollaborationDrops`, `checkExclusiveGateways`, `checkStartEventCount`, `checkHandlerFlows`, `attachDefaultFlows`, `refuseIoMapping`, `onlyExtensionElement`, `resolveListenerBinding`, `readThrownMessageBinding`, and the `ENGINE_READS_ELSEWHERE` table behind `warnUnimportedSetting`; the namespace swap sits at the top of `xmlToIr`. +`PrintWarningCategory` and the warnings built beside it in `packages/transform/src/ir-to-dsl.ts` settle which reports the print hop makes and what each one costs. +The consumer-facing summary is the import contract section of `packages/transform/README.md`. diff --git a/docs/decisions/0013-target-users-without-bpmn-knowledge.md b/docs/decisions/0013-target-users-without-bpmn-knowledge.md deleted file mode 100644 index ba9eda5..0000000 --- a/docs/decisions/0013-target-users-without-bpmn-knowledge.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -status: accepted -date: 2026-06-30 -decision-makers: Marlon Kranz, Oliver Kopp ---- - -# Target Users Without BPMN Knowledge and Minimize Boilerplate - -## Context and Problem Statement - -ADR-0008 gave BPMNscript a structured, code-like surface aimed at developers who prefer working in code. -It left two questions open: how much BPMN literacy the language may assume, and how much required syntax is acceptable. -Keywords, settings, and event declarations could still presuppose familiarity with BPMN's element types and vocabulary, and the grammar could still demand explicit text where the compiler could supply a default. - -How much BPMN knowledge may the language assume, and how much required syntax (boilerplate) is acceptable? - -Decided in the supervision meeting of 2026-06-30. - -## Decision Drivers - -- BPMN-literate users already have a well-supported option: the graphical modeler. - The textual language is motivated by the other population, for whom BPMN vocabulary is a barrier rather than a help. -- Required syntax that carries no process information raises the entry barrier without paying for itself. -- ADR-0008's structured constructs already hide gateway mechanics behind `if`/`while`/`parallel`. - Without an explicit audience decision, future grammar work has no tiebreaker between "closer to BPMN" and "simpler for newcomers". - -## Considered Options - -- Assume BPMN literacy: mirror BPMN terminology and structure, keeping the text close to the XML it compiles to -- Assume general programming literacy but no BPMN knowledge, and minimize required syntax - -## Decision Outcome - -Chosen option: "Assume general programming literacy but no BPMN knowledge, and minimize required syntax". -Concretely, two rules bind future grammar decisions: - -1. No BPMN prerequisite. - Reading or writing a `.bpmnscript` file must not require knowing BPMN. - Keywords name what the user means (a step a person performs, a step the system performs, a decision), not the BPMN element behind it. -2. Defaults over declarations. - Whenever the compiler can infer or synthesize a detail, the grammar must not require the user to write it. - When two designs express the same process, the one with less required text wins. - -Implicit sequence flow (ADR-0008) and synthesized structural ids (ADR-0010) already follow both rules; this decision makes them binding for what comes next. - -### Consequences - -- Good, because the entry barrier drops: a newcomer can write a running process without first learning BPMN's vocabulary or diagram semantics. -- Good, because grammar discussions get a tiebreaker instead of re-arguing the audience each time. -- Bad, because hiding BPMN vocabulary makes the DSL-to-XML correspondence less obvious for BPMN-literate readers; documentation has to carry that mapping, and the README glossary is a start. -- Bad, because every default the grammar adopts is a rule the round trip must invert, synthesized on compile and elided on decompile, growing the mapping surface that ADR-0010 normalization already covers for ids. - -## More Information - -Related decisions: ADR-0008 (structured, code-like grammar) settled the surface shape. -ADR-0010 (deterministic structural ids) is the existing model for how a compiler-supplied default stays round-trippable. -The engine tuning keys (`asyncBefore`, `asyncAfter`, `exclusive`, `jobPriority`, `retryCycle`) and the listener block are engine-side settings outside the no-BPMN-knowledge promise: they name what Operaton does with a step, not a BPMN element, and a reader who never tunes the engine never writes them. diff --git a/docs/decisions/0048-mirror-the-engines-parse-time-rules-in-the-validator.md b/docs/decisions/0013-validator-mirrors-parse-time-rules.md similarity index 71% rename from docs/decisions/0048-mirror-the-engines-parse-time-rules-in-the-validator.md rename to docs/decisions/0013-validator-mirrors-parse-time-rules.md index 19a72aa..cb645d7 100644 --- a/docs/decisions/0048-mirror-the-engines-parse-time-rules-in-the-validator.md +++ b/docs/decisions/0013-validator-mirrors-parse-time-rules.md @@ -4,7 +4,7 @@ date: 2026-09-14 decision-makers: Marlon Kranz --- -# Mirror the engine's parse-time rules in the validator +# The validator mirrors the engine's parse-time rules ## Context and Problem Statement @@ -13,7 +13,7 @@ decision-makers: Marlon Kranz A handler written inside a block subscribes on that block, since the event sub-process it lowers to takes the block as its event scope (`parseEventDefinitionForSubprocess`), and a handler attached to the block subscribes on the block too (`parseBoundaryEvents`), so the two collide on one name. Every branch of an `await` block subscribes on the gateway (`parseIntermediateCatchEvent`), so two branches on one name collide as well. `addEscalationEventDefinition` refuses an escalation catch without a code beside one with a code when both are boundary events on one host or both event sub-processes in one scope. -`selectInitial` refuses a second plain or timer start on a process, a rule ADR-0034 chose not to mirror. +`selectInitial` refuses a second plain or timer start on a process. The validator keyed a handler by its host name and grouped handlers by syntactic container, so none of these collisions drew a diagnostic, and the deployment failed with an engine message naming a generated id. Three more facts sit beside those, silent rather than refused. @@ -33,9 +33,9 @@ Which of the engine's parse-time rules should the validator repeat, and how shou ## Considered Options -- Mirror each parse-time refusal in the validator and warn on each setting the engine never reads -- Leave the refusals to the engine and document them -- Mirror the refusals and refuse the unread settings too +- Mirror each parse-time refusal in the validator and warn on each setting the engine never reads. +- Leave the refusals to the engine and document them: nothing in this tool then restates a rule that can move, but the author meets the rule as a failed deployment rather than on the line that caused it. +- Mirror the refusals and refuse the unread settings too: an unread setting would be impossible to miss, but the document deploys and runs, so the refusal would reject a script the engine accepts. ## Decision Outcome @@ -48,14 +48,14 @@ A boundary on a repeated host subscribes on the multi-instance body, one scope a An escalation, error, or cancel catch is compared against catches of its own kind alone, and an escalation catch-all beside a coded catch of its kind is refused. The `on error` case stays refused as this surface's own rule, and its message says so: `addErrorEventDefinition` sorts the definitions and the runtime takes the first match. Two branches of one `await` block on one message or signal name are refused. -On a process, a second plain or timer start, a second message or signal start of one name, and a second condition start of one rendered condition text are refused; the two warnings ADR-0034 introduced stay. +On a process, a second plain or timer start, a second message or signal start of one name, and a second condition start of one rendered condition text are refused. A start inside a subprocess, an attempt block, or a handler body refuses `initiator` and a `form` block. `retryCycle`, `exclusive`, or `jobPriority` written without `asyncBefore` or `asyncAfter` of the same spelling family draws a warning saying it configures no job; `join*` pairs with `join*` and `run*` with `run*`. On a repeated step the step's own `retryCycle` and `exclusive` pair with `asyncBefore` and `asyncAfter`, the whole-loop job, while `runRetryCycle`, `runExclusive`, and `jobPriority` pair with `runAsyncBefore` and `runAsyncAfter`, the per-run jobs. `jobPriority` sits with the per-run keys because `parseActivity` swaps the scope to the multi-instance body before `createActivityOnScope` stores the priority, so it lands on the inner activity and prices each run's job. The body itself is created without one, and its job takes the process's priority. -A timer carrier is exempt for all three, since the timer job reads them off the event element (ADR-0047). +A timer carrier is exempt for all three, since the timer job reads them off the event element. `resultVariable` beside `topic` or `type` draws the same kind of warning, since nothing writes the variable. ### Consequences @@ -63,17 +63,8 @@ A timer carrier is exempt for all three, since the timer job reads them off the - Good, because a script that compiles now deploys where these rules are concerned, and the message names the scope and the engine method. - Good, because a setting that configures nothing is reported at the key that carries it. - Bad, because the validator restates engine rules that can move under a new engine version; each rule cites the method it mirrors so the drift can be checked. -- Neutral, because a script written before this decision with a nested start form, an unpaired job setting, or a `resultVariable` beside `topic` now draws a diagnostic where it drew none. +- Neutral, because a script with a nested start form, an unpaired job setting, or a `resultVariable` beside `topic` draws a diagnostic where it drew none. ### Confirmation -`packages/language/test/validating.test.ts` holds a row per rule: a handler inside a block beside a boundary on it per trigger, the repeated-host exemption, the escalation pairs both ways and the legal mix, two race branches on one name, each duplicate start kind, `initiator` and a form on a start in each of the three containers, each job setting key with and without its flag on each carrier, and `resultVariable` beside each binding. - -## More Information - -Amends ADR-0034, which declined to mirror `selectInitial`; a second plain or timer start is now an error. -Amends ADR-0041, whose "no `runJobPriority`" rule stands while the reason moves: `jobPriority` on a repeated step prices each run's job rather than the whole loop's. -Amends ADR-0042 and ADR-0043, under which `resultVariable` beside `topic` or `type` was accepted and ignored; it now warns. -Operaton behaviour was read from `BpmnParse.addEventSubscriptionDeclaration`, `parseEventDefinitionForSubprocess`, `parseBoundaryEvents`, `getMultiInstanceScope`, `parseIntermediateCatchEvent`, `addEscalationEventDefinition`, `addErrorEventDefinition`, `parseBoundaryCancelEventDefinition`, `selectInitial`, `parseScopeStartEvent`, `parseProcessDefinitionStartEvent`, `parseStartFormHandlers`, `parseAsynchronousContinuation`, `parseActivity`, `createActivityOnScope`, `parseServiceTaskLike`, and `DefaultFailedJobParseListener.parseActivity`. - -Related decisions: ADR-0019 (boundary events), ADR-0023 (host-less handlers as event sub-processes), ADR-0025 (the `await` block), ADR-0034 (several starts on a process), ADR-0040 (job settings on gateways), ADR-0047 (timer job settings on the element the engine reads). +`packages/language/test/validating.test.ts` holds a row per mirrored rule: a handler inside a block beside a boundary on it per trigger, the repeated-host exemption, the escalation pairs both ways and the legal mix, two race branches on one name, each duplicate start kind, `initiator` and a form on a start in each of the three containers, each job setting key with and without its flag on each carrier, and `resultVariable` beside each binding. diff --git a/docs/decisions/0014-honest-bpmn-import-contract.md b/docs/decisions/0014-honest-bpmn-import-contract.md deleted file mode 100644 index 75ccc63..0000000 --- a/docs/decisions/0014-honest-bpmn-import-contract.md +++ /dev/null @@ -1,159 +0,0 @@ ---- -status: accepted -date: 2026-07-04 -decision-makers: Marlon Kranz ---- - -# Honest BPMN Import: Refuse Unsupported Constructs, Warn on Non-Semantic Drops - -## Context and Problem Statement - -`xmlToIr`'s docstring stated that silent semantic loss is impossible, but the transform actually dropped several kinds of content without any diagnostic: event definitions on start/end events (timer, message, signal, error, terminate), loop characteristics on tasks (multi-instance and standard loop), whole collaborations (pools and message flows), Operaton/camunda extension attributes beyond `assignee`/`formKey`/`class`, and lanes. -Some of these drops change what the imported process executes; others do not. -The import contract needs to make good on its own claim: what should happen when `xmlToIr` encounters content the IR cannot carry, and should every such case be treated the same way? - -## Decision Drivers - -- The "no silent semantic loss" claim must hold for content whose absence changes execution semantics. - A dropped timer or a dropped loop is not a cosmetic loss. -- Not every unrepresentable construct is equally severe. - Refusing content that causes no semantic loss (an extension attribute, a lane) would make the importer unusable on any file a real modeler exports, since modelers routinely add such content. -- Whatever channel reports non-fatal drops must be impossible to ignore by accident; a warning nobody reads is equivalent to a silent drop. -- Consumers (the CLI, the VS Code extension) need one classification check ("is this an unsupported-construct refusal?") that does not have to enumerate every error subclass by hand as new ones are added. - -## Considered Options - -- Two-tier contract: refuse (throw) constructs that change execution semantics, warn (return) on constructs that do not -- Refuse everything the IR cannot express, with no warning tier -- Keep the current behavior (drop silently) and only correct the docstring to describe it - -## Decision Outcome - -Chosen option: "Two-tier contract: refuse constructs that change execution semantics, warn on constructs that do not", because it is the only option consistent with the "no silent semantic loss" claim while remaining usable on real Modeler-exported files, which routinely carry cosmetic extension content that a total-refusal contract would reject outright. - -Refused constructs throw a subclass of `UnsupportedConstructError` before any IR is produced, so there is never a partial IR, and the refusals include: - -- an event definition of a kind the event's position does not accept -> `UnsupportedEventDefinitionError` -- a multi-instance repetition the engine refuses to deploy, one this tool cannot write back unchanged, or one on an event handler, which its trigger enters rather than repeats -> `UnsupportedLoopCharacteristicsError` -- two or more executable processes -> `UnsupportedCollaborationError`; a pool or a message flow warns, and a document shape the engine refuses before it reads an element draws `UnsupportedDocumentError` (ADR-0049) -- an event of a supported kind carrying a shape the surface cannot express, such as an error throw with no code -> `UnsupportedEventFeatureError` -- a call activity with no `calledElement`, with a version binding the surface cannot pin, with a `calledElementTenantId`, which decides which tenant's copy of the called process runs, or with an `operaton:in`/`out` mapping in a shape the surface cannot spell -> `UnsupportedCallActivityError` -- an `operaton:formField` of a type the form surface does not map -> `UnsupportedFormFieldTypeError` -- an `operaton:constraint` the engine refuses to deploy or the surface cannot spell: an unregistered name, a `validator` or a bound with no `config`, or a name repeated on one field -> `UnsupportedFormFieldConstraintError` (ADR-0037) -- Operaton extension content in a shape the surface cannot write or the engine refuses to deploy, as ADR-0054 lists it -> `UnsupportedExtensionFormError` -- an unsupported flow-element kind (pre-existing) -> `UnsupportedElementError` -- an unsupported service-task execution form (pre-existing), an `operaton:type` outside `external`, `mail`, and `shell`, or a `mail`/`shell` type on a thrown message -> `UnsupportedServiceTaskFormError` (ADR-0042) - A mail or shell task missing a field its behaviour's own parse requires or naming a field its behaviour class does not declare draws the same error. - So does a shell task carrying a field as an expression, or spelling a flag outside `true`/`false` in any letter case. - A service, send, or business rule task without a decision reference that carries `operaton:resultVariable`, or the older `operaton:resultVariableName`, beside `operaton:class` or `operaton:delegateExpression` draws it too, since `BpmnParse.parseServiceTaskLike` fails the deployment (ADR-0043). -- a sequence flow's condition expression or a conditional event definition's condition carrying a `language`, which Operaton hands to a script engine rather than evaluating as the UEL expression this tool writes -> `UnsupportedConditionExpressionError` -- an `operaton:errorEventDefinition` on an external task carrying `errorRef` and no `expression`, which fails the deployment, or an `errorRef` naming an error root with no code -> `UnsupportedErrorMappingError` (ADR-0038; a definition with no `errorRef` is skipped and a dangling one imports as its text since ADR-0054) -- a user task carrying a `bpmn:humanPerformer` beside `operaton:assignee`, or more than one `bpmn:humanPerformer`, both of which Operaton refuses to deploy -> `UnsupportedAssignmentError` (ADR-0039) -- an event-based gateway carrying `operaton:asyncAfter`, which `BpmnParse.parseEventBasedGateway` refuses to deploy -> `UnsupportedEventFeatureError` (ADR-0040) - -Every refusal shares the abstract base `UnsupportedConstructError`, so a consumer classifies the whole family with a single `instanceof` check while each subclass still carries construct-specific metadata for a tailored message. - -Warned constructs are returned in a `warnings: ImportWarning[]` array alongside the IR. -`xmlToIr` now returns `{ ir, warnings }` rather than a bare `BpmnProcess`: - -- an Operaton or camunda extension attribute or element whose content the IR does not read. - The boundary is drawn per owner kind rather than by a list of names, so `operaton:assignee` is data on a user task and a reported drop on a service task. -- an external task's `operaton:taskPriority`, `operaton:properties`, or `operaton:errorEventDefinition` on a service, send, or business rule task bound by class, expression, delegate expression, decision, or a built-in mail or shell type, since `parseExternalServiceTask` alone reads them and no other binding reaches it (ADR-0038, ADR-0042) -- a shell flag the engine deploys and then reads as `false`, one spelled `TRUE` rather than `true`, carried as written with a warning that the printed script draws an error there (ADR-0042) -- on a form field, what the engine never reads: a `datePattern` off a `date` field, `operaton:value` children off an `enum` field, and a `config` on `required` or `readonly`. - What the engine deploys and then fails on is carried as written, with a warning that the printed script draws an error there. - That is a bound on a type its validator refuses, a bound whose `config` is not an integer, and a literal enum default naming no value. - A repeated enum value id is kept once, as the engine keeps it, and the warning names the rewrite (ADR-0037). -- lanes -- a `name` on an event handler, a boundary event, a throw, an emit, or an await, since none of those has a label slot in this tool's surface; and a `name` on a start or an end whose id is the one the compiler mints for its container (ADR-0050), which no script can spell back, so the statement that would have carried the label is left out whole -- a `bpmn:Message` or `bpmn:Signal` root that nothing in the imported process references, a receive task's `messageRef` included; and an error or escalation root carrying no code, which nothing can key it by. - An error or escalation root carrying a code imports as the declaration ADR-0030 gives it, whether or not anything raises it. -- `bpmn:documentation` on the definitions root, an event definition, an external task's `operaton:errorEventDefinition`, an Error, Escalation, Message, or Signal root, a multi-instance loop characteristics element, a sequence flow, or one of the three intermediate events with no label slot, since none of those has a node in the IR to hold it. - `bpmn:documentation` carrying more than one child on one element, or a child whose `textFormat` is not plaintext, warns the same way; a single plaintext child on any other element imports as the `documentation` ADR-0031 gives it. -- BPMN content on an element the transform touches that no reader reads: an artifact on a process or a sub-process (a `bpmn:textAnnotation`, its `bpmn:association`, a `bpmn:group`), a `bpmn:ioSpecification`, a `bpmn:property`, a data association, a `bpmn:auditing` or `bpmn:monitoring` block, and a resource role the engine reads nothing of: a `bpmn:performer`, a role with no formal expression, and any role on an activity other than a user task. - On a user task a `bpmn:humanPerformer` and a `bpmn:potentialOwner` import onto the Operaton assignment attributes instead, with a warning per role naming the rewrite (ADR-0039). -- a root element other than the process and the error, escalation, message, and signal roots the events resolve against, such as a `bpmn:category`, a `bpmn:dataStore`, a `bpmn:itemDefinition`, or a `bpmn:interface` -- an attribute written without a namespace that BPMN does not declare -- a BPMN-declared attribute the engine itself reads nothing of, an `instantiate="true"` or an `eventGatewayType` other than `Exclusive` on a wait with several branches, or a `startQuantity` or `completionQuantity` other than `1` on an activity (ADR-0039), so the imported process runs exactly as the source document does. - Each is reported only where the document set it away from the value it reads back as when nothing is written, which is why an exported gateway carrying `eventGatewayType="Exclusive"` warns about nothing. -- a `default` on a step naming a flow that does not leave the step, which `BpmnActivityBehavior.handleNoTransitions` finds nothing to take for; one naming a route out of the step is carried since ADR-0054 and printed as the fallback of the step's block (ADR-0053). -- a `bpmn:standardLoopCharacteristics` or a `bpmn:loopCondition` on any host, which Operaton parses and never dispatches on, so the step imports and runs once exactly as the source document runs it -- a `bpmn:dataObject`, a `bpmn:dataObjectReference`, or a `bpmn:dataStoreReference`, which Operaton replaces with its own variable mechanism and never reads -- `isExecutable="false"` on the process, which imports as an executable process regardless; an absent one warns the same way (ADR-0049) -- a `bpmn:manualTask`, which imports as a plain step, since `ManualTaskActivityBehavior` is a pass-through identical to a plain task. - The warning names the rewrite rather than the drop, because the engine stores the tag it parsed as the activity type and history reports `task` where the document said `manualTask`. - -No element leaves the transform unreported. -Whatever it cannot carry is named by the tag the document spells and by its own id, and attributed to the element it sat on, a construct being reported whole so that a dropped `bpmn:ioSpecification` names itself rather than each data input inside it. -The lane structure goes the same way, reported lane by lane, with anything hung on a lane or a lane set leaving with it. -What is dropped without a warning is the diagram interchange data ADR-0003 settles, and an attribute the transform does not read. -One shape of that is an attribute in a foreign namespace written directly on a mapped BPMN element, which is where an editor parks its own bookkeeping. -The same attribute on an extension child the IR reads is reported, and so is an attribute written in no namespace at all that BPMN does not declare, since no editor writes there. -The other is a BPMN attribute the transform neither reads nor reports, such as a process's `processType` or `isClosed`; a sequence flow's `name` warns since ADR-0054. - -Returning `{ ir, warnings }` makes the warnings channel unignorable at the type level: every call site must destructure or explicitly discard `warnings`, so a caller cannot silently drop the diagnostics this decision exists to guarantee. -The alternatives, an optional collector parameter (`xmlToIr(xml, sink?)`) or a second function (`xmlToIrWithWarnings`), leave the channel easy to skip. - -The contract covers two hops. -`irToDsl` reports in the same shape, returning `{ source, warnings }` for the reason `xmlToIr` returns `{ ir, warnings }`: a channel a caller can skip is a silent drop. -Its warnings carry the same fields and report what the print hop could not carry into the script. -Some of what they name merely drops, such as a gateway label no statement form can hold. -Some changes what a recompiled document runs, such as a fallback re-derived on an imported fork whose branches all carry conditions and that named none. -Some is printed as the model spells it and draws an error when the source is read back: an `else` beside a branch that runs whatever the conditions do leaves the fallback nothing to take, and the script it prints does not recompile at all. -Every one of them is warned rather than refused, because a refusal has no meaning on this hop: the restructurer is total by ADR-0009 and always produces source. -The reports themselves live in `packages/transform/src/ir-to-dsl.ts`, where `PrintWarningCategory` and the warnings built beside it settle which the print hop makes and what each one costs. -ADR-0025 is the decision that extended this contract to the print hop. - -### Consequences - -- Good, because every caller (the CLI, the VS Code extension, the round-trip test suite) now surfaces both the refusal and the warning channel instead of only one or neither. -- Good, because the shared `UnsupportedConstructError` base keeps consumer classification to one `instanceof` check as new refusal categories are added. -- Bad, because some warned items on the import hop do bear on what runs, against the boundary this decision draws, among them a dropped `operaton:field`, which leaves the bound class without a value it was injected with, and `isExecutable="false"`, which imports as an executable process; a step's `default`, once among them, is carried since ADR-0054. - Refusing any of them would reject files that otherwise import cleanly, and carrying `isExecutable` through would mean an IR field, a serializer path, and a DSL surface for a flag this tool has no use for, so they are reported instead. -- Bad, because every call site of `xmlToIr` had to migrate from `const ir = await xmlToIr(xml)` to `const { ir } = await xmlToIr(xml)`, a one-time, mechanical, but repo-wide edit. -- Bad, because a handful of undeclared `operaton:` extension elements cannot be tied by `bpmn-moddle` to a specific owning element; their warnings are attributed to the process id rather than the precise element. - -## More Information - -The exact refuse/warn boundary and the `ImportWarning` shape (`elementId`, `category`, `message`) are documented in `packages/transform/src/errors.ts` and `packages/transform/src/xml-to-ir.ts`; a consumer-facing summary is in `packages/transform/README.md`. - -Amended by ADR-0030, which narrows `unreferencedRoot` to the roots the surface still cannot hold. - -Amended by ADR-0031, which narrows the `bpmn:documentation` bullet to the positions the surface still cannot hold. - -Amended by ADR-0033, which lifts the refusal on a call activity's variable mapping and narrows the bullet to the shapes the surface still cannot resolve or write back. - -Amended by ADR-0036, under which the label bullet's "left out whole" holds for a start, and for an end only at its block's tail; anywhere else the end prints under its reserved id, label and all, and the print reports it. - -Amended by ADR-0037, which adds the form field constraint refusal and the form field drops, carries, and rewrite to the lists above. - -Amended by ADR-0038, which adds the error mapping refusal and the warning on an external task's extras under a binding the engine never reads them for. - -Amended by ADR-0039, which adds the assignment refusal, narrows the resource-role drop to the roles the engine never reads, adds the quantity attributes to the warned list, and removes `startQuantity` from the unreported examples. - -Amended by ADR-0040, which adds the event-based gateway refusal above and carries a gateway's five job settings instead of warning about them. -A listener found on a gateway still warns, and the warning names `BpmnParse.parseExecutionListenersOnScope`, which every gateway parser runs it through, since the surface has no position for it; an `operaton:inputOutput` there refuses, as `checkActivityInputOutputSupported` refuses to deploy it (ADR-0054). - -Amended by ADR-0041, which carries an async, exclusive, or retry setting found on a repetition's `multiInstanceLoopCharacteristics` element instead of refusing it, narrowing the `UnsupportedLoopCharacteristicsError` bullet above. - -Amended by ADR-0042, which adds the `UnsupportedServiceTaskFormError` and extras-warning detail above for a mail or shell task. - -Amended by ADR-0043, which adds a result variable beside a class or delegate expression binding to the `UnsupportedServiceTaskFormError` bullet above. - -Amended by ADR-0049, which narrows the `UnsupportedCollaborationError` bullet to two or more executable processes, warns per pool and message flow, adds `UnsupportedDocumentError` for the document shapes the engine refuses before it reads an element, reads the camunda namespace as the operaton one, and warns on an absent `isExecutable` as on a false one. - -Amended by ADR-0050, under which the label bullet's minted id is the exact `StartEvent_` or `EndEvent_` rather than a prefix, any other start or end prints under its id, an id the script cannot spell is respelled on print with a `renamedId` warning, and a form field id or an input/output parameter name the script cannot spell refuses. - -Amended by ADR-0051, under which the print hop declares `var : any` for every variable the printed script reads bare and nothing else declares, so a decompiled script builds without an undeclared-variable warning. - -Amended by ADR-0052, under which a split inside a loop body whose every route is conditioned walks the route that stays inside the body inline and prints the leaving routes alone as jumps. - -Amended by ADR-0053, under which a step's routes print as the fork block the engine runs them as, with the step's `default` as the `else`, a degraded split keeps its conditions on its jumps under a marker line, and an empty gateway, a self-loop, and a jump across a branch border each draw a print warning. - -Amended by ADR-0054, which moves the tiers above to where the engine's parse draws them: what `BpmnParse` fails the deployment on refuses naming the method, and what it deploys and this surface cannot spell warns naming the reader. -Under it the exclusive gateway shapes refuse as `UnsupportedGatewayShapeError`, and so do a condition typed through `xsi:type` as anything but `tFormalExpression`, a flow into or out of an event handler, a second plain or timer start, a repeated extension block, an io mapping on a start, a gateway, or a handler, and a priority that is not a number. -Listeners repeat per event, a step's `default` is carried, a thrown message's result variable refuses beside a class, delegate, or expression binding, and a dangling `errorRef` imports as its text. - -Related decisions: ADR-0006 (the shared IR, where `warnings` deliberately lives outside the IR, which stays serializable). -ADR-0007 (the Operaton moddle extension fork, whose declared and undeclared elements determine warning-attribution precision). -ADR-0003 (auto-layout for diagram interchange, the one kind of element this contract drops without a warning). diff --git a/docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md b/docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md new file mode 100644 index 0000000..8f5c3d6 --- /dev/null +++ b/docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md @@ -0,0 +1,138 @@ +--- +status: accepted +date: 2026-06-12 +decision-makers: Marlon Kranz +--- + +# Restructure the IR into a DSL with dominator analysis + +## Context and Problem Statement + +The decompile direction (`irToDsl`) must turn a flat BPMN graph (the IR) back into structured BPMNscript source. +The graph may have come from a graphical modeler and may be structured, partially structured, or entirely unstructured (irreducible). + +How should `irToDsl` decide which subgraphs can be phrased as `if`, `while`, `parallel` or `await` blocks, what form the routes it cannot fold take, and what happens to a region it cannot phrase at all? + +## Decision Drivers + +- The reconstruction must be total: every valid IR must produce a valid DSL string and never throw, because the CLI `parse` command must always produce output. +- Structured constructs should be recovered wherever the graph allows it, so the decompiled output is readable and round-trips without information loss. +- The analysis must handle AND fork and join pairs (parallel gateways), not just XOR. +- Unstructured graphs (irreducible control flow, cross-branch jumps) must not crash the decompiler; they degrade to `goto` statements. +- A jump is the fallback for what the catalog cannot fold, never the first answer for a shape it can fold. +- A report describes what runs differently and nothing else, so a print that routes the way the model routes draws none, and what the marker line and the warnings channel say has to be enough to start a hand repair, so a lost split names itself in the source. +- The page says what the graph does. + Statements run top to bottom and the flow between them is never written out, so a statement missing from the page is a flow the reader cannot see. +- The honest import contract (ADR-0012): what the round trip cannot carry is reported, never dropped without a word. +- The logic must be isolated and separately testable, not entangled with IR types or the grammar. + +## Considered Options + +- Dominator and post-dominator analysis with a fixed pattern catalog and a `goto` fallback: each construct gets a criterion that reads the graph rather than a heuristic, and the fallback is a form the language already has, so totality costs no new syntax. +- RPST (Refined Program Structure Tree) decomposition: a region tree would recover more structured patterns and would answer the continuation question for every construct at once, but the catalog with a `goto` fallback already covers the current scope. +- Ad-hoc recursive pattern matching without formal control-flow analysis: there is no criterion to check a match against, so an unstructured graph has no defined outcome and the decompiler is free to loop or to throw. +- An `if` chain for a step with more than one route out, and jumps carrying no conditions where a split degrades: the print of a common modeler shape, a task with two plain flows, then runs one route where the model runs both, and a degraded split's conditions are discarded with no marker for the reader to find. +- Refusing a step with more than one outgoing flow on import: the printer never sees the shape, but the engine deploys and runs it, and the import contract refuses only what the engine refuses or what changes a stored value. +- Keeping a split whose every route is conditioned as jumps wherever it sits: the print reports the drop and no rule is added, but a condition on every route with no default named is the common modeler shape, and the process the thesis demonstrates on came back running differently. +- Deferring the chain that holds an elided synthesized end to the last position in its block: it conflicts with the elided-start-first rule whenever one chain holds both, only one chain can be last where there are several elided ends, and printing the end in place covers every shape it would. + +## Decision Outcome + +Chosen option: dominator and post-dominator analysis with a fixed pattern catalog and a `goto` fallback, because dominator analysis gives a mechanically checkable criterion for each structured construct, and the edges the catalog cannot fold degrade to `goto` instead of to a failure. + +The analysis is `analyzeCfg(process): CfgAnalysis`, a pure function over the IR with `immediateDominator`, `immediatePostDominator`, `dominates`, `postDominates`, `backEdges`, `outgoing` and `incoming` queries. +`VIRTUAL_ENTRY` and `VIRTUAL_EXIT` give the dominator algorithm a unique single entry and exit. + +The pattern catalog: + +- XOR split with a post-dominating join -> `if`/`else if`/`else` +- Statement with more than one route out -> the fork block the engine runs it as, inclusive or parallel +- Unconditioned back-edge from a body exit to the XOR head dominating it -> `while`, the loop condition read from the head's edge into the body +- Conditioned back-edge from an XOR head to the body entry dominating it -> `do...while`, the loop condition read from the back-edge itself +- AND fork with a matching AND join -> `parallel { { } { } }` +- OR fork with a matching OR join -> the same `parallel` block with each conditioned branch headed by its condition, and the fallback flow heading a branch as `else`. + A fallback that runs straight into the join is left out where enough branches remain to fill the block, and prints as an empty `else` where dropping it would leave too few. +- Event-based gateway whose every outgoing flow reaches an intermediate catch event -> `await { ... }` with one branch per catch, continuing at the exclusive merge the branches share when they have one +- Every other edge -> `goto `, or a dropped-edge marker where the edge has no name to jump to + +A gateway a pattern folds is never printed, which is what keeps the round trip idempotent. + +`emitRoutes` asks where a split's routes come back together in four steps. +The clean join is the post-dominator the split dominates, refused when the enclosing construct's stop node does not post-dominate it, since inside a `do` body the body dominates every node behind the loop, so a join behind the loop would otherwise pass the dominance checks whenever a leaving route and the loop exit share an end. +`convergence` reads where the split's live routes come back together off the model's routes by forward reachability bounded by the stop node, and so answers a split whose branch can end and whose post-dominator therefore lies at the exit. +The guard clause answers when exactly one route is unconditioned, that route being the continuation; it admits a bare authored terminal as a branch entry when the split's route is the terminal's only incoming flow, so the terminal prints inside the `if` rather than as a `goto` with a trailing statement, and it admits a gateway the split dominates, which a jump could not name anyway. +The enclosing continuation is the stop node the enclosing construct handed down, and answers only while that node is defined, no route is unconditioned, and at least one route's target is dominated by the split and post-dominated by the stop node; the chain then walks that route inline, prints the routes that leave the region as jumps, and stops at the stop node where the enclosing construct carries on. +The same rule serves a split inside a `while` body, a `do` body and a branch, including a split inside a guarded branch. +Outside any enclosing construct the jumps stay, so a split at process level whose every route is conditioned prints as jumps, closing with no `else`, so the recompiled split gains a fallback into the join that the model never named, and the print reports it. +A loop gateway's own exit condition prints as the fall-through after the `} while (...)` line and is reported as dropped. +Every route of a folded exclusive split gets a form: a condition heads its branch, and a route carrying none heads a branch as `true`, takes the chain's `else`, or runs straight into the join as the chain's fall-through, since heading that last route instead would put a `true` over an empty branch and leave the rest of the chain unreachable. + +`followLinear` reads a step's routes off the model, and two or more hand the step to the fork printer as its split, since `BpmnActivityBehavior.performOutgoingBehavior` leaves a step by every flow whose condition holds or that carries none, and by the flow the step names as `default` only when none was taken. +The block is inclusive when any route carries a condition or the step names a `default`, the rule `InclusiveGatewayActivityBehavior.execute` applies to a gateway of that kind, and parallel otherwise, with the `default` as the block's `else`. +The one shape a choice routes the same way, exactly one weighed route beside the `default`, keeps the `if`/`else` the choice prints, which reads better and lowers to the same routing. +The fork printer is keyed on a split's id and kind rather than on a gateway, so a step's block takes the merge's `join` settings and no head settings of its own. + +A fork or race the catalog cannot fold prints one marker line naming the split and its kind, `split G degraded to jumps; was inclusive`, and then an `if` chain of jumps with each route's condition on its own jump and the split's fallback as the `else`, each jump taking a branch of that chain since a jump ends its block and a second one beside it could never run. +The report says the conditions are on the jumps and that the split's kind, whether it opened one branch, every branch whose condition held, or the first to resolve, is written nowhere. +An exclusive split with no clean join prints its jumps with no marker: an `if` chain of jumps is a choice, and an exclusive split takes the first route whose condition holds (`ExclusiveGatewayActivityBehavior.doLeave`), so nothing of it is lost. + +A gateway with no route out prints nothing and is reported by kind, the report naming what the model does there: a parallel gateway ends the run as the script's block end does (`ParallelGatewayActivityBehavior.execute` leaves by no route and `PvmExecutionImpl.leaveActivityViaTransitions` ends the execution), an inclusive gateway stops with an error (`InclusiveGatewayActivityBehavior.execute` throws `stuckExecutionException`), an event-based gateway waits forever with nothing to wait for, and an exclusive gateway keeps the model from deploying (`BpmnParse.validateExclusiveGateway`), which the import refuses ahead of the printer, so that last report covers hand-built IR alone. +The loop patterns ignore a back edge whose source is its target, so such a gateway prints as a choice whose route back into itself has no name and takes the dropped-edge marker, while its exit route keeps its condition. +An event sub-process a flow edge leads into is malformed IR and the printer throws, as it does on a duplicate id; the import refuses the flow ahead of it. + +Some edges have no `goto` form at all. +An edge arriving at a gateway that still chooses between branches cannot be named, because a `goto` names a statement and a gateway has none, so the jump is only expressible through the gateway's successor and only while the routing has a single outcome. +An edge whose target the printer elides cannot be named either, the elided element leaving no statement behind for a jump to spell. +Such an edge is dropped, a marker comment is printed where it would have gone naming the element it led into, and `irToDsl` reports the drop on a warnings channel of its own. +The marker comment stays in the printed source, where it is the reader's pointer to the place needing repair. +The branch walk records the `await` or `parallel` branch each statement is printed in, as the path of branch ids from the outermost block down, and every jump records the path it was printed under. +Once the walk is done, a jump whose target sits in a branch the jump's path does not begin with is reported, since the validator refuses a `goto` into a branch from outside it: a branch's steps run only when the whole block is reached. +The jump is written all the same; the model is where the route comes from. + +A plain end event whose id carries a synthesized prefix and holds nothing printable is left out only where it is its block's tail, at its block's own depth with no flow statement of the same block printed after it. +The compiler mints such an end in `lowerContainerBody` and `lowerBoundaryHandler`, both at the tail of a block, and never inside a branch or loop body, whose exit it wires to the join or the loop head instead, so only at a tail does reading the source back re-derive the same terminal. +`isElidedOnPrint` stays the model-level predicate and keeps answering "the printer may drop this end", which `warnRefusedStatements`, `forwardToRealTarget` and the importer's `warnElidedNamedDrop` read. +Whether the printer does drop the end is a fact about print order, known only once every chain of the block is on the page, so that half of the decision sits in the emitter: `emitNode` records the end with the position it would have printed at, and `Emitter.emit` then leaves out an end nothing followed and splices any other back at its position, with one report per printed end, in page order. +The printed source draws exactly one validator error, on the printed `end` line, and renaming the id in the model makes the source clean and round-trips the document structurally unchanged. +The import warning on a labelled plain end describes both outcomes, since the importer cannot know which position the printer will give an end: where the script can do without the end it is left out and its label with it, and anywhere else it prints under a name the script refuses. +Either way the fix it names is a rename in the diagram. + +### Consequences + +- Good, because the algorithm terminates and produces parseable DSL for every IR, total over the supported scope, and the fallback costs the language no new syntax. +- Good, because AND fork and join pairs are recovered as `parallel` blocks without special-casing the decompiler. +- Good, because a step with two plain routes or with a weighed route beside a plain one round-trips to the routing the engine runs, with no report. +- Good, because the conditions of a degraded split survive on its jumps, and the marker line puts the reader at the split rather than leaving the jumps to explain themselves. +- Good, because the fork printer, the invented-fallback report and the fallback-condition report each have one home, and a gateway and a step share it. +- Good, because the CFG analysis is a pure, stateless utility with its own test suite, auditable independently of the emitter, and the same dominator queries answer the clean join, the guard clause and the enclosing continuation. +- Good, because a split whose every route is conditioned inside a loop body prints its staying routes inline, so the Operaton invoice example prints its review call inside the loop and the rebuilt process keeps the loop. + A cascade of such splits inside one body prints as nested chains, each continuing at the same stop node, and the rule adds no state to the walk. +- Good, because the restructurer reports what it drops instead of leaving the caller to find it, including where the drop changes what a recompiled document runs. + A fallback re-derived on an imported OR fork that named none is one such report: `InclusiveGatewayActivityBehavior` throws a stuck execution when every branch of such a fork carries a condition and none of them holds, while the printed block falls through, so a document recompiled from that script runs on where the model would have stopped. +- Good, because a synthesized plain end that is not its block's tail is refused loudly instead of rewiring its chain into whatever the printer appended next: the reader finds a reserved id on the page, a print warning, and a validator error naming the element to rename. +- Neutral, because a route running straight into an enclosing construct's stop node prints as an empty branch, which the validator warns about; the condition on it survives, where a jump would have lost the branch. +- Neutral, because a step whose routes reconverge at a merge of another kind, or at a step, degrades to jumps under the marker: the block form synchronizes at a merge of its own kind, which the model does not have there. +- Neutral, because a jump into an elided end is still dropped and marked. + `forwardToRealTarget` reads the model-level answer, and at the top level the jump is printed before the end's position is known; for a plain end in the same container the re-derived tail end is the same terminal, so the process runs the same and the marker is the only cost. +- Neutral, because RPST would recover more structured patterns, for example nested switch-like gotos, but is left for later, once the scope justifies the added machinery. +- Bad, because topology-based back-edge disambiguation, `while` against `do...while`, requires checking the `conditionExpression` field, not just graph shape. +- Bad, because a target the stop node post-dominates but the split does not dominate is walked inline by the chain all the same, the way the guard clause walks it, so a target reached from elsewhere leaves its other edges as jumps. +- Bad, because "tail" for a synthesized end is decidable only after the block's passes, so the emitter carries a deferred list and a resolve step that a reader has to find. +- Bad, because the decompiler always produces parseable source but not always source that validates, and not every edge survives. +- Bad, because a modeller's document with a default-named plain end that is not last prints a reserved id and is refused until renamed, and the import-side label warning can only name the two outcomes rather than the one that will happen. +- Bad, because a step whose routes merge at a merge of another kind prints jumps under a marker where an `if` chain would read better, that chain being a choice the model is not. +- Bad, because a branch that ends inside a loop body, a `do { end } while`, a loop gateway the compiler leaves without an incoming flow, and a post-test loop whose body ends inside a branch still print as jumps or markers, the last of them a jump the validator refuses; these are reported and pinned as they are. + +### Confirmation + +`irToDsl` is verified total by the unit test suite (`packages/transform/test/`): every test input produces source and its warnings, and never throws. +The goto-degradation path is confirmed by `tests/golden/unstructured-goto.bpmn` in `tests/round-trip-constructs.test.ts`. +The tables in `packages/transform/test/ir-to-dsl.test.ts` pin the printed source, the whole warning list, the re-parse and the real-node reachability for each shape above: a step whose own routes split, the degraded marker with its weighed jumps, the empty-split report per gateway kind, the self-looping gateway, the jump across a branch border, the guard clause, a split inside a loop body whose every route is conditioned, and a synthesized plain end that is not its block's tail. +`tests/goto-fallback.round-trip.test.ts` runs the composite shapes a fuzz run drew through compile, export, import and print, pinning each print's warning categories and whether it validates, and pins the modeler's shape of a conditioned split in a loop through the XML hop. +The `review-loop` round-trip suite pins the frozen artifact byte for byte, its import without a warning, and the chain inside the `do` body, and every round-trip suite under `tests/` validates the source printed from its golden with zero error diagnostics, which is the proof that no golden's printed form moved. + +## More Information + +The CFG analysis lives at `packages/transform/src/cfg-analysis.ts`. +In `packages/transform/src/ir-to-dsl.ts`, `emitRoutes` chooses the continuation, `cleanJoin` holds the containment check, `convergence` and `enclosingContinuation` sit beside it, `branchStaysInRegion` decides a guard clause's branch entry, `followLinear` reads a step's routes, `emitFork` prints a fork block, `emitJumps` the degraded chain, `emptySplitWarning` the report for a gateway with no route out, and `emitBranch` with the check at the end of `Emitter.emit` does the branch bookkeeping for a jump across a branch border. +Each print warning category and what it costs is listed in `packages/transform/README.md`. diff --git a/docs/decisions/0015-subprocess-di-expansion-hint.md b/docs/decisions/0015-subprocess-di-expansion-hint.md deleted file mode 100644 index 2a1cc0e..0000000 --- a/docs/decisions/0015-subprocess-di-expansion-hint.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -status: accepted -date: 2026-07-17 -decision-makers: Marlon Kranz ---- - -# Author a Diagram-Interchange Expansion Hint for Sub-processes - -## Context and Problem Statement - -ADR-0003 established that `irToXml` never computes its own coordinates: it serializes DI-less BPMN XML and hands it to `bpmn-auto-layout`, which lays out every element and injects the `bpmndi:` diagram-interchange data. -That decision assumed a flat process with one plane and no nested containers. - -Embedded sub-processes break the assumption. -Fed DI-less XML containing a `bpmn:subProcess`, the installed `bpmn-auto-layout` does not throw, but it also does not produce a degraded-but-sane result: it renders the sub-process as a collapsed box and scatters shapes for its nested children into the root plane, at coordinates that duplicate unrelated top-level elements. -The output parses and validates, so nothing signals the problem. -A viewer opening the generated diagram would show the sub-process's children floating on top of sibling top-level elements instead of nested inside their parent. - -How should `irToXml` produce a correctly laid-out diagram for a process that contains a sub-process, without taking on any layout computation of its own? - -## Decision Drivers - -- ADR-0003's no-custom-layout-code stance should hold for sub-processes too. - A bespoke box-and-offset algorithm here would duplicate what `bpmn-auto-layout` already does correctly once it is told to expand. -- The fix must not touch the semantic element tree or its ids, because DI is presentation only. -- A process without any sub-process must keep producing byte-identical output; this is the golden path the export pipeline is tested and deployed against. - -## Considered Options - -- Pre-seed a minimal `isExpanded` shape hint per sub-process before layout -- Accept the collapsed rendering and post-process the output to strip the misplaced child shapes -- Compute sub-process bounds and child placement manually, bypassing `bpmn-auto-layout` for nested containers - -## Decision Outcome - -Chosen option: "Pre-seed a minimal `isExpanded` shape hint per sub-process before layout", because it stays inside `bpmn-auto-layout`'s own contract. -The library already reads an existing shape's `isExpanded` flag before computing bounds; it simply had nothing to read when none was pre-existing. -Supplying that one boolean per sub-process is the smallest input that makes the library take the branch it already has for expanded containers. - -Before calling `moddle.toXML`, `irToXml` walks the built moddle tree for every `bpmn:SubProcess` element at any nesting depth. -Only when at least one exists, it attaches a `bpmndi:BPMNDiagram` holding a `bpmndi:BPMNPlane` rooted at the process, which in turn holds one `bpmndi:BPMNShape` per sub-process, each carrying `isExpanded="true"` and a `bpmnElement` reference to its sub-process. -No `dc:Bounds` are supplied: the library recomputes and discards any bounds on a pre-existing shape, computing correct ones from the expanded layout instead. -What the library does need, and what the shape carries for no other reason, is an `id`. -It locates a pre-existing shape by looking it up in its own id-keyed element index while parsing the DI-less-but-hinted XML, so a shape without one is invisible to it even though nothing in the final output ever references that id back. - -`bpmn-auto-layout` fully replaces this hint during layout: it discards every pre-existing diagram (`this.diagram.diagrams = []`) after reading the `isExpanded` flags off it, then regenerates the diagram it actually serializes. -The hint therefore never survives into the output as such; it is a one-shot instruction, not persisted diagram data. - -### Consequences - -- Good, because the change stays inside `irToXml`'s existing DI-generation call and adds no dependency, no coordinate math, and no custom layout logic. - The library still produces every bound and waypoint in the final document. -- Good, because a process without a sub-process attaches no hint at all, so its output is byte-for-byte unchanged. -- Bad, because the hint's correctness is pinned to one specific behavior of the installed `bpmn-auto-layout` version, namely reading `isExpanded` off an id-keyed pre-existing shape. - A library upgrade that changes how it detects expansion could silently regress nested layout without any XML-validity signal. - -### Confirmation - -A regression tripwire in `ir-to-xml.test.ts` builds an IR whose sub-process has two or more children, runs it through the real `irToXml` with no mocks and the actual installed `bpmn-auto-layout`, and asserts every nested child's `bpmndi:BPMNShape` bounds fall strictly inside its parent sub-process's shape bounds. -The same test suite also pins two-level nesting (inner sub-process inside outer, inner children inside inner), exactly one `bpmndi:BPMNDiagram` in the final output (the library's regenerated diagram, not a duplicate of the hint), and that a sub-process-free process's output stays byte-identical to the frozen golden. - -## Pros and Cons of the Options - -### Pre-seed a minimal `isExpanded` shape hint per sub-process - -- Good, because it is a handful of lines that reuse a code path the layout library already has. -- Good, because it degrades to a no-op for sub-process-free processes. -- Neutral, because it depends on empirically observed library internals rather than a documented public contract for this exact scenario. - -### Accept the collapsed rendering and post-process the output to strip misplaced shapes - -- Bad, because a collapsed sub-process still loses the point of an embedded sub-process in a viewer: the reader cannot see its children without manually expanding and re-laying-out the box themselves. -- Bad, because stripping the scattered child shapes without breaking id references between the semantic tree and the DI is more code than the chosen option, for a worse result. - -### Compute sub-process bounds and child placement manually - -- Bad, because it reintroduces exactly the custom layout code ADR-0003 exists to avoid, for one specific case, while the rest of the diagram still depends on the library. -- Bad, because keeping a hand-rolled nested-box algorithm visually consistent with `bpmn-auto-layout`'s own spacing and sizing conventions is an ongoing maintenance burden with no natural termination. - -## More Information - -Implemented in `packages/transform/src/ir-to-xml.ts` (`buildSubProcessExpansionHint`/`collectSubProcessElements`), called from `irToXml` right before serialization. - -Related decisions: ADR-0003 (auto-layout owns all DI generation; this decision extends rather than revisits that choice). -ADR-0006 (the IR itself carries no layout information, since the hint is derived purely from the moddle tree built for export and never stored). diff --git a/docs/decisions/0051-synthesize-variable-declarations-on-print.md b/docs/decisions/0015-the-print-declares-every-variable-it-reads.md similarity index 56% rename from docs/decisions/0051-synthesize-variable-declarations-on-print.md rename to docs/decisions/0015-the-print-declares-every-variable-it-reads.md index f31ae84..323b61e 100644 --- a/docs/decisions/0051-synthesize-variable-declarations-on-print.md +++ b/docs/decisions/0015-the-print-declares-every-variable-it-reads.md @@ -4,16 +4,14 @@ date: 2026-09-14 decision-makers: Marlon Kranz --- -# The print declares every variable it reads bare, typed any +# The print declares every variable it reads ## Context and Problem Statement A `var x: number` line in the header declares a variable so the validator can check its uses, and nothing else. BPMN has no slot for it, the compiler writes none, and the process the engine runs is the same with or without it. -The print used to derive a declaration only for a bare `for each ... in ` collection, and relied on form fields and catch bindings, which the XML does carry, to type everything else. -A variable read in a condition, a count, an `until`, an error mapping's `when` or an `in` mapping came back undeclared, so the script `bpmns parse` wrote warned the moment it was built. -That was every decompiled example, the Operaton invoice example, and 779 of the programs a fuzz run put through the round trip. -The golden fixtures avoided it by declaring every condition variable on a start form, which is a workaround rather than a contract. +A document nobody wrote in this language therefore carries no declaration at all, while a variable the printed script reads in a condition, a count, an `until`, an error mapping's `when` or an `in` mapping needs one or the script warns the moment it is built. + Where should the declarations a rebuilt script needs come from? ## Decision Drivers @@ -25,23 +23,20 @@ Where should the declarations a rebuilt script needs come from? ## Considered Options -- Declare on print every variable the body reads bare, typed `any` -- Carry `var` lines through the XML in an extension element -- Keep declaring collections alone +- Declare on print every variable the body reads bare, typed `any`: it works for a document nobody wrote in this language, which carries no declaration to read back, and adds nothing to the XML and needs no reader, at the cost of the type, a name being declarable only as `any`. +- Carry `var` lines through the XML in an extension element: the type would survive the round trip, but an extension element the engine ignores would be a second home for a fact the form and catch bindings already carry, the importer would have to reconcile the two when they disagree, and a third-party document never carries it, so the derivation on print is needed all the same. +- Keep declaring collections alone: nothing changes, but nearly every decompiled program then warns on rebuild, about the tool's own output. ## Decision Outcome -Chosen option: declare on print every variable the body reads bare, typed `any`, because the print already knows every position it writes a variable in. -A third-party document never carries a declaration, so the derivation is needed whatever the XML holds. +Chosen option: declare on print every variable the body reads bare, typed `any`, because the print already knows every position it writes a variable in, and a third-party document never carries a declaration, so the derivation is needed whatever the XML holds. The header gets one `var : any` line per name the body reads in a position the script writes bare and nothing types, in first-appearance order over one fixed walk of the model. -The positions are the ones the validator reads a variable at. -They are a flow's condition, a loop's count and completion condition, a conditional trigger, an error mapping's condition, an `in` mapping's source, and a bare collection. +The positions are the ones the validator reads a variable at: a flow's condition, a loop's count and completion condition, a conditional trigger, an error mapping's condition, an `in` mapping's source, and a bare collection. A body prints bare only when it fits the expression subset, so only those bodies contribute names; a raw `"${...}"` body is opaque to the validator and to the print alike. The typed set is the validator's symbol table, `DefaultVariableSymbolProvider.collect`. It holds every form field, every catch binding, every input and output parameter name, every loop element variable, and the four counters `MultiInstanceActivityBehavior` sets once anything repeats. -`externalTask` inside a mapping and the source of an `out` mapping are left out for the reasons the validator leaves them out. -The one is resolved on the external task's execution, the other in the called process. +`externalTask` inside a mapping and the source of an `out` mapping are left out for the reasons the validator leaves them out: the one is resolved on the external task's execution, the other in the called process. The type is `any` because a read says nothing more. Every declaration of a name has to agree on its type, so a line for a name a form already types would be an error rather than a duplicate. @@ -50,7 +45,7 @@ A condition the print had to drop still declares its variable, since the model r ### Consequences -- Good, because a decompiled script builds clean, the invoice example included. +- Good, because a decompiled script builds clean, the Operaton invoice example included. - Good, because the document the engine sees is unchanged: nothing new is written to the XML. - Bad, because a `var x: boolean` comes back as `var x: any`, so the type an author wrote is lost on the way through the XML. A fixture that needs the type back declares the variable on a form, whose type the XML carries. @@ -61,37 +56,10 @@ A condition the print had to drop still declares its variable, since the model r ### Confirmation -The row under "the header declares every variable the body reads bare" in `packages/transform/test/ir-to-dsl.test.ts` prints one process reading a variable at every position and typing one through every source. -It pins the whole header and an empty diagnostic list on the re-parse. +The row under "the header declares every variable the body reads bare" in `packages/transform/test/ir-to-dsl.test.ts` prints one process reading a variable at every position and typing one through every source, pinning the whole header and an empty diagnostic list on the re-parse. The two-pass table in `tests/round-trip-constructs.test.ts` requires an empty diagnostic list on every printed script, and its last row is a condition variable coming back declared and re-printing byte for byte. -## Pros and Cons of the Options - -### Declare on print every variable the body reads bare, typed `any` - -- Good, because it works for a document nobody wrote in this language, which carries no declaration to read back. -- Good, because it adds nothing to the XML and needs no reader. -- Bad, because the type is lost, and a name can only be declared as `any`. - -### Carry `var` lines through the XML in an extension element - -- Good, because the type would survive the round trip. -- Bad, because an extension element the engine ignores would be a second home for a fact the form and catch bindings already carry. - The importer would have to reconcile the two when they disagree. -- Bad, because a third-party document never carries it, so the derivation on print is needed all the same. - -### Keep declaring collections alone - -- Good, because nothing changes. -- Bad, because nearly every decompiled program warns on rebuild, about the tool's own output. - ## More Information The walk is `variableDecls` in `packages/transform/src/ir-to-dsl.ts`. The validator's positions are in `checkExpression` in `packages/language/src/bpmn-script-validator.ts` and its symbol table in `packages/language/src/variable-symbol-provider.ts`. - -Amends ADR-0014, whose print hop now declares `var : any` for every variable the printed script reads bare and nothing else declares, so a decompiled script builds without an undeclared-variable warning. -Amends ADR-0027, whose collection declaration on print becomes one case of that rule rather than the only one. - -Related decisions: ADR-0027 (the collection declaration this generalizes, and the seeded loop counters). -ADR-0014 (the honest import contract the clean rebuild belongs to). diff --git a/docs/decisions/0016-derived-event-root-elements.md b/docs/decisions/0016-derived-event-root-elements.md deleted file mode 100644 index 9447ed1..0000000 --- a/docs/decisions/0016-derived-event-root-elements.md +++ /dev/null @@ -1,173 +0,0 @@ ---- -status: accepted -date: 2026-07-17 -decision-makers: Marlon Kranz ---- - -# Derive Event Root Elements From Usage: Syntactic Throw Terminality, Soft Trigger Words - -## Context and Problem Statement - -BPMN keeps error and escalation definitions in two places. -A `bpmn:Error` or `bpmn:Escalation` root element sits under `bpmn:Definitions`, declared once per code and carrying, for errors, the message text (`operaton:errorMessage`). -Every start, end, or intermediate-throw event that catches or throws that code carries its own `errorEventDefinition`/`escalationEventDefinition`, referencing the root by id. -Nothing about the act of using a code in an event definition is enough, by itself, to know it needs a root element to exist; that bookkeeping sits one level up, in the document's root-element list, disconnected from where the code is actually written. - -The DSL's audience does not maintain BPMN documents by hand. -Asking a user to declare a root element before referencing its code from a handler, a throw, and a catch-all elsewhere in the same process is exactly the kind of required syntax that carries no process information of its own. -At the same time, the event surface needs two properties that BPMN's own vocabulary does not settle for a textual language: what makes a thrown event end its path versus merely notify and continue, and whether the words naming event kinds and catch-parameter fields need to be reserved to work at all. - -This decision covers three related points that recur across the whole event layer: how root elements come into existence, how `throw` and `emit` decide whether the flow continues, and how the words spelling the event vocabulary are lexed and validated. - -## Decision Drivers - -- The target audience writes processes, not BPMN documents; syntax that exists only to declare what a compiler could already infer from usage does not pay for itself. -- BPMN lets an event definition reference no root at all, a catch-all. - The surface must not force a code where the author wrote none, and must not synthesize a root element for a code that never resolves to one. -- The language round-trips: BPMN imported into DSL source and recompiled must reproduce the same document, so every derivation rule has to invert, and a printed statement must mean the same thing regardless of where it happens to sit in its enclosing block. -- Error and escalation are the first two event kinds; message, signal, timer, and conditional follow later. - The design should not spend a keyword, or accumulate a root-element bookkeeping obligation, that later kinds would each need to repeat. - -## Considered Options - -For root elements: - -- Derive them from usage, deduped by code, with the thrown-message text as one declared exception -- Model them explicitly as a `Definitions`-level IR root -- Require an explicit declaration for every code - -For `throw`/`emit` terminality: - -- Decide it by keyword, as a property of the word itself -- Decide it by statement position, so that last in a block compiles to an end event and anything else to an intermediate throw - -For `error`, `escalation`, `code`, and `message`: - -- Lex them as soft, validated identifiers -- Reserve them as keywords - -## Decision Outcome - -Chosen: derive root elements from usage instead of modeling them explicitly, with the thrown-message text as the one piece of root data the IR stores because usage alone cannot supply it; decide `throw`/`emit` terminality from the keyword rather than from statement position; and lex trigger kinds and binding fields as validated identifiers rather than keywords. -All three keep BPMN's own bookkeeping (registries, event definitions, reserved vocabulary) out of the author's hands wherever the compiler can carry it instead, and spend required syntax only where the alternative breaks round-trip fidelity or collides with the audience's own variable names. - -Root elements are derived, not modeled. -`irToXml` walks the whole IR once, collects every distinct error and escalation code in use on a catching start event, a throwing end event, or an intermediate throw, anywhere in the document, and synthesizes one `bpmn:Error` or `bpmn:Escalation` root per distinct code. -Every event definition carrying that code is wired to the same root through `errorRef`/`escalationRef`. -A definition without a code (catch-all) gets no ref and contributes no root, because BPMN itself treats a ref-less catch as "any error", so there is nothing for it to point at. -Root ids are sanitized from the code and de-collided against the rest of the document; the root's `name` is the name the code is declared under (ADR-0030), which is the code itself wherever an identifier can spell it. - -The one exception is the message text an error carries when thrown. -`operaton:errorMessage` lives on the root, not on the throw statement, because two throw sites sharing a code share one root, and the message cannot live at the throw site without a mechanism to reconcile disagreeing copies. -The surface exposes this as a process-header declaration, `error CODE(message: "...")`, read into `BpmnProcess.errorDecls` and stamped onto the synthesized root at export; import reads the message back off the root. -It is the one piece of root-element data the IR stores explicitly, because it is the one property usage alone cannot determine: nothing about using a code says what its message reads. - -An imported document whose two roots share a code but disagree on the message is refused, not merged. -Collapsing them would change what a throw carries at runtime, which is the same reasoning ADR-0014 already applies to any drop that would alter execution semantics. - -ADR-0030 amends this on one point: the declaration form generalizes from the message text to the code itself, so every error and escalation code is declared and every use site names the declaration. -The inference argument this decision rests on holds on its own terms and is not withdrawn. -What outweighs it is an argument this decision does not weigh: a declaration is what gives a code a cross-reference, and a cross-reference is what gives it jump-to-definition, find-references and rename, which for a textual DSL is the contribution rather than a convenience. -Derivation itself is untouched, since `irToXml` still walks usage to build the roots and a declaration only adds one for a code nothing raises. - -Throw and emit decide terminality by keyword, not position. -`throw error(C)` always compiles to an end event; `emit escalation(C)` always compiles to an intermediate throw that falls through to whatever follows it. -The distinction lives in the keyword, not in where the statement sits inside its block. - -A single verb whose compiled form depends on being last in its block looks appealing, one fewer keyword, but it does not survive the round trip. -Take an IR shape the importer must be able to reproduce: a branch that fires an escalation and rejoins the main flow, `split -> A -> escalation-intermediate-throw -> join`. -Printed inside its branch block, that statement is the last statement of the block, `{ A (C) }`, simply because the branch has nothing after it, not because the event ends anything. -A position-decided desugarer reads "last in block" and turns the statement back into an escalation end event on the way in, which drops the join edge the original graph had. -The same printed text would then mean two different graphs depending on information the text itself does not carry, which is exactly what round-trip fidelity rules out. - -Deciding terminality from the keyword removes the ambiguity. -`throw` always ends its path, matching the intuition every reader already brings from exception handling in a general-purpose language; `emit` always continues. -Both escalation forms, end event and intermediate throw, stay independently printable, which a document containing either one must be able to reproduce regardless of where it sits. - -`emit` is a general continuing verb, not an escalation-specific one. -Error has no continuing BPMN form, since there is no intermediate error throw, so `emit error` is a validator error. -Escalation does have one, and later event kinds that fire and continue reuse the same verb instead of growing their own. -Giving every kind its own throw-and-continue verb turns the surface into a vocabulary quiz; one shared verb keeps it small as kinds are added. - -Trigger kinds and binding fields are validated identifiers, not keywords. -`on`, `throw`, `emit`, and `alongside` are the only words this event layer reserves. -`error`, `escalation`, `code`, and `message`, the words that actually name the event kind and the catch-parameter fields, lex as plain identifiers and are checked in position by the validator instead. - -The reason is the audience, not parsing convenience. -`message` and `code` are two of the most ordinary variable names in Java-style code, and reserving them would make `var message: string` a parse error anywhere in a file, not only near an event handler. -A Langium keyword is lexer-global: there is no way to reserve a word only inside `on`/`throw`/`emit` and leave it free everywhere else. -Four permanent collisions on exactly the words this audience reaches for is a worse trade than the validation work a soft word costs. - -What a keyword gives away for free (highlighting, completion, "did you mean") is rebuilt explicitly instead, each in the mechanism meant for it rather than a lexer trick. -A validator checks `trigger`, `field`, and the declaration's `kind` against their small legal sets and names the options directly in the diagnostic; each set has at most two members, so "unknown event kind 'erorr'; write 'error' or 'escalation'" already is the suggestion. -A semantic-token provider marks exactly the AST properties that carry meaning, so `on error` highlights `error` while `var error` stays plain, a precision a keyword-based or regex-based highlighter cannot reach. -Completion items are offered at the same positions. -This also pre-pays the next event kinds: `message`/`signal` become validated trigger values later at zero additional reserved-word cost. - -### Consequences - -- Good, because using a code never requires declaring it first: a handler, a throw, and a catch-all can all reference the same code without any one of them being the "first" to establish it. - ADR-0030 gives this consequence up in exchange for the cross-reference, so a code is declared once and named at every site that raises or catches it. -- Good, because every user of one code shares exactly one root element, so a code's message text has exactly one place to live, never several that could disagree. -- Good, because two disagreeing imports of the same code are refused rather than silently reconciled, keeping ADR-0014's "no silent semantic loss" claim intact for this construct too. -- Good, because `throw`/`emit` terminality is a property of the word printed, readable without looking at what follows in the block, and it matches the reading every reader already brings from exception handling elsewhere. -- Good, because the soft-word design costs zero additional reserved words when `message`/`signal` triggers are added later. -- Bad, because a catch-all definition contributes no root element to inspect, so a document consisting only of a catch-all handler for a code leaves nothing that names that code at the root-element level. - That is correct, but there is no single element a tool can point to for "this code, however it's caught". -- Bad, because the soft-word design moves work from the grammar into four separate mechanisms (validator, semantic tokens, completion, parser-error messages) that all have to agree on the same two small word sets, instead of one keyword declaration doing it once. -- Bad, because a document whose two roots for one code disagree cannot be imported at all, even when the disagreement reads as cosmetic to a human ("Payment declined" versus "Payment was declined"). - The importer has no way to tell a cosmetic disagreement from a meaningful one, so it refuses both alike. - -### Confirmation - -`packages/transform/test/ir-to-xml.test.ts` asserts the derivation directly: a document using one code from a handler, a throw, and a catch-all handler produces exactly one root element referenced by every coded use and none by the catch-all, with dedicated cases for id sanitization and collision suffixing. -`packages/transform/test/ast-to-ir.test.ts` and `ir-to-dsl.test.ts` pin the branch-tail counterexample as a round-trip case: an intermediate escalation throw as the last statement of a branch block prints as `emit` and re-imports as the same intermediate throw, never an end event. -`packages/language/test/validating.test.ts` pins the soft-word behavior: `var message: string`, `if (code == "x")`, and a task named `error` all parse and validate cleanly, while an unrecognized trigger word produces the options-naming diagnostic and a misspelled binding field is reported as a setting the handler does not take, rather than a silent pass-through. - -## Pros and Cons of the Options - -### A `Definitions`-level IR root modeling `{ process, rootElements }` explicitly - -Every event definition would reference an explicit IR-level root object instead of a bare code string, and `astToIr`, `irToXml`, `irToDsl`, and `xmlToIr` would all thread it through. - -- Good, because it mirrors BPMN's own document structure exactly, leaving no derivation step that could get the mapping wrong. -- Bad, because every consumer signature changes (the CLI, all four transforms, every existing test) to carry data the DSL surface can never author independently in the first place; there is no syntax for "declare a root without using it" beyond the message declaration this decision already provides. -- Bad, because it reintroduces the registry-management burden this decision exists to avoid: something still has to create, id, and look up root objects, and that something is either the compiler (making the explicit IR-level model pure ceremony) or the author (reintroducing the boilerplate the derived design avoids). - -### Explicit declarations for every code - -Every error and escalation code would need a header declaration before use, generalizing how the `error CODE(message: "...")` declaration already works for message text. - -- Good, because it removes all derivation logic: a code is either declared or using it is a compile error, symmetrical and simple to check. -- Bad, because it demands decl-versus-usage redundancy for no expressive gain. - Dedup-by-code already makes usage-derived synthesis lossless for every property except the message text, which is precisely why that one property gets a declaration instead of the rule being generalized to all of them. - -ADR-0030 takes this option after all, on a gain measured in editor behaviour rather than in expressiveness. -The redundancy is real and is what buys the cross-reference, so the argument above is answered rather than refuted. - -### Position-decided throw terminality - -A single verb whose compiled form depends on whether a successor statement follows it in the same block: last-in-block compiles to an end event, anything else to an intermediate throw. - -- Good, because it removes one keyword (`emit`) from the surface. -- Bad, because it is unsound under round-trip, per the branch-tail counterexample above: the same printed statement, in the same textual position, would have to mean two different graphs depending on information the printed text does not carry. - -### Reserving trigger and field words as keywords - -`error`, `escalation`, `code`, `message` become real keywords, parsed and highlighted like `on`, `throw`, and `emit`. - -- Good, because highlighting and completion come from the generated grammar and the parser's keyword table directly, with no additional provider code. -- Bad, because a Langium keyword is lexer-global: reserving `message` for the event surface reserves it everywhere, breaking `var message: string` anywhere in a file, on an audience for whom `message`, `code`, and `error` are ordinary variable names. - -## More Information - -Extended by ADR-0017 (message/signal/timer/conditional payloads) and ADR-0018 (compensation), the later event kinds that reuse this decision's `throw`/`emit` terminality rule and soft-word design without adding a reserved word. -Extended also by ADR-0019 (boundary events), whose colon separator exists precisely so the trigger words stay soft. -Amended by ADR-0030, which generalizes the message-text declaration to the code itself so that a code carries a cross-reference. -Amended by ADR-0035, which adds the one `emit` that ends its chain, `emit link`, and argues terminality is still read off the printed words. - -Related decisions: ADR-0006 (the IR as the shared model, where vendor- or serialization-only data such as `targetNamespace` attaches at the IR-to-XML boundary rather than living in the IR; `errorDecls` and `escalationDecls` are the one exception, and they are an exception because they cannot be derived from usage, not because the boundary rule was relaxed). -ADR-0010 (deterministic structural ids, whose sanitize-then-suffix collision rule synthesized root ids follow). -ADR-0013 (the audience this design serves: no BPMN-document bookkeeping, no required syntax the compiler could supply itself). -ADR-0014 (the honest import contract, under which disagreeing root definitions for one code are refused rather than merged, for the same reason a semantically significant drop is refused rather than warned). -ADR-0001 and ADR-0002 (VS Code and Langium as the IDE target and workbench, the reason the soft-word design rebuilds highlighting and completion as explicit language-server providers rather than through the keyword table). diff --git a/docs/decisions/0016-task-kinds-on-the-authoring-surface.md b/docs/decisions/0016-task-kinds-on-the-authoring-surface.md new file mode 100644 index 0000000..06acf89 --- /dev/null +++ b/docs/decisions/0016-task-kinds-on-the-authoring-surface.md @@ -0,0 +1,71 @@ +--- +status: accepted +date: 2026-08-30 +decision-makers: Marlon Kranz +--- + +# Task kinds on the authoring surface + +## Context and Problem Statement + +Four BPMN activity tags need a statement in this grammar: `bpmn:task`, `bpmn:sendTask`, `bpmn:receiveTask`, and `bpmn:businessRuleTask`. +A document carrying a tag with no statement imports as something the printer cannot write back, so the round trip stops there. +Each of the four needs a word, and every statement keyword is a name an author can never use again. +Which four words, where do their payloads sit, and how many IR kinds do four tags need? + +## Decision Drivers + +- ADR-0006 binds a keyword to what the author means rather than to the BPMN element behind it. +- A statement keyword cannot be soft: `Statement` being keyword-led is what lets `start S` tell its optional trigger from the next statement. +- A Langium keyword is lexer-global, so a word spent on a statement is a variable and step name lost everywhere in a file. +- ADR-0012 refuses what changes the run and warns only about drops that do not. +- ADR-0007 models the IR on what the engine does, so a distinction the engine does not make should not become an IR kind. + +## Considered Options + +- `step`, `send`, `receive`, and `decide` as the four words: each says what the author is doing, three of the four are the verb itself, and the cost is four ordinary English words lost as identifiers everywhere in a file. +- `task`, `notify`, `expect`, and `rule`: `task` names the BPMN tag and nothing else, `notify` narrows a send to a notification, and `expect` and `rule` read worse than the verb beside `user` and `service`. +- The message name and the decision key in positional slots after the id: a label and a message name are both quoted strings, so an unkeyed one has no reading that position can settle. +- The message name and the decision key as settings: `call` already names its target there, and `binding` and `version` already carry the rule a decision step needs. +- One IR node for the three service-task-like tags: the discriminator is optional, so a service-task literal needs no annotation to stay valid, and the binding switch is written once. +- One IR kind per tag: it splits a shape Operaton itself does not split, and copies that binding switch through the writer, the printer, and the reader. +- A receive task with no message name in scope: the engine runs it, so there is nothing a refusal would save. +- A receive task with no message name refused on import: an error path, a message, and a test spent rejecting content the engine executes, and downgrading it to a validator warning is no cheaper, since the goldens require a decompiled document to re-validate with no diagnostic at all. + +## Decision Outcome + +Chosen options: `step`, `send`, `receive`, and `decide`, every payload in the settings, one IR node for the three service-task-like tags, and the nameless receive task in scope. + +Sharing a word with the BPMN tag is not what ADR-0006 forbids. +`user`, `service`, and `script` already do it, and each is legal because the word means something to a reader who has never seen BPMN. +`send`, `receive`, and `decide` are the verbs an author means, and ADR-0006 names a decision as a thing a keyword should name. +`task` was rejected for the generic kind because it is the one candidate carrying no meaning beyond the tag name. + +All four are hard keywords, so `step` cannot be a variable name, a bare expression identifier, an attribute key, a listener event, a form-field id, or a map key. +No source file uses any of the four, and the four rules built into a live Langium parser with Chevrotain's self-analysis on drew no ambiguity report. +`decide` sits close to `if`, which is a decision in the same vocabulary, and the two never compete for a position: `if` takes a condition and opens branches, and `decide` is a leaf activity naming a decision table. + +One IR node covers `bpmn:serviceTask`, `bpmn:sendTask`, and `bpmn:businessRuleTask`. +Operaton's `BpmnParse` runs all three through `parseServiceTaskLike` when the tag carries a `class`, `expression`, `delegate`, or `topic` binding, so they share one node and an optional discriminator picks the tag. +A business rule task naming an `operaton:decisionRef` goes to `parseDmnBusinessRuleTask` instead, and the `decision` binding that form carries is the one variant `service` and `send` refuse. +A send task therefore has no message semantics of its own: the engine gives it none, and `send X(class: "...")` is what makes it send anything. + +`parseReceiveTask` subscribes a receive task to a message only when `messageRef` is present, so a nameless one is a wait state the engine's signal API continues, in scope in both directions with no refusal and no warning. +A named one shares its derived `bpmn:Message` root with an `await message` of the same name. + +### Consequences + +- Good, because a message throw or end carries the implementation that makes the engine really send it, so this surface does not treat every thrown message as a pass-through. + `BpmnParse` calls `isServiceTaskLike` on the `bpmn:messageEventDefinition` in both throw positions and never inspects the `endEvent` or `intermediateThrowEvent`, so the carry lives on the definition and the same attributes written on the event are inert. +- Bad, because `step` carries two senses at once: the language's word for the activity that does nothing, and the ordinary noun for any activity in a process. + +### Confirmation + +`packages/language/test/` and `packages/transform/test/` pin the four statements token by token, every binding rule, the import of each tag, the emitted element with its derived root, and the printed line. +The frozen pair `tests/golden/task-kinds.{bpmnscript,bpmn}` holds all four kinds, both receive forms, both decision bindings, and a message end carrying an implementation, compared byte for byte by `tests/task-kinds.round-trip.test.ts` under an import with no warning at all. +`tests/e2e/task-kinds.test.ts` deploys that process to a real Operaton, walks the token past the step, the send, and the decision, and parks it at the receive task until the message is correlated. + +## More Information + +Related decisions: ADR-0006 (the rule that a keyword names what the author means, and the reason `task` is not one). +ADR-0012 (the honest import contract behind every refusal and every warning here). diff --git a/docs/decisions/0017-event-trigger-payload-surfaces.md b/docs/decisions/0017-event-trigger-payload-surfaces.md deleted file mode 100644 index 726653f..0000000 --- a/docs/decisions/0017-event-trigger-payload-surfaces.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -status: accepted -date: 2026-07-18 -decision-makers: Marlon Kranz ---- - -# Event Trigger Payloads: One Paren Slot, Validated Timer Particles, Deferred Conditional Narrowing, Name-Keyed Roots - -## Context and Problem Statement - -The event layer's `on` handler already hosts two trigger kinds, error and escalation, sharing one payload shape: an optional code string and an optional parenthesized binding list. -Four more trigger kinds join the same `on` surface, message, signal, timer, and conditional, and between them they need three payload shapes the existing rule does not have. -Message and signal carry a name string, structurally identical to error/escalation's code. -Timer carries something different: a word choosing which of BPMN's three timer forms applies, followed by a time expression. -Conditional carries a boolean expression, which wants the same parenthesized slot error/escalation already use for bindings, but for an unrelated purpose: a condition, not a catch parameter. - -Fitting three payload shapes into one handler surface, without forking `on` into a separate grammar rule per trigger kind and without reserving a new keyword for each shape, is the central question this decision resolves. -Two narrower questions ride along with it: how a timer's payload text becomes one of BPMN's three timer definition kinds without silently picking the wrong one, and how much of BPMN's conditional-evaluation narrowing the surface should expose given that the DSL's audience has no reason to know it exists. - -## Decision Drivers - -- One `on` handler surface has to host all six trigger kinds without forking into six grammar rules or growing a reserved word for every new payload shape. - The soft-word design already in place for trigger names should not be reopened to make room for new payloads. -- A condition is a real expression, not a string. - It needs the same round-trip and variable-participation guarantees the language's `if` conditions already have, not a demotion to opaque text that a validator, a symbol table, or a highlighter cannot see inside. -- A timer's payload must resolve to exactly one of BPMN's three time-definition kinds. - Because a wrong resolution changes what the engine schedules, not just how the text reads, guessing the kind from the value's shape is a real correctness risk, not a cosmetic one. -- Message and signal need something for the engine to key its subscriptions on, and BPMN keeps that identity in root elements the same way it already does for error and escalation codes. - Whatever the surface settles on must not reintroduce the declare-before-use boilerplate the language otherwise avoids. -- Camunda's conditional-evaluation narrowing attributes change when a condition is checked. - If the surface cannot express them, silently dropping them on import would be a behavior change disguised as a cosmetic loss. - -## Considered Options - -For the parenthesized slot: - -- One paren slot holding either a binding list or a condition, disambiguated structurally -- A binding-marker keyword freeing the parens for bindings only -- A string-wrapped condition -- Reserving `condition` as a keyword to dispatch the parens - -For timer forms: - -- Timer particles (`after`/`at`/`every`) as validated identifiers mapping one-to-one to BPMN's three timer forms -- A single particle with the timer form inferred from the value's shape - -For conditional narrowing: - -- Defer the attributes and refuse them on import rather than drop them -- Surface them as a fourth payload shape -- Drop them silently on import - -For message and signal roots: - -- Derive them from usage, keyed by name, with no declaration form -- An explicit root declaration, mirroring the error message declaration - -## Decision Outcome - -Four related decisions, chosen together because each keeps the same principle: spend a new payload shape or a new reserved word only where the alternative loses something the surface cannot get back, whether round-trip fidelity, execution-accurate scheduling, or truthful runtime semantics, and nowhere else. - -One paren slot, disambiguated structurally. -The parentheses after a trigger's code stay a single grammar position that holds either a binding list or a condition expression. -Which one is present is decided structurally, not by which trigger word precedes it: a binding list is a field name followed by a variable name, two identifiers in a row, and the expression sub-language never places two identifiers adjacently, since every accessor starts with `.` or `[` and every operator level requires an operator token in between. -The parser tells the two apart by looking at the second token after the opening parenthesis, which settles it before any per-trigger legality is even considered. -Whether a given trigger is allowed to use bindings, a condition, or neither is exactly the kind of position rule the validator already owns for every other soft word. - -Rejected: a binding-marker keyword, freeing the bare parentheses for a condition by writing bindings after a marker such as `as (...)`. -It spends a new reserved word to solve a collision that structural lookahead already dissolves for free, and it breaks the parameter-list reading (`on error(X, code: c, message: m)`, read like `catch (Exception e)`) that motivated the binding shape in the first place. -Rejected: a string-wrapped condition, written as a quoted literal instead of a real expression. -It reads worse than the language's own `if (...)`, and it throws away everything a real expression AST buys, including undeclared-variable checking, type checking, symbol participation, and precise highlighting, turning the condition into opaque text the rest of the toolchain cannot see inside. -Rejected: reserving `condition` as a keyword to dispatch the parens explicitly. -Reserving it would make it a lexer-global reservation, unusable as an ordinary variable name anywhere in a file, for exactly the reason the trigger and binding words already stay soft identifiers rather than keywords. - -Timer particles are validated identifiers, one-to-one, with no inference. -A timer's payload is a particle word, `after`, `at`, or `every`, followed by a time expression. -Each particle maps to exactly one of BPMN's timer forms: a duration, a fixed date, or a repeating cycle. -The particle is a plain identifier, checked in position by the validator like every other soft trigger word, not a keyword. - -Rejected: a single particle whose timer form is inferred from the shape of the time value itself, where an ISO date looks like a date and a duration string looks like a duration. -This shifts a scheduling decision onto pattern-matching the value's text, and a value that happens to be malformed, whether a mistyped ISO string or an expression the engine evaluates at runtime rather than a literal the compiler can inspect, silently becomes a different timer kind than the one the author meant, with no diagnostic pointing at the mismatch. -Three explicit particles read as the English they mean, "after an hour," "at nine," "every ten minutes," and cannot silently resolve to the wrong form. - -Conditional narrowing is deferred, and refused rather than dropped on import. -BPMN's conditional event definition can additionally narrow when a condition is re-evaluated, to a named variable or a specific kind of variable change, rather than re-checking on every change. -This narrowing is an optional, engine-side evaluation optimization: a handler behaves correctly without it, re-checking on any variable change instead of a specific one. -The surface does not expose it, because the audience writing BPMNscript has no reason to know Camunda's evaluation-narrowing mechanism exists, and giving it a place on the `on condition` payload would mean a fourth, narrower payload shape carrying nothing but an optimization hint. - -Because the narrowing attributes change when the engine re-checks a condition, they are runtime semantics, not cosmetic metadata. -Importing a document that carries them therefore refuses rather than silently dropping them: dropping a narrowing hint would leave the imported process re-checking its condition on every variable change instead of the one the original author scoped it to, which is exactly the kind of silent behavior change the honest import contract exists to prevent. -The refusal names the attribute so a future surface has somewhere to land it, rather than pretending the information never existed. - -Message and signal roots are derived from usage, keyed by name, with no declaration form. -A message or signal root carries exactly one piece of information the engine cares about: its name, which is also the identity the engine keys its subscriptions and correlations on. -Because the name is both the only data and the natural key, the root is fully derivable from wherever the name is used, whether a handler, a throw, or an emit, with no separate declaration form and no new field on the process's structure. -Every use of the same name, anywhere in the document, resolves to the same root. - -This differs from the error and escalation declarations ADR-0030 settles. -A code is declared in the process header and named at each of its sites, which gives it a cross-reference and gives its thrown message one place to live, since two throw sites sharing a code might disagree on wording. -A message or signal name has neither need: the name is the root's only data and is also its key, so nothing about a use is ambiguous or needs reconciling, and no declaration form is introduced for one. -The payload shapes read that split directly. -An error or escalation payload is a bare name resolving to a declaration, while a message, a signal and a timer carry quoted text, a condition carries an expression, and compensation and cancel carry nothing. - -Rejected: an explicit root declaration mirroring the error message declaration. -It would generalize a mechanism that exists only because the error message text has no other source, to two triggers for which usage already supplies every property in full, which is pure boilerplate with no expressive gain. -ADR-0030 reverses that reasoning for codes on a tooling argument that would apply to a message and a signal name too; extending it to them is a decision of its own and is not taken here. - -### Consequences - -- Good, because none of the four decisions spends a new reserved word: message, signal, timer, and conditional slot into the existing soft-word and paren machinery at zero reservation cost, the same guarantee the error/escalation design already made for the trigger set as a whole. -- Good, because one verb pair, `throw` and `emit`, continues to span every trigger kind that has a throw form at all. - Signal joins error and escalation under the same rule instead of growing its own vocabulary, so the reader who has learned the rule once for error and escalation does not have to relearn it for signal. -- Good, because the condition payload stays a real, checkable expression rather than an opaque string, so a condition handler gets the same variable checking, symbol participation, and highlighting an `if` statement already gets. -- Bad, because a document carrying the conditional narrowing attributes cannot be imported at all, even though the underlying evaluation difference is invisible to a reader who does not already know Camunda's narrowing mechanism exists. - The refusal is correct but reads as stricter than the visible difference suggests. -- Bad, because timer's three particles are one more small vocabulary to learn than a single particle would have been, paid once in exchange for a scheduling decision that cannot silently resolve to the wrong timer kind. - -## More Information - -Extended by ADR-0018 (compensation), the one trigger kind whose payload is empty, slotting into the same `on`/`throw`/`emit` surface this decision shapes without a new reserved word. -Amended by ADR-0030, which makes an error or escalation payload a declared name and leaves message and signal names derived from usage. - -Related decisions: ADR-0013 (the target audience and the no-boilerplate rule, the reason message and signal get no declaration form, and the reason the conditional narrowing attributes stay off the surface until an author actually needs them). -ADR-0014 (the honest import contract, the reason conditional narrowing attributes are refused on import rather than dropped, and the model this decision follows for treating a semantics-bearing drop as a refusal). -ADR-0016 (the derived error/escalation root design and the `throw`/`emit` verb pair, which this decision extends to message and signal, and against which it contrasts the error message declaration with message/signal's lack of one). diff --git a/docs/decisions/0017-service-task-bindings.md b/docs/decisions/0017-service-task-bindings.md new file mode 100644 index 0000000..4e706cf --- /dev/null +++ b/docs/decisions/0017-service-task-bindings.md @@ -0,0 +1,92 @@ +--- +status: accepted +date: 2026-08-16 +decision-makers: Marlon Kranz +--- + +# Service task bindings, including external, mail and shell + +## Context and Problem Statement + +A `service`, `send`, or `decide` step names the work it runs with a binding key in its parens. +Operaton dispatches every form of that work from one place. +`BpmnParse.parseServiceTaskLike` reads `operaton:type` before any code attribute: `external` builds `parseExternalServiceTask`, `mail` builds `parseEmailServiceTask`, `shell` builds `parseShellServiceTask`, and any other value fails the deployment. +With no `operaton:type`, the same method reads a class, an expression, or a delegate expression. +Each form also reads its own extras, and refuses to deploy some of the combinations an author can write beside a binding. +Which keys does this surface offer, what does each compile to, which extras ride which binding, and which combinations are refused? + +## Decision Drivers + +- ADR-0007 models every service-task execution form as one tagged union, `ServiceTaskBinding`, rather than as separate node kinds, so a surface that splits what the IR unifies is an asymmetry with no consumer that benefits from it. +- ADR-0006 commits the language to the smaller keyword surface: a reserved word is a name an author can never choose for a variable or a step, and both `external` and `mail` are words a real process is likely to want. +- ADR-0012's import contract: refuse what the engine refuses, warn on what changes, drop nothing in silence. +- ADR-0013's rule that a deployment refusal the engine's parser raises is reported in the editor first, with the fix named. +- ADR-0021 requires typed IR fields, so an extra under a binding the engine never reads it for is a type error. +- The one bracket shape of ADR-0011: scalar settings in the parens, structured members in the braces. +- ADR-0018's rule that a field's legal placement follows the engine's own parser rather than its schema. +- A mapping names an error code, and ADR-0023 makes every code a declared name the editor resolves. + +## Considered Options + +- `topic` as a binding key rather than `external` as its own statement keyword: a keyword would flag at the step that the work runs in a separately deployed worker, but it reserves an English word for a distinction that produces no distinct BPMN element and that Operaton itself spells as `operaton:type="external"` on an ordinary `bpmn:serviceTask`. +- `type` as a binding key rather than `mail` and `shell` as two more keywords: naming each behaviour directly would cost two more reserved words for a distinction the engine reads off one attribute and the IR collapses into one binding kind. +- `type` rather than dedicated `to:` and `command:` keys: a mail task takes up to eight fields and a shell task twelve, more than a settings key per field can hold without duplicating the member-block machinery `field` already provides. +- The external extras on the binding rather than on the service task node beside it: on the binding the type forbids them wherever the engine never reads them, while on the node a rule has to say they are empty unless the binding is external. +- An error mapping as a member line rather than a settings key or a nested block: several mappings repeat the way parameters do, where a settings key holds one value and a `fields { }` nesting is the cost ADR-0018 refused. +- `when` as a soft word rather than a keyword: lookahead settles the shape, so reserving the word buys nothing, and the price is that the validator checks the word and the editor highlights it through semantic tokens alone. +- The extras on the three task kinds alone rather than on a thrown message binding a topic too: the engine does read them there, but off the message definition for the priority and the mappings and off the event element for the properties, a split shape of its own for a fourth site. +- A result variable refused beside a class or delegate binding rather than warned and dropped on import or accepted on both sides: dropping it prints a script that differs from the document, and accepting it keeps producing documents the engine refuses. + +## Decision Outcome + +A step's settings carry exactly one of five binding keys: `class`, `expression`, `delegate`, `topic`, and `type`. +The validator's zero-binding and more-than-one-binding messages name all five, so every key is discoverable from the error text alone. +There is no `external`, `mail`, or `shell` keyword, and each word parses as an ordinary identifier free for a variable or a step name. +`THROW_BINDING_KEYS` carries the four code and topic keys but not `type`: a thrown message opens no member block here, so the fields a mail or shell task's required-field check demands could never be written on one. + +The IR variant for a built-in behaviour is `{ kind: 'builtin'; type: BuiltinTaskType; fields?: FieldInjection[] }`, tagged by the engine's own `type` value rather than by two IR kinds, so every exhaustive switch grows one case instead of two. +A field rides a `type:` binding for the same reason it rides `class:`: `parseEmailServiceTask` and `parseShellServiceTask` both build their behaviour through `instantiateDelegate`, the identical call a `class` binding's `ClassDelegateActivityBehavior` reaches, so `applyFieldDeclaration` runs the same check whichever behaviour declared the names. + +The validator mirrors the checks the engine's own parse runs before it builds a mail or shell behaviour: the required field groups, the field names each behaviour class declares, and the value shapes `validateFieldDeclarationsForShell` casts. +The tables behind all of them live once in `packages/language/src/vocabulary.ts`, read by both the validator and the importer, so a shape refused on one side and accepted on the other cannot happen by construction. + +Three extras ride the `topic` binding and no other, because `parseExternalServiceTask` alone reads them. +`taskPriority` is a settings key, while a property and an error mapping are member lines of the block the step already opens. +`error when ` is parsed as `ID ID ID Expr`, told apart from a parameter's `ID ID '='` at the third token, so neither `error` nor `when` is reserved. +The code slot is a cross-reference in the scope a `throw error(X)` resolves in, so an undeclared code draws the linker's message, jump-to-definition works, and a `bpmn:error` root is derived for the use as for a throw. +The condition is evaluated on the task's execution by `ExternalTaskEntity.evaluateThrowBpmnError`, where `VariableScopeElResolver` resolves `externalTask` to the task entity beside every process variable. +In the IR the three sit on the external variant of `ServiceTaskBinding`, so the type cannot carry them under a class, expression, delegate, decision, or built-in binding and every direction's guard stays a type check. +A task prints its injected fields, then its properties, then its mappings, then its parameters and listeners. + +`resultVariable` is refused beside `class` or `delegate` because `parseServiceTaskLike` fails the deployment there, with the message `'resultVariableName' not supported for elements using ''`. +The validator quotes that refusal under the element name the engine uses and names the fix, and the importer refuses the same document with the same text. +Like the built-in field checks, these checks ask only once exactly one binding is written; a task with two bindings has that conflict to fix first. + +### Consequences + +- Good, because there is one keyword surface to teach: an author reads that a step can call a class, an expression, a delegate expression, an external worker, or a behaviour the engine builds itself, as five keys of the same statement rather than as five statements that compile to the same element. +- Good, because no word is reserved for any of them: `external`, `mail`, `shell`, `when`, and `property` stay ordinary identifiers, and `error` already headed every code site. +- Good, because boundary-event host enumerations, the reserved-step-name check, and every other place the grammar or validator listed task kinds side by side name `service` once. +- Good, because a document a real deployment produced, carrying an external, mail, or shell task with its priority, its properties, and its failure mappings, imports and recompiles without a warning. +- Good, because a mapping's code has a declaration to jump to and a rename that reaches it, and a script that would fail deployment over its result variable draws an error at the setting. +- Bad, because a reader skimming XML sees `operaton:type="external"` as a distinguishing marker that the source surfaces as a setting in a step's parens, and nothing in `service Ship(topic: "...")` tells a reader who does not know `topic` that this step runs in a separately deployed process. +- Bad, because the shared block holds a fourth member shape, told from a parameter at the third token rather than by a head word. +- Bad, because `externalTask` is a name the validator admits in one position and warns on in every other. +- Bad, because the shell flag's case-sensitivity gap, a written `"True"` deploying and reading as `false`, is a fact this surface documents rather than one it closes. +- Bad, because the engine's refusal text for a result variable is quoted by the validator and by the importer separately, so a change in the engine's wording is two edits. + +### Confirmation + +The `external-task` golden pair under `tests/golden` compiles to the shape `parseExternalServiceTask` reads, imports back with no warning, and prints to source that re-desugars to the same IR; its suite asserts the complete binding of all three tasks at every hop and the frozen `extensionElements` text. +The `mail-and-shell` golden pair carries a mail `service` task and a shell `send` task and round-trips byte for byte, and the `engine-attributes` pair binds its `AssessBodywork` task with an expression beside its `resultVariable`. +The binding tables in `packages/language/test/validating.test.ts` pin the exactly-one-binding message, the required-field, undeclared-field, and shell-value-shape refusals, each external-extra diagnostic, and `resultVariable` beside each of the five bindings, and the import tables in `packages/transform/test/xml-to-ir.test.ts` pin each refusal and warning. +`tests/e2e/forms-and-external-tasks.test.ts` fetches the charge from a real engine and fails it with a matching and a non-matching message, and the mail end-to-end case asserts the engine refuses a mail task with no body, citing the same message `validateFieldDeclarationsForEmail` throws. + +## More Information + +The binding union and its variants are declared in `packages/transform/src/ir/types.ts`, the key and field tables in `packages/language/src/vocabulary.ts`, the validator's binding tables in `packages/language/src/bpmn-script-validator.ts`, and the importer's checks in `readServiceTaskBinding` in `packages/transform/src/xml-to-ir.ts`. + +Related decisions: ADR-0007 (the IR between the AST and BPMN XML, whose tagged union these keys are the surface of). +ADR-0012 (the honest import contract behind every refusal and warning above). +ADR-0018 (field injection, whose placement rule extends to a third binding, and the `property` direction shared with an external task). +ADR-0008 (the moddle fork, which gains `taskPriority` on `ServiceTaskLike` and a concrete `ErrorEventDefinition` type beside the trait carrying the catch-side variables). diff --git a/docs/decisions/0018-compensation-through-event-subprocesses.md b/docs/decisions/0018-compensation-through-event-subprocesses.md deleted file mode 100644 index fa9e9fe..0000000 --- a/docs/decisions/0018-compensation-through-event-subprocesses.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -status: accepted -date: 2026-07-18 -decision-makers: Marlon Kranz ---- - -# Compensation Only as a Subprocess Undo Block: One Handler Form, No Targeted Throws - -## Context and Problem Statement - -BPMN gives a compensation handler two different attachment points. -The first is a boundary event sitting on the activity being compensated, connected by an association to a separate activity marked `isForCompensation`, an activity that never runs in the normal flow and only executes when compensation reaches it. -The second is a compensation event sub-process: a `bpmn:subProcess` with `triggeredByEvent="true"` whose start event carries a `compensateEventDefinition`, nested directly inside the embedded sub-process it compensates. -The engine's own reference documentation treats the two as alternatives for the same intent, reversing the completed work of an activity, with the event-sub-process form restricted to embedded sub-processes (it is not propagated to the separate process instance a call activity spawns) and capped at one per sub-process level. -The engine additionally does not support `waitForCompletion="false"` on a compensation intermediate throw event: the throw always blocks until the triggered compensation finishes, regardless of what the attribute says. - -BPMNscript already has a subprocess construct and an established `on`/`throw`/`emit` vocabulary for every other event kind. -The question this decision settles is which of BPMN's two attachment shapes, or a third designed one, the DSL exposes for compensation, and how throwing compensation fits the verb pair the rest of the event layer already uses. - -## Decision Drivers - -- The engine itself restricts one of the two BPMN attachment forms (compensation event sub-processes) to embedded sub-processes and one per level; a surface that pretends both forms are equally general everywhere would produce documents the engine does not actually support. -- The audience writes processes without BPMN vocabulary already established for `on`/`throw`/`emit`; introducing a second attachment mechanism (boundary event, association, a normally-dormant activity) for one event kind breaks the pattern every other trigger kind follows. -- `waitForCompletion="false"` and `activityRef`-targeted throws only matter in combination with boundary-attached handlers; neither has a use once boundary handlers are out of scope. -- The honest import contract (ADR-0014) already distinguishes constructs the DSL refuses from ones it silently drops; a construct this decision does not model needs to land on one side of that line, not the other. - -## Considered Options - -- Compensation handlers exist only as a subprocess's `on compensation` block (a compensation event sub-process nested in the `subprocess` it compensates), thrown with the existing `throw`/`emit` verb pair and no code -- Boundary compensation events with an associated `isForCompensation` activity, mirroring BPMN's other attachment form -- A throw targeted at a specific activity by name (`activityRef`), giving one throw statement the ability to compensate a single named unit of work rather than the nearest enclosing scope -- A dedicated `compensate` verb, kept separate from `emit`, for the continuing form - -## Decision Outcome - -Chosen option: "Compensation handlers exist only as a subprocess's `on compensation` block, thrown with the existing `throw`/`emit` pair and no code", because it is the only option that keeps compensation inside the vocabulary the rest of the event layer already uses, and because the rejected alternatives either duplicate a granularity the subprocess construct already provides or only pay for themselves once that duplication exists. - -The handler. -A subprocess's undo steps are written directly in its body as `on compensation { ... }`, the steps that reverse whatever that subprocess already finished doing. -`on compensation` lowers to a `bpmn:subProcess triggeredByEvent="true"` nested inside the `bpmn:subProcess` it undoes, whose start event carries a `compensateEventDefinition`; there is at most one per subprocess, matching the engine's own restriction. -It runs when compensation reaches the subprocess, and only for a subprocess instance that actually completed, since an instance that never finished has nothing to undo. -Compensation reaches it by two routes: `throw compensation` or `emit compensation` in an enclosing scope, and a cancel end that gives up an enclosing `attempt` block. -Operaton's `CancelEndEventActivityBehavior` throws the compensation itself on that second route, before handing the run to that block's cancel handler. -Because a compensation event sub-process only exists inside an embedded sub-process, the construct is only legal directly inside a `subprocess` body or an `attempt` body; a process cannot compensate itself, so `on compensation` at process level is rejected rather than approximated. - -The throw. -Compensation joins `throw`/`emit` on the same terms every other trigger kind already established (ADR-0016): `throw compensation` ends the current path after undoing the nearest enclosing scope's completed work, `emit compensation` undoes it and continues. -Neither carries a code, because compensation has no name to correlate against, unlike error or escalation, so there is nothing for a code string to select. -`emit compensation` always waits for the triggered undo to finish before falling through, matching the engine, which does not honor `waitForCompletion="false"` in the first place; the surface has no attribute for a behavior the engine ignores. - -What is refused, not modeled. -Boundary compensation events, `activityRef`-targeted throws, `waitForCompletion="false"`, and `isForCompensation` activities are all refused on import under the same contract ADR-0014 already established for constructs the DSL cannot express without changing what the process does. -None of the four is silently dropped or approximated by the nearest `on compensation` equivalent; an import encountering any of them stops with a diagnostic naming the construct. -When the boundary event and the `isForCompensation` activity are paired through a `bpmn:association`, the diagnostic also names the compensated activity, the boundary event, and the handler, and prints the `subprocess`/`on compensation` rewrite an author would write by hand. - -Per-activity undo. -BPMN's boundary-event form exists chiefly to compensate a single activity rather than a whole embedded sub-process. -BPMNscript reaches the same granularity without a second mechanism: wrapping the one step that needs to be undoable in its own `subprocess` gives it its own `on compensation` block, scoped to exactly that step. -A single task that needs an undo path becomes a named unit of work with its own undo block, the same construct already used everywhere else compensation applies, at whatever granularity the author needs. - -### Consequences - -- Good, because a saga (several steps, each undoable, unwound in reverse when a later step fails) round-trips through the DSL and executes on the engine using only constructs the surface already has: `subprocess`, `on`, `throw`, `emit`. -- Good, because compensation reuses the same verb pair, the same catch-block reading, and the same container rule every other trigger kind already follows; there is nothing new to learn beyond "undo block" and "no code." -- Good, because an import carrying the boundary-event pattern, a targeted throw, or `waitForCompletion="false"` refuses outright, rather than reporting nothing and reproducing a different execution than the one the imported document actually specifies. -- Bad, because an author porting an existing BPMN model that already uses boundary compensation events must restructure it, pulling the compensated activity and its handler into a `subprocess`, rather than have the importer translate the pattern automatically. -- Bad, because per-activity compensation on many independent steps means one small `subprocess` wrapper per step; the surface trades a second attachment mechanism for a per-step wrapping requirement instead. -- Neutral, because compensation's ordering (reverse of execution), invocation count, and the variable snapshot an undo block sees are entirely engine behavior. - ADR-0016's "nothing here to model" reasoning for escalation delivery applies the same way to compensation ordering. - -### Confirmation - -Round-trip coverage in `packages/transform/test` pins a subprocess with an `on compensation` block lowering to a nested `triggeredByEvent` sub-process with exactly one `compensateEventDefinition`-carrying start event, and `throw compensation`/`emit compensation` lowering to a compensation end event and a compensation intermediate throw respectively, both without an `activityRef`. -Refusal coverage in the same suite pins a boundary compensation event, an `activityRef`-targeted throw, `waitForCompletion="false"`, and an `isForCompensation` activity each producing a distinct refusal rather than a silently altered import. -`packages/language/test/validating.test.ts` pins the container rule: an undo block directly inside a `subprocess` body validates and so does one directly inside an `attempt` body, the same block at process level, inside another handler's body, or inside an `if` branch is rejected, and a second undo block in one `subprocess` or one `attempt` is rejected as a duplicate. -Two suites under `tests/e2e/` deploy to a real Operaton and confirm that an undo block executes on a running engine rather than only lowering to the right shape. -`service-boundary-and-compensation.test.ts` covers the route this decision defines: the `emit compensation` a process-level error handler raises reaches the undo block of a subprocess that had already completed, releasing the seat it reserved. -`booking-attempt.test.ts` covers the route ADR-0028 adds: a completed subprocess's undo steps run once the `attempt` block around it is given up. - -## Pros and Cons of the Options - -### Compensation handlers only as a subprocess `on compensation` block - -- Good, because it reuses the subprocess construct and the `on`/`throw`/`emit` vocabulary the rest of the event layer already established, instead of adding a second attachment mechanism for one event kind. -- Good, because it matches the engine's own restriction, that a compensation event sub-process is only legal inside an embedded sub-process, rather than pretending a broader form exists. -- Bad, because per-activity compensation costs a wrapping `subprocess` per step instead of a boundary event attached directly to the step. - -### Boundary compensation events with an `isForCompensation` associated activity - -- Good, because it lets a single activity carry its own compensation handler without wrapping it in a subprocess. -- Bad, because it is a whole second attachment axis (a boundary event, an association, and an activity that never runs in the normal flow) for a granularity level the wrap-in-`subprocess` pattern already reaches with constructs the surface already has. -- Bad, because it introduces a kind of activity (`isForCompensation`, dormant except when triggered) with no equivalent anywhere else in the grammar, breaking the rule that every construct maps to something the author writes and runs directly. - -### A throw targeted at a specific activity by name (`activityRef`) - -- Good, because it lets one throw compensate a single named unit of work rather than everything the nearest enclosing scope completed. -- Bad, because it only has a target worth naming once boundary-attached handlers exist to be the targets; with those rejected, there is nothing distinct for `activityRef` to select beyond the nearest enclosing scope `throw compensation` already reaches. -- Bad, because naming an activity across scope boundaries needs a reference-resolution mechanism the grammar does not otherwise require anywhere else in the event layer. - -### A dedicated `compensate` verb, separate from `emit` - -- Good, because a compensation-specific verb name reads unambiguously as compensation rather than sharing a word used for escalation and signal. -- Bad, because it grows the event layer's continuing-throw vocabulary to two words (`emit`, `compensate`) for one behavior, fire and continue, that `emit` already spans for every other trigger kind with a continuing form. -- Bad, because it would need to be a reserved keyword to avoid ambiguity with `emit`, unlike every trigger name and binding field, which stay soft identifiers precisely so the audience's ordinary variable names keep working. - -## More Information - -Related decisions: ADR-0013 (the target audience and the no-boilerplate rule, the reason a wrapping `subprocess` is preferred over a second attachment mechanism for per-activity granularity). -ADR-0014 (the honest import contract, the reason boundary compensation events, `activityRef`-targeted throws, `waitForCompletion="false"`, and `isForCompensation` activities are refused rather than approximated). -ADR-0016 (the `throw`/`emit` terminality rule and the soft-word design, both of which compensation reuses without adding a reserved word or a position-dependent reading). -ADR-0017 (the trigger-payload design, under which compensation is the one trigger kind whose payload is empty, since it has no code, name, or condition to carry). - -Extended by ADR-0019 (boundary events attached to an activity), which adds the attachment axis for six other trigger kinds while leaving this decision intact: compensation is still reachable only as a subprocess undo block, and a compensation boundary event is still refused on import. - -Extended by ADR-0028 (work that can be given up), which adds a second route into these same undo blocks: a cancel end gives up the block around them, and the engine throws the compensation itself, with no `throw compensation` written anywhere in the source. diff --git a/docs/decisions/0018-forms-and-field-injection.md b/docs/decisions/0018-forms-and-field-injection.md new file mode 100644 index 0000000..7585231 --- /dev/null +++ b/docs/decisions/0018-forms-and-field-injection.md @@ -0,0 +1,108 @@ +--- +status: accepted +date: 2026-09-10 +decision-makers: Marlon Kranz +--- + +# Forms and field injection + +## Context and Problem Statement + +Three Operaton surfaces attach to an element this language already models rather than to a new BPMN construct. +A service task, a send task, a business rule task, and both listener kinds can carry Operaton fields: name and value pairs injected into the Java bean the class or delegate binding instantiates. +A user task can carry a form reference by id and version, an alternative to the fixed form key the surface already writes. +A form field carries four things beyond its id, type, label and default: a validation list, an enum's values, a date pattern, and a property list, read by `DefaultFormHandler.parseValidation`, `DefaultFormHandler.parseProperties`, and `FormTypes.parseFormPropertyType`. +None of the three needs a new flow node or a new statement keyword; each needs a place inside a member block or a settings list an element already has. + +What is open for a field is where that place is, since Operaton's moddle schema admits one almost anywhere `extensionElements` can appear while its parser reads one off only a narrow set of bindings. +`IoParameter` already spells `input greeting = "hello"` and `output result = "${x}"` inside the brace block a service-task-like statement opens, and a field is the same shape: a name, an `=`, and a value that is either a literal or a raw expression. +A field also has two XML slots for the same runtime value, `stringValue` as an attribute and `` as a child, and the surface has to pick one spelling rather than grow a second vocabulary for a distinction no author can act on. + +What is open for a form reference is which element gets it and what has to go with it: the schema allows one on a user task and on a start event, while the parser refuses to deploy a form reference with no binding or one beside a form key. +What is open for a form field is where its four parts go, and what the import does with a shape the engine deploys but this surface cannot check. + +## Decision Drivers + +- Every reserved keyword is a name an author permanently loses. +- Operaton's own parser decides what is legal, not what its schema merely allows to be written. +- The one bracket shape of ADR-0011: scalar settings in the parens, structured members in the braces. +- An illegal combination should be unrepresentable in the IR's type rather than merely checked for, continuing the convention ADR-0021 set for engine attributes generally. +- A closed set of constraint names the validator can check. +- The import contract commits this tool to refusing what the engine refuses and warning on what changes, never dropping in silence something that would change what the engine does (ADR-0012). + +## Considered Options + +- Reuse `IoParameter` as a third direction, `field`: it already parses any identifier in the direction position, so a direction costs no grammar rule and no reserved word and inherits the duplicate-key check, the highlighting, and the completion, at the price of one rule carrying two kinds of member. +- A dedicated `FieldInjection` rule: the rule name alone would say what a member is, but `field` becomes a word an author permanently loses, and the placement and value rules are the same whichever rule carries them. +- A nested `fields { }` sub-block: it collects an element's fields under one head, but nests a second member-block shape inside the first, where `input` and `output` already repeat with none. +- Two DSL spellings, one for `stringValue` and one for ``: the slot the exporter writes would be visible in the source, but the two slots behave identically until the value reads `${...}`, which the single spelling already tells apart, and choosing between them requires knowing Operaton's XML. +- `formRef` on a user task only: it sits beside `formKey`, so both ways of naming a form are read in one place, at the price of a start event's form reference this surface cannot express. +- `formRef` on a user task and a start event, matching Operaton's schema: it accepts every placement the schema allows, but a start event carries no `formKey` here either, so it closes one half of that asymmetry and leaves the other standing. +- Constraints as settings in the field's parens, values and properties as members of its braces: it is the one bracket shape, so a reader derives a field's spelling from any other element, at the price of two member shapes under one block. +- Constraints as member lines beside the values: a field would have one member list and no parens, but `required` on its own line reads as a part the field is built from rather than a rule it is checked under. +- A nested `validation { }` block inside the field: it mirrors the XML, and nests a second block shape inside a field, the cost a `fields { }` block was already refused for. +- `enum` and `property` as soft words rather than keywords: `var enum: string`, a step named `property`, and every existing file stay legal, and an enum value cannot be named by a reserved word, the limit a field id and a parameter name already have. +- Constraints in the IR as an ordered list rather than flat named fields: the engine's validation order survives the round trip with one map per direction, where flat fields lose the XML order and need a plumbing site per name per direction. +- A fixed `validator:` key rather than an open key set: an unknown name is a diagnostic here instead of a failed deployment there, and a validator registered under its own engine name is written `validator: ""` instead. + +## Decision Outcome + +The three rest on the same kind of evidence: what Operaton's own parser reads off an element, not what its schema merely permits to be written there. +Field injection reuses `IoParameter` as a direction, `formRef` lands on a user task alone and requires a binding, and a form field takes constraints in its parens and members in its braces with every word soft, an ordered constraint list, and a closed key set. + +`field greeting = "hello"` sits beside `input` and `output` inside the same member block a service task, a send task, and a business rule task open, and inside a listener's own block too. +`IoParameter` already parses `direction=ID name=ID '=' value=IoValue` for any identifier in the direction position, so the grammar accepts `field` with no new rule at all, and it is the validator, not the parser, that checks which directions a given owner allows. +A fourth direction, `property`, joins the three the same way. +The one grammar edit this decision needs is the brace block a listener gains. + +That brace block is forced, not chosen, once a field needs to repeat. +Spelling it as a setting instead, `service S(field: "greeting=hello")`, hits two limits the settings surface already has: `checkDuplicateKeys` rejects a repeated setting key, and a setting's value is typed as an expression, which admits no map literal that could hold several named fields at once. +A member block, where `params+=IoParameter*` already repeats freely, is the only surface with room for more than one. +Operaton hands a field list to a class-bound and a delegate-expression-bound listener of either kind exactly as it does to a service task, so `on (...) { }` takes the same rule the task's own block uses. +A field is none of the three things a listener's block still refuses to nest: it is not a form, not an input or output parameter, and not a nested listener (ADR-0020). + +A field's value has one DSL spelling regardless of which of Operaton's two XML slots it lowers to: a quoted string lowers to the `stringValue` attribute and a `"${...}"` raw expression to an `` child, the same literal-versus-expression split `renderIoValue` already draws for an io parameter's value. +The moddle already normalizes an `expression` attribute into a child element on its own, so a normalizing round trip is not new here. +The one shape this rule cannot close is a `stringValue` that reads `${...}`: writing it back would turn a literal Operaton injects verbatim into an expression Operaton evaluates instead, so that shape keeps a drop warning naming the reason rather than being carried. + +Where a field is legal follows the engine, not the schema. +`BpmnParse.parseServiceTaskLike` hands `parseFieldDeclarations` to `ClassDelegateActivityBehavior`, to `ServiceTaskDelegateExpressionActivityBehavior`, and to the mail and shell behaviours it instantiates itself; nothing else takes one. +`ServiceTaskExpressionActivityBehavior` is built from an expression and a result variable with no field list, and `parseExternalServiceTask` is never handed one either. +`parseExecutionListener` and `parseTaskListener` repeat the identical split for both listener kinds, neither of which binds a built-in type. + +`formRef` lands on a user task only, matching where `formKey` already sits. +Operaton's schema also allows a `formRef` on a start event, but this language does not carry `formKey` on a start event either, so giving `formRef` a start-event home would fix half of an existing asymmetry and leave the other half standing, which is a larger, separate change. +A `formRef` requires a binding because `BpmnParse.parseFormDefinition` calls `addError` when `formKey` and `formRef` are both present on one task, and again when `formRef` carries a binding that is absent or outside `latest`, `deployment`, and `version`. +The IR carries a form reference as a key and a binding with the binding required, which is what makes the invalid state unrepresentable rather than merely refused later, and both hops report it: when Operaton itself refuses to deploy a shape, the importer refuses it too rather than carrying something that would never run. + +A form field reads `id: type "label" = default (settings) { members }`, which is the bracket shape of ADR-0011 applied at no grammar cost. +The grammar's type rule is `VarType | ID`, so `enum` costs no keyword, and the validator holds the word to the five types the engine registers. +Constraints are written in the order the engine validates them, and the IR keeps that order as a list keyed by a closed name union rather than as flat named fields. +That key set stays closed even though `customFormTypes` and `customFormFieldValidators` can register more: engine configuration is something this surface cannot see, so a name registered there is indistinguishable from a typo, and `validator` with a class name reaches a custom validator anyway. + +### Consequences + +- Good, because no word is reserved by any of the three. + `field`, `property`, `enum` and the form-field setting keys stay ordinary identifiers, and `formRef` is one more setting key rather than a keyword. +- Good, because a field and a property reuse the io-parameter machinery already built for `input` and `output`: the duplicate-key check, the highlighting, and the completion all extend to them for free. +- Good, because the illegal states are unrepresentable rather than merely checked. + A field has no binding-key slot to sit in on an expression, a topic, or a decision binding, a form reference with no binding cannot be constructed in the IR at all, and an illegal constraint name is a type error. +- Good, because a document Operaton itself refuses to deploy is refused on import, and a field the engine would deploy and then fail on every submission or render is a diagnostic naming the fix. + A Modeler-drawn form carrying any of the four parts imports, prints, and recompiles to the same IR. +- Bad, because a listener's brace block now holds two kinds of member under the same grammar rule, an io parameter and a field, so a reader has to check the direction word rather than the rule name to know which one they are looking at. + A form field's braces hold two member shapes told apart at the third token for the same reason. +- Bad, because the field-placement rule is a fact about Operaton's parser, not its schema, so it has to be documented rather than discovered from the moddle alone. +- Bad, because a constraint the engine deploys on the wrong type is carried on import with a warning, so the printed script does not compile until the author moves it. + +### Confirmation + +The `forms` golden pair under `tests/golden` compiles to the exact `operaton:formField` shape, imports back with no warning, and prints to source that re-desugars to the same IR, and its suite asserts the complete `FormField[]` of both forms at every hop. +The validator table in `packages/language/test/validating.test.ts` pins each diagnostic, and the import tables in `packages/transform/test/xml-to-ir.test.ts` pin each refusal, drop, carry, and rewrite. + +## More Information + +Related decisions: ADR-0008 (the Operaton moddle extension fork, which carries the `Properties`, `Property`, `Validation`, and `Constraint` types). +ADR-0012 (the honest import contract behind every refusal and warning here). +ADR-0017 (the binding keys a field rides, and the `property` direction shared with an external task). +ADR-0020 (the listener surface whose brace block holds a field beside its bindings). +ADR-0021 (engine attributes as named IR fields, the typing rule a field, a form reference, and the constraint name union all follow). diff --git a/docs/decisions/0019-a-call-activitys-variable-mapping.md b/docs/decisions/0019-a-call-activitys-variable-mapping.md new file mode 100644 index 0000000..c906921 --- /dev/null +++ b/docs/decisions/0019-a-call-activitys-variable-mapping.md @@ -0,0 +1,80 @@ +--- +status: accepted +date: 2026-09-11 +decision-makers: Marlon Kranz +--- + +# A call activity's variable mapping + +## Context and Problem Statement + +Operaton lets a call activity name a Java class or a bean expression that computes its in and out variable mapping in code, written as `operaton:variableMappingClass` or `operaton:variableMappingDelegateExpression` on the element. +Both mechanisms run, and the delegate runs last on each side. +`BpmnParse.parseCallActivity` reads `operaton:in` and `operaton:out` unconditionally, with no guard on either attribute. +`CallableElementActivityBehavior.execute` builds the variable map from the in mappings and hands the already populated map to the delegate, which mutates it, and `CallableElementActivityBehavior.passOutputVariables` applies the out mappings first and runs the delegate after. +A delegate therefore overrides a declared mapping rather than replacing it, so a document carrying one loses nothing on the way in. + +What is open is everything else: what the two attributes spell on the authoring surface, whether that spelling sits in the call's parens or its braces, what shape the IR gives the pair, and what happens on a document that sets both at once. + +## Decision Drivers + +- The honest import contract (ADR-0012) reserves a refusal for loss that changes execution, and the engine runs a declared mapping and a delegate alike. +- An injected field is legal on a `class` or a `delegate` binding, a rule about binding words that stays keyed on a word alone only while no other binding spells itself with them (ADR-0018). +- The one bracket shape (ADR-0011) puts a setting that describes the element in the parens and a list with members of its own in the braces. +- An illegal combination should be unrepresentable in the IR's type rather than merely checked for, the convention ADR-0021 set for engine attributes. +- Operaton resolves a document setting both attributes with an if/else-if in `BpmnParse.parseCallActivity`: the class wins and the delegate expression is dropped with no diagnostic. + +## Considered Options + +- `mapper` for the class and `mapperDelegate` for the expression: the field rule stays a rule about the words `class` and `delegate`, true at every site that states it, at the price of two words describing a Java binding without using either word the surface uses for one everywhere else. +- `class` and `delegate`, the words a service task's own bindings use: they match the Java shape and the attribute names the engine reads, but a call activity's variable mapping receives no field list, so the field rule would have to name an element as well as a word at each of the seven sites that state it, `FIELD_HOSTS_MESSAGE` included. +- A setting in the call's parens: a variable mapping describes the call itself and has no members, which is the line ADR-0011 draws, and the offer order, the printed order, and the completion snippet all come from one row; a long call then carries the binding and the mappings in two different brackets. +- A member of the call's brace block, beside its `in` and `out` mappings: it would sit beside the mappings the delegate computes, but the braces hold lists whose members repeat, where a call names one mapping delegate or none, and it would need a grammar rule where a setting key needs none. +- A `CallVariableMapper` of its own, tagged `class` or `delegateExpression`: the two attributes Operaton reads are exactly the two tags, so a third spelling cannot be constructed and no slot is unreachable, at the price of a second class-or-expression union in the IR. +- The `CodeBinding` a service task and both listener kinds already carry: a call activity's delegate would be written the way a service task's is, but its `expression` member has no attribute to lower to and its `fields` slot would be present and always empty. +- An author-time error, and on import the class with a warning naming the dropped delegate: the author is told at the one moment a choice is still open and the import keeps what the engine keeps, at the price of two hops answering the same question differently. +- Mirroring the engine on both hops, taking the class in silence: one rule covers both directions, and it reproduces a silent drop on the hop where a diagnostic costs nothing. + +## Decision Outcome + +Chosen: `mapper` and `mapperDelegate` in the parens, carried by a `CallVariableMapper` of their own, mutually exclusive at author time and resolved to the class with a warning on import. + +A variable mapping delegate has the Java shape of a `class` or a `delegate` binding and none of its behaviour, because Operaton hands it no field list: `instantiateDelegateClass` constructs the delegate with a null field-declaration list, and `ClassDelegateUtil.applyFieldDeclaration` returns without setting anything when the list is null. +Spelling it with those two words would make every site that states the field rule false at once and re-key the rule from which word to which word on which element. +A distinct word is the honest one anyway: on a service task `class` names what runs, while here the delegate names what computes the data crossing the process boundary. + +A variable mapping is one setting on the call rather than a list with members of its own, so it goes where ADR-0011 puts a setting, and that one row is the whole authoring surface: the grammar already accepts any identifier as a setting key, completion derives its offer from the element's own keys, and the printer prints in the same order. + +The IR carries a `CallVariableMapper` tagged `class` or `delegateExpression` rather than reusing `CodeBinding`, on two counts the engine decides. +`CodeBinding`'s `expression` member has no counterpart on a call activity, because Operaton reads exactly two attributes there and declares no `operaton:variableMappingExpression`, so reusing the type would admit a third spelling that a hand-written validator rule then has to refuse. +Its `fields` slot would be present and always empty, since that slot exists for the two behaviours Operaton injects into and a variable mapping is neither. + +Two spellings on one call is an author-time error, delegated to `checkAtMostOneBinding` so the sentence is the one every other exclusive pair on the surface prints. +Import cannot refuse the same shape: Operaton deploys and runs the document with the class, so under ADR-0012 there is no execution loss to refuse for, and the import takes the class and reports the dropped delegate as shadowed. +There is no rule between a mapper and the call's `in` and `out` mappings, because the engine runs both. +The round-trip fixture therefore authors a mapper beside an `in` and an `out` mapping rather than alone, and a reader who adds an exclusivity rule turns it red. + +Either spelling names an implementation of Operaton's `DelegateVariableMapping`, whose two methods are `mapInputVariables(DelegateExecution, VariableMap)` and `mapOutputVariables(DelegateExecution, VariableScope)`. +Nothing on the authoring or the import hop can check that the named class implements it, since `resolveDelegateClass` and `instantiateDelegateClass` resolve the class when the call executes rather than when the document deploys. + +### Consequences + +- Good, because a document Operaton deploys and runs imports and re-exports. +- Good, because the field rule stays keyed on a word alone. + A mapper carries no injected field and takes none, so that rule needs no element-by-element exception and no site that states it has to change. +- Good, because a mapper and the declared `in` and `out` mappings sit in one call with no rule between them, which is what the engine does with them. +- Bad, because the call surface gains two words for a binding shaped in Java exactly like a `class` or a `delegate` binding and deliberately not spelled like one. + A reader has to be told why rather than read it off the surface. +- Bad, because neither the validator nor the importer can check a mapper. + The class is resolved when the call executes, so a misspelled name, or one whose class implements nothing, passes both hops and fails in the engine. + +### Confirmation + +The mapper block in `tests/call-activity.round-trip.test.ts` authors each spelling beside an `in` and an `out` mapping on the same call and asserts the whole IR node, the serialized Operaton attribute, an empty warning list, an identical re-import, the printed setting in its canonical position, and that the printed source revalidates. +The import table in `packages/transform/test/xml-to-ir.test.ts` pins both attributes under both namespace prefixes and the both-set case, asserting the whole mapper object and the whole warning list rather than membership in either. + +## More Information + +Related decisions: ADR-0008 (the Operaton moddle extension fork, which declares both attributes, without which moddle drops them on write). +ADR-0018 (the field rule this spelling keeps keyed on a word alone). +ADR-0021 (engine attributes as named IR fields, the precedent `CallVariableMapper` extends). diff --git a/docs/decisions/0019-boundary-events-attached-to-an-activity.md b/docs/decisions/0019-boundary-events-attached-to-an-activity.md deleted file mode 100644 index d7c58d1..0000000 --- a/docs/decisions/0019-boundary-events-attached-to-an-activity.md +++ /dev/null @@ -1,221 +0,0 @@ ---- -status: accepted -date: 2026-07-23 -decision-makers: Marlon Kranz ---- - -# Boundary Events Attached to an Activity: Host Syntax, Trigger Scope, and a Self-Contained Body - -## Context and Problem Statement - -Every `on` handler in BPMNscript compiles to an event sub-process that guards its whole enclosing process or subprocess body (ADR-0016). -BPMN has a second, narrower attachment for the same catch vocabulary: a `bpmn:boundaryEvent` docked directly on one activity, which only catches while that activity is running and, depending on `cancelActivity`, either cancels it the moment the trigger fires or lets it keep running alongside the catch. -Giving `on` an optional host turns it into this second attachment form without introducing a competing keyword or a second handler construct. - -Four questions have to be settled together, because each interacts with a decision already recorded for the host-less handler. -How is a host spelled without colliding with a handler shape that already exists (`on timer("PT2H")`)? -Which of the language's eight catchable triggers may attach as a boundary event, and what happens to the two this decision does not admit? -What does a hosted handler's body compile to, given that the host-less form's meaning must stay exactly what ADR-0016 already fixed it to be? -And how is a token that appears directly at a boundary event, without ever traversing the main flow, represented in the dominator-based restructuring analysis (ADR-0009) that turns flat IR back into structured DSL? - -## Decision Drivers - -- The grammar already commits every trigger word to being a soft identifier (ADR-0016), so a new syntax slot cannot recover disambiguation from the trigger word's token type, only from structure the grammar itself contributes at parse time. -- The host-less `on` handler's meaning, an event sub-process, is exercised by existing golden fixtures and must not move: a hosted handler is a new attachment form layered next to it, not a replacement for it. -- Operaton's own parser (`BpmnParse.parseBoundaryEvents`) is the actual authority on which triggers, and which host shapes, a boundary event may legally carry; a surface stricter than the engine owes a reason. -- The restructuring analysis (ADR-0009) already commits to being total over every valid IR. - A boundary event is the IR's first flow-element shape with outgoing edges but no incoming ones, a shape the analysis has never had to reason about, so the decision has to say explicitly what that shape means for reachability and dominance, not leave it implicit. -- The honest import contract (ADR-0014) and the compensation decision (ADR-0018) both carry commitments this feature must not invalidate: no silent mangling on import, and compensation stays a subprocess undo block rather than gaining a second attachment mechanism. - -## Considered Options - -For the host separator: - -- A colon between host and trigger (`on Pack: error(X)`) -- A bare space (`on Pack error(X)`) -- A postfix clause (`on error(X) at Pack`) - -For the trigger scope: - -- All eight of the language's catchable triggers as boundary events -- Every trigger but compensation and cancel -- A smaller hand-picked subset - -For the body shape: - -- The body lowers inline into the host's container and rejoins the main flow only through an explicit `goto` -- The body falls through to whatever statement follows the host -- The body is wrapped in a container of its own, the way a host-less handler already is - -For the CFG treatment: - -- A boundary event is wired to the restructuring analysis's virtual entry like a start event -- It is left unwired entirely -- It is made the root of a second analysis run over the same container - -For the id scheme: - -- The boundary event's id is derived from the host and the trigger -- It reuses the positional `EventSubProcess_` scheme already used for host-less handlers - -## Decision Outcome - -Five related decisions, chosen together because each keeps the same host-less meaning intact wherever the new attachment form does not have to touch it, and spends new syntax, a new reserved word, or a new pass only where an alternative would either misparse an existing form, mangle an import, or leave an escape chain permanently unreadable by the restructurer. - -The colon separator. -A hosted handler is written `on Pack: error(X) { ... }`: the host name, a colon, then the trigger exactly as a host-less handler already writes it. -The bare form originally proposed, `on Pack error(X)`, is ambiguous with a handler that already parses today: a host-less handler carrying a code and a hosted handler carrying none both read as `on` followed by two identifiers, because every trigger word lexes as a plain identifier by deliberate design (ADR-0016) rather than as a grammar keyword. -The ambiguity sits in the token text, not the token type, so no amount of lookahead over token types can tell a host from the trigger that follows it. -Only the words themselves would, and reserving them as keywords is exactly what ADR-0016 already ruled out. -The colon resolves the ambiguity at the second token, before either reading has to be committed to: plain two-token lookahead already suffices, well inside what Langium's default parser handles without any special grammar annotation. - -Rejected, having been worked through directly: promoting the eight trigger words to real keywords and then re-admitting them as ordinary identifiers through a data-type escape hatch, so `var message: string` keeps parsing. -This alternative does work, but at a cost disproportionate to what the colon buys for free. -Every site in the grammar that currently accepts a soft trigger word as an identifier would need a parallel keyword-or-identifier rule, some two dozen individual grammar productions, which reopens the soft-trigger-word decision ADR-0016 already closed for reasons unrelated to boundary events. -It also does not fully remove the ambiguity: the grammar generator still reports a residual ambiguous alternative between the keyword-led host form and the timer form, resolved by picking whichever alternative the generator orders first and reported only as a warning on the build's stderr, a resolution an author has no visibility into and that a future grammar edit could silently invert. -The colon leaves no such residual: it is a structural token the grammar decides on unambiguously, not a policy the generator applies silently. -Also rejected: a postfix host clause, `on error(X) at Pack`, which reuses the word `at` that a timer's date key already claims (`on timer(at: "2026-08-01T09:00:00")`), reopening the exact kind of two-reading collision the colon exists to avoid, just moved to a different position in the handler. - -The six-trigger scope. -Message, timer, signal, and conditional boundary events are interrupting or non-interrupting (`alongside`) on any activity. -Error is always interrupting, on any activity, reusing the same `alongside: false` restriction already enforced for a host-less error handler rather than adding a boundary-specific rule. -Escalation is interrupting or non-interrupting but only on a subprocess, an `attempt` block, a call, or a user task. -Operaton gates that boundary on `attachedActivity.isSubProcessScope()`, and `parseTransaction` sets that flag exactly as `parseSubProcess` does, so an `attempt` block hosts an escalation on the same terms an ordinary block does. -These restrictions are read from Operaton's own boundary-event parsing rather than invented, so every boundary event the surface admits is one the engine deploys; where the surface is stricter than the engine, as it is for error's cancellation mode and for compensation, this decision says why. - -Compensation is excluded on the grounds ADR-0018 already established for it generally: BPMN attaches compensation through a `bpmn:association` and an `isForCompensation` activity, a different attachment mechanism entirely. -This exclusion preserves ADR-0018's decision rather than superseding it, since `on compensation` remains only a subprocess's own undo block, never a boundary form. -It also keeps the import-side `refuseIfForCompensation` guard honest for exactly the reason ADR-0018 gives it: an activity marked `isForCompensation` still can never run, because a `bpmn:BoundaryEvent` carrying a `compensateEventDefinition` is refused on import just as it always was, now as one specific refusal inside a boundary event's own import path rather than as a blanket "no boundary events at all" rule. -Cancel is excluded from these six because Operaton allows a cancel boundary on a transaction alone; ADR-0028 surfaces that container as the `attempt` block and admits one there. - -The self-contained body. -A hosted handler's body lowers inline into the same container as its host, with no wrapping sub-process, as a boundary event followed by the body's own statement chain, terminating in the chain's own implicit end event. -The only way back into the main flow is an explicit `goto`, uniform across interrupting and non-interrupting handlers alike, matching what a bare `{ }` block already means everywhere else in the language: a scope ends where its last statement ends, unless something inside it names where control goes next. - -Rejected: falling through to whatever statement follows the host in the main flow. -This reads correctly for a "retry, then continue" shape on an interrupting boundary, but it is wrong by default for a non-interrupting (`alongside`) one: the host's own token is still running the main flow forward at the same time the escape chain would also be falling into it, duplicating the token and running the rest of the process twice. -A single body shape that is correct for both interrupting and non-interrupting boundaries, with rejoining always spelled out by the author, avoids a rule that would otherwise have to special-case `alongside`. - -The transparency rule, and a consequence it has that the author cannot see. -Because a hosted handler's body is not its own container, the scope provider's flow-container walk (the same walk that decides what `goto` may resolve against) has to treat a hosted `on` as transparent: resolving a name from inside such a body walks straight past the handler to the real enclosing process or subprocess, exactly as if the hosted `on` were not there at all. -This is what makes `goto` legal in both directions across a hosted handler's body, both a `goto` inside the body reaching a main-flow statement and a main-flow `goto` reaching into the body, because both resolve against the one container the lowering actually places their statements in. - -This same transparency has a consequence a program's author has no way to observe from the source text alone, and it has to be recorded as a decision rather than left to be discovered as a surprise. -A host-less `on` written inside a hosted handler's body is not scoped to that escape path. -Because the hosted handler contributes no container of its own, a host-less handler nested inside it lowers into the same outer container its host lives in, the process or subprocess the hosted handler is itself attached to, and therefore guards that whole outer container, not just the one escape path it is textually written inside. -The result is valid BPMN that Operaton deploys without complaint, and it follows directly and necessarily from treating a hosted handler as transparent; it is not a bug in the lowering. -But it widens what a nested handler guards invisibly, purely as a side effect of where it happens to be written, and an author reading only the source has no local signal that this widening occurred. - -Boundary events as a second entry into the control-flow graph. -The restructuring analysis wires every boundary event to its container's virtual entry, unconditionally, the same way it already wires every start event. -This is the honest model, not a workaround: at runtime, a token genuinely appears at a boundary event the moment its trigger fires while its host is running, without ever traversing a sequence flow into it. -Without this wiring, a boundary event's whole escape chain has no immediate dominator at all, and the restructurer can recognize an `if`, a `while`, or a clean join only relative to a dominance relationship, so an escape chain with no path from the entry could never be recognized as anything but a sequence of unstructured `goto`s. - -The accepted trade-off: a node reachable from both the main flow and an escape chain loses a tight immediate dominator once the boundary event is wired in. -Its dominator moves up to the shared virtual entry, since neither the main flow's split nor the escape chain alone reaches it unconditionally. -An `if`/`else` whose join such an escape chain jumps into therefore degrades to plain `goto`s on decompile rather than printing as a structured branch. -The dominance result itself is correct: a region two independent entry points can both reach is genuinely not dominated by either one's split, so printing it as `goto`s is the restructurer being honest about a graph shape it cannot losslessly re-fold into `if`/`else`. - -The degraded output is not always re-readable, though, and the limit is worth stating precisely. -When the join the chain lands on is a synthesized gateway rather than an authored step, the emitted `goto` names an id that has no statement form, so the printed source carries an unresolved-reference diagnostic and does not re-desugar to the graph it came from. -That is a property of the `goto` sweep and not of the attachment axis, since the same output arises from a boundary-free program whose `while` body ends in a `goto` into an `if` branch, but an escape chain rejoining a branch join is a natural second way to reach it. -Decompiling a hand-drawn diagram of that shape yields source a reader must repair by hand. -Rejected: leaving a boundary event unwired, which makes every escape chain permanently un-restructurable regardless of how simple its own internal control flow is; and running a second, boundary-rooted analysis over the same container, which the restructurer's single-`Emitter`-per-container design does not support, since two independent analyses over one shared node/edge set would have no way to agree on which of them owns a node reachable from both. - -Host-derived ids. -A boundary event's id is `Boundary__`, collision-resolved against the same document-wide `taken` set the implicit start/end ids already share, rather than the positional `EventSubProcess_` scheme a host-less handler's event sub-process already uses. -Both halves of this id, the host's authored name and the trigger word, are text the author wrote and that survives a round trip verbatim, so the id does not move when the decompiler reorders handlers to the end of a container's statement list the way the trailing-position rule requires. -A positional id would drift the moment that reordering happened, exactly the instability the existing `normalizeIr` re-key already has to canonicalize away for event sub-processes; a host-derived id needs no equivalent re-key for the generation direction at all. - -The one case this id scheme does not fully disambiguate is two boundary handlers sharing a host and a trigger but differing in code. -`on Pack: error(A)` and `on Pack: error(B)` are legal, non-duplicate handlers under the validator's `(host, trigger, code)` key, yet both base to the identical `Boundary_Pack_error` before collision suffixing runs, so which one keeps the bare id and which one receives the `_2` suffix is positional rather than derived from either code. -This is stable in the generation direction, since lowering assigns suffixes in statement order, the decompiler reprints handlers in that same order, and re-lowering reproduces it. -It is exposed on import, though, where a hand-written or externally modeled document may present the two boundary events in whatever order the tool that wrote them chose. -Folding the code into the base id was rejected: no other id constructor in this scheme sanitizes arbitrary author-supplied text into an id fragment, and doing so here would still need a numeric suffix for the case where two boundaries share every distinguishing property outright (two `timer` boundaries on one host, which carry no engine subscription key and so are never rejected as duplicates). -The round-trip normalizer compensates on the comparison side instead: its canonical-id function keys a boundary event's signature on the event definition's own payload, not on the printed id, so two IR snapshots compare equal regardless of which physical id each assigns to which occurrence. - -### Consequences - -- Good, because the colon resolves the host/trigger ambiguity with no reserved word, no residual parser-generator warning, and no change to ADR-0016's soft-trigger-word design. -- Good, because the six-trigger scope admits only what Operaton's own parser accepts, so a boundary event the validator passes carries a trigger the engine deploys. - The converse does not hold: a compensation boundary is refused on ADR-0018's attachment-mechanism grounds rather than on an engine refusal, and Operaton would deploy the document that refusal rejects. -- Good, because excluding compensation costs nothing new to build: the existing `on compensation` undo block already covers the granularity a boundary compensation event would have reached, and the import-side refusal that keeps `isForCompensation` honest needed no new mechanism, only a new call site for the existing one. -- Good, because the self-contained body is uniform across interrupting and non-interrupting boundaries, so there is exactly one rule to learn ("the body ends where it ends; rejoin with `goto`") instead of one rule per cancellation mode. -- Good, because wiring a boundary event to the virtual entry makes every escape chain's own internal control flow, including a nested `if`/`else`, restructurable on exactly the same terms as the main flow, rather than falling back to `goto`s for a construct that has nothing irregular about it on its own. -- Good, because a host-derived id survives every reordering the decompiler's trailing-position rule performs, without adding a new re-key rule to the round-trip normalizer for the generation direction. -- Bad, because a node reachable from both the main flow and an escape chain loses a tight immediate dominator, degrading a main-flow `if`/`else` whose join such a chain jumps into into `goto`s on decompile. - That is accepted as a correct description of a graph shape with two genuine entry points, not worked around. -- Bad, because a host-less `on` handler written inside a hosted handler's body is silently promoted to guard the outer container the hosted handler itself attaches to, rather than being scoped to just that escape path. - It is a direct, unavoidable consequence of treating a hosted handler as transparent to the flow-container walk, and one an author cannot see from the source alone. -- Bad, because two boundary handlers sharing a host and a trigger but differing only in code cannot be told apart by id text alone before the positional collision suffix is applied, which is stable across a recompile but exposed on an import written by another tool. - -### Confirmation - -`packages/language/test/parsing.test.ts` pins the colon's disambiguation directly: `on Review: timer("PT2H")` parses with `host` set to `'Review'` and `trigger` set to `'timer'`, never confusing the host with the trigger. -`packages/language/test/scoping.test.ts` pins the transparency rule in both directions (`goto` from inside a hosted body reaching the main flow and back), and `packages/transform/test/ast-to-ir.test.ts` pins the promotion consequence: a host-less handler written inside a hosted body lowers into the outer container. -`packages/language/test/validating.test.ts` pins the six-trigger scope, the escalation host restriction, and the compensation-has-no-host refusal, each with an exact-message assertion. -`packages/transform/test/cfg-analysis.test.ts` pins the virtual-entry wiring and the accepted dominance trade-off as an explicit regression case, not an incidental property. -`packages/transform/test/ir-to-dsl.test.ts` pins the self-contained body, including a nested `if`/`else` inside an escape chain restructuring cleanly and a chain that rejoins the main flow printing as `goto`. -`packages/transform/test/synthesize-ids.test.ts` pins the host-derived id template and its collision suffixing. -The frozen `tests/golden/boundary-events.bpmn` fixture and its round-trip suite exercise every one of these decisions together across a real DSL -> XML -> IR -> DSL -> IR cycle, including the import-first direction that is this scheme's one open disambiguation case. - -## Pros and Cons of the Options - -### The colon separator - -- Good, because it resolves the ambiguity at the second token, requiring no lookahead beyond what the parser generator already performs by default. -- Good, because it leaves every trigger word a soft identifier, touching none of ADR-0016's design. -- Bad, because it is one more character an author has to remember relative to the originally proposed bare form, for a distinction the bare form cannot actually make. - -### Promoting trigger words to keywords, admitted back as identifiers by type - -- Good, because a keyword-based host/trigger boundary would need no separator character at all. -- Bad, because it touches roughly two dozen individual grammar productions rather than one, reversing ADR-0016's soft-word design for the sites it touches. -- Bad, because the ambiguity is not actually removed, only resolved by the parser generator's own alternative-ordering rule, reported solely as a build-time warning and invisible to anyone not reading the build log line by line. - -### A postfix host clause (`on error(X) at Pack`) - -- Good, because it reads left-to-right as "catch this, at this activity," which some authors may find more natural than naming the host first. -- Bad, because `at` already belongs to a timer's date key (`on timer(at: "2026-08-01T09:00:00")`), recreating the same word-level collision the colon exists to avoid, in a different position. - -### All eight of the language's catchable triggers as boundary events - -- Good, because it would need no per-trigger row distinguishing boundary-legal triggers from the rest. -- Bad, because it would let the surface author a `bpmn:BoundaryEvent` carrying a `compensateEventDefinition`, a document Operaton's own parser does not treat as an ordinary boundary attachment and that this language deliberately keeps expressed only as a subprocess undo block (ADR-0018). - -### Falling through to the host's successor - -- Good, because it needs no `goto` at all for the common "retry, then continue" shape. -- Bad, because it is wrong by default for a non-interrupting boundary: the host's own token is still advancing the main flow, so falling through duplicates it and runs the remainder of the process twice. - -### Wrapping a hosted handler's body in its own container - -- Good, because it would reuse the host-less handler's existing lowering unmodified, needing no new transparency rule in the scope provider. -- Bad, because BPMN's own boundary-event semantics require the escape path to be a flow element of the same container as the host, not a nested one; wrapping it would make a `goto` back to the main flow cross a container boundary the same way ADR-0016's rule already forbids for a host-less handler, defeating the entire point of allowing a rejoin. - -### Leaving a boundary event unwired in the restructuring analysis - -- Good, because it needs no change at all to the analysis's entry-wiring rule. -- Bad, because every escape chain becomes permanently un-restructurable: not even a chain with no internal branching of its own gets an immediate dominator, so the restructurer could never recognize anything inside it but a flat sequence of `goto`s. - -### A second CFG analysis rooted at each boundary event - -- Good, because it would keep the main flow's own dominance relationships completely untouched by the presence of any boundary event. -- Bad, because the restructurer's `Emitter` design holds exactly one analysis per container; a node reachable from both the main flow and an escape chain would have two independent, and potentially disagreeing, dominance answers with no mechanism to reconcile them. - -### Reusing the positional `EventSubProcess_` id scheme - -- Good, because it would need no new id constructor at all. -- Bad, because a positional coordinate is exactly the property that drifts once the decompiler moves a handler to the end of its container's statement list, the instability `normalizeIr` already has to canonicalize away for host-less handlers, which a host-derived id avoids needing in the first place. - -## More Information - -Related decisions: ADR-0016 (soft trigger words and the derived event-root design, both of which this decision reuses untouched: the colon separator exists because trigger words stay soft, and a boundary event's `errorRef`/`escalationRef` derivation is the same one ADR-0016 already established). -ADR-0017 (the per-trigger payload surfaces, where message, signal, timer, and conditional payloads render identically whether the handler is hosted or not, since a boundary header reuses the same payload-rendering function a host-less handler's header already uses). -ADR-0018 (compensation through event sub-processes, whose decision this one's exclusion of compensation from the boundary form preserves rather than supersedes, keeping its import-side `isForCompensation` guard valid for the reason stated there). -ADR-0009 (dominator/post-dominator restructuring, the analysis this decision's CFG-entry wiring extends, and the reason a boundary event's dominance trade-off is described as a correct property of the graph rather than a defect in the restructurer). -ADR-0010 (deterministic structural ids, the collision-resolution convention a host-derived boundary id reuses, and the contrast against the positional scheme this decision deliberately does not reuse). -ADR-0013 (the target audience and no-boilerplate rule, the reason a hosted handler reuses the existing `on`/`throw`/`emit` vocabulary rather than introducing a distinct construct for a boundary attachment). -ADR-0014 (the honest import contract, under which a `bpmn:BoundaryEvent` imports every trigger this decision admits, refusing a compensation boundary and any host outside this same container rather than mangling either into an approximation). - -Extended by ADR-0028 (work that can be given up), which adds `cancel` to this attachment axis on the one host kind that can catch it, the `attempt` block. diff --git a/docs/decisions/0020-intermediate-catch-events.md b/docs/decisions/0020-intermediate-catch-events.md deleted file mode 100644 index 03ef843..0000000 --- a/docs/decisions/0020-intermediate-catch-events.md +++ /dev/null @@ -1,174 +0,0 @@ ---- -status: accepted -date: 2026-07-24 -decision-makers: Marlon Kranz ---- - -# Intermediate Catch Events: the `await` Keyword, a Synthesized Id, and a Four-Trigger Scope - -## Context and Problem Statement - -BPMNscript has two ways to react to a trigger so far. -An `on` handler (ADR-0016) is a racing scope guard: it compiles to an event sub-process that competes with the whole container it guards, firing only if its trigger arrives before the container finishes. -`throw`/`emit` (ADR-0016, ADR-0017) go the other way, making the current path fire a trigger outward, ending or continuing depending on the verb. -Neither shape covers a third one BPMN itself has: `bpmn:intermediateCatchEvent`, a node sitting directly on the main sequence flow where the token stops in place and stays stopped until the trigger fires, then carries on to whatever follows. -Nothing in this language lets an author write "pause here for a message" as a single step in a linear flow; the closest available shape, a hosted `on` handler, guards a whole activity rather than blocking one point in a sequence. - -Adding that third shape raises the same three questions every earlier event-layer decision has had to answer for its own construct. -What word introduces it, given that the grammar's existing trigger words (`message`, `timer`, `signal`, `condition`, and the timer particles) are deliberately soft identifiers rather than keywords, so a new syntax slot cannot lean on token type to stay unambiguous, only on token position? -Whether the construct needs an author-chosen name at all, since a name is what a `goto` would target and this node has no body to jump back out of? -And which of BPMN's catchable event kinds this specific attachment point exposes, given that error, escalation, and compensation already have a home elsewhere in this language's model and a plain intermediate catch element is not where BPMN itself lets several of them live? - -## Decision Drivers - -- The grammar already commits every trigger word and every timer particle to being a soft identifier (ADR-0016), so any new rule sharing that vocabulary has to be checked against the same kind of token-level collision ADR-0019's colon separator had to resolve for boundary events. - A plausible-looking rule is not enough; it has to be run through the parser generator and shown clean. -- The restructuring analysis (ADR-0009) and the round-trip id normalizer (ADR-0010) already have settled rules for what does and does not need special handling: only a node with no incoming edge needs virtual-entry wiring, and only a handful of node kinds get re-keyed on compare. - A new construct should fit those existing rules rather than add exceptions to either one, if its own shape genuinely allows that. -- The payload surfaces for message, signal, timer, and conditional triggers are already built (ADR-0017) and reused by both `on` handlers and boundary events (ADR-0019). - A third consumer of the same surfaces should cost one dispatch case per layer, not a second payload grammar. -- The honest import contract (ADR-0014) means an unsupported form on the wire has to be refused with a diagnostic that names what was refused, not silently dropped and not guessed into an approximation. - -## Considered Options - -For the keyword: - -- `await` -- `wait` -- `receive` - -For goto-target naming: - -- No authored name at all, a synthesized id only -- An optional `name=ID` slot mirroring how a hosted `on` handler is named - -For the trigger scope: - -- Message, timer, signal, and conditional only -- The full set of BPMN event kinds this language already models somewhere, adding error, escalation, compensation, cancel, and link -- A different hand-picked subset - -## Decision Outcome - -Three decisions, chosen together for the same reason ADR-0016's and ADR-0019's each were: spend a new reserved word, a new syntax slot, or a wider trigger list only where the alternative genuinely loses something, whether grammar unambiguity, round-trip fidelity, or a meaning this language does not actually have, and reuse what already exists everywhere else. - -The keyword is `await`. -Before committing to it, the rule was built into a live Langium parser with Chevrotain's self-analysis switched on (`createServicesForGrammar`), and the full trigger-payload battery was run through it. -Every form parsed with zero ambiguity warnings, including the case that actually matters: `await timer("PT1H")`, a bare identifier followed by a parenthesized payload, the same token shape `on timer("PT1H")` already has, resolved to `trigger='timer'` with the duration as its payload, never confusing the trigger for anything else. -`wait` and `receive` parse exactly as cleanly; the choice between the three is not a parsing question at all, and is settled on what each word actually says. -`receive` names precisely what a message catch does and nothing else: a signal is broadcast, not addressed to anyone to "receive"; a timer is not received from anywhere; a condition is evaluated, not delivered. -Using it across all four triggers would mislabel three of them. -`wait` reads as a generic pause, closer to a fixed delay than to a subscription that resolves when something happens, a fair description of the timer case and a poor one for a message or signal correlation. -`await` carries the meaning every one of the four cases actually shares: execution stops here until this specific thing resolves, then continues with whatever comes next, which is exactly what an intermediate catch event does at runtime and is the same word this audience already reads that way in mainstream asynchronous code. - -Reserving `await` costs nothing existing code already pays for. -It collides with no trigger word or timer particle already in the grammar, and it appears as a bare identifier nowhere in the committed corpus. -Unlike `message` or `code`, words ADR-0016 keeps soft precisely because they are ordinary variable names this audience reaches for constantly, `await` is not a word a process author would otherwise want free. - -No authored name; the id is synthesized. -A trial grammar carrying an optional `name=ID` slot ahead of the trigger, mirroring how a hosted `on` handler is named, was run through the same live-parser check and reintroduced the exact ambiguity ADR-0019's colon separator exists to avoid. -Chevrotain reported "Ambiguous Alternatives Detected inside IntermediateCatchEvent Rule": with a name and a trigger both bare identifiers, nothing decides which of the two the first word after `await` is, and a timer read the wrong way loses the one piece of information deciding which of BPMN's three timer forms the author meant. -That is not a cosmetic misparse. -A bare payload, an `at` key, and an `every` key map to a duration, a fixed date, and a repeating cycle respectively, three different things the engine schedules, so losing which one was written is a scheduling error hiding behind a name field nobody asked the catch to have in the first place. - -Dropping the name slot removes the ambiguity outright, and it costs the surface nothing a `goto` target ever needed: an intermediate catch has no body, so unlike a hosted `on` handler it never has to be an addressable jump-back point. -It also matches the one construct already shaped exactly like it. -`IntermediateThrowEvent`, `emit`'s compiled form, is a one-in/one-out, fall-through main-flow node with no name syntax of its own, and the catch is its topological twin, differing only in whether the token fires forward immediately or stops and waits. -The catch's id is `Catch_`, generated by `makeIntermediateCatchEventId` from the same structural-coordinate scheme (ADR-0010) `makeThrowEventId` already uses for `Throw_`, and `/^Catch_/` joins `RESERVED_ID_PATTERNS` so an author can never type a name that would collide with one. -Because there is no name syntax to print in the first place, the decompiler needs none of the reserved-prefix-omission handling a hosted `on` handler's printed name needs; omitting something that was never there is not a rule, it is the absence of one. - -One consequence follows from having no name at all, and it is not new. -A hand-drawn diagram whose second edge or `goto` targets an intermediate catch's synthesized id runs into exactly the same wall every other synthesized, unnameable id already does on decompile: the printed source has nothing to write as the jump's target text, so the edge prints as the existing hand-repair marker rather than a resolvable `goto`. -This is the same invariant that already governs every `goto` the decompiler emits, that a jump target has to be something the grammar can actually spell, extended to one more node kind rather than a new mechanism built for this one. - -The trigger scope is message, timer, signal, and conditional. -These are exactly the four shapes the shared `readCatchEventDefinition` helper already maps, the same function an `on` handler's start event and a boundary event's catch side both already read through, so admitting them to `await` costs one new dispatch case per layer, not new mapping logic. -They are also the only four kinds this language treats as something a token can genuinely wait on. -Error and escalation are always thrown by `throw`/`emit` and always reacted to by a racing `on` handler (ADR-0016); nothing about either one is a value a linear flow blocks on and then continues past, the way a message correlation or a timer's clock is. -Letting `await` also claim them would mean two independent constructs, a handler and an await, both plausibly "catching" the same trigger, with no rule for which one actually fires when it arrives. -Compensation has exactly one meaning in this language: the undo block a subprocess declares with `on compensation` (ADR-0018), invoked only by the engine's own compensation machinery, never something with an independent arrival a token waits for. -Cancel is excluded because BPMN gives it no position an `await` could occupy: a transaction sub-process's boundary catches one, and an end event inside that sub-process raises it. -ADR-0028 surfaces that container as the `attempt` block and admits both of those positions there, neither of them the pass-through step on the main flow an `await` compiles to. -Link is BPMN's own off-page connector, built for splitting one diagram across pages; this language's `goto` already does the job of naming a jump inside one textual source, so link would duplicate an existing mechanism for a diagramming problem a single `.bpmnscript` file never has. -A catch carrying more than one event definition, or `parallelMultiple="true"`, waiting on several triggers on one element at once, is a different modeling shape from the one-trigger-per-`await` grammar this rule commits to, and nothing in the surface's design calls for it. - -On import, `bpmn:intermediateCatchEvent` moves from a blanket `UnsupportedElementError` to an honest refuse-or-map split, matching the contract ADR-0014 already sets for every other construct: the four supported kinds map through `readCatchEventDefinition` unchanged, and a link, error, escalation, compensation, or cancel definition, or more than one definition on the same element, is refused by name rather than silently dropped or approximated. -The XML schema itself does not stop a document from placing one of the excluded definitions on an intermediate catch element, so the refusal is a real check against real input a foreign tool or a hand-edited file can produce, not a defensive branch that never fires. - -The catch's topology settles the two passes that would otherwise need new cases for it. -The restructuring analysis wires only start events and boundary events to its virtual entry (`buildGraph`, `cfg-analysis.ts`); every other flow element, including the existing intermediate throw, is an ordinary node reached by a normal incoming edge, and the catch is one more ordinary node of that kind, so it needs no dominance handling that is not already there. -The round-trip id normalizer re-keys only gateways, event sub-processes, and boundary events on compare; task and event ids, the catch's own `Catch_` among them, round-trip verbatim exactly like `Throw_` already does. -Both claims are confirmed by dedicated regression cases rather than left as an assumption the construct happens not to violate. - -### Consequences - -- Good, because the full payload battery parses with zero ambiguity warnings, so `await` reserves one word and touches no existing grammar rule's disambiguation. -- Good, because `await` names exactly the behavior all four supported triggers share, execution stops until this resolves, in a word this audience already reads that way, rather than a word accurate for one trigger and approximate for the other three. -- Good, because dropping the name slot removes the trial ambiguity outright and costs nothing a `goto` target ever needed, since the catch has no body to jump back out of. -- Good, because the catch is a topological twin of the intermediate throw, so forward emit, decompile, the restructuring analysis, and the round-trip normalizer all reuse existing machinery through one new dispatch case each, rather than new passes. -- Good, because the four-trigger scope matches `readCatchEventDefinition` exactly, so import gains an honest refusal for exactly the forms it cannot represent instead of either a blanket refusal or a silent drop. -- Bad, because an author can never give a specific `await` a name to `goto`. - The only way to reach one is to write it inline where it belongs, and a hand-drawn diagram's stray edge into one prints as a hand-repair marker rather than a resolvable jump, the same limit every other unnameable synthesized id already carries. -- Bad, because error, escalation, compensation, and cancel stay permanently unreachable from `await` even on a hand-crafted document where the shape looks unremarkable to a reader who does not already know BPMN restricts a plain intermediate catch element to a smaller set of event kinds than a boundary event or an event sub-process's start accepts. - -### Confirmation - -`packages/language/test/parsing.test.ts` pins the `await` disambiguation directly: each of `await message("...")`, `await timer("...")` in all three of its forms, `await signal("...")`, and `await condition(...)` parses to the field shape its trigger implies, with the timer case pinning that which of the three was written survives, not swallowed into a name. -A soft-word survival pin confirms `var await` fails to parse, proving the reservation, while `var message: string` and a step named `every` still parse clean. -`packages/language/test/validating.test.ts` pins the four-trigger acceptance and each trigger's missing payload, alongside the rejection of `await error`, `await escalation`, and `await compensation`, matched on the substrings that name the four legal words and the shape each of those kinds takes instead. -`await cancel` is refused there too, matched against its whole message, which points at the `end cancel` that gives an `attempt` block up and the `on : cancel` that catches it. -The refusal of an unknown word after `await` is matched whole as well, so the four accepted kinds and the place each refused kind is written instead are fixed in one string. -`packages/transform/test/ast-to-ir.test.ts` pins the four lowering cases and that two catches in one body receive distinct `Catch_` ids. -`packages/transform/test/ir-to-xml.test.ts` pins the four emitted `*EventDefinition` shapes and that no `name` attribute is stamped on the element. -`packages/transform/test/xml-to-ir.test.ts` pins the four mapped shapes and the refusal of an unsupported trigger, of multiple definitions on one element, and of conditional narrowing attributes inherited from ADR-0017. -`packages/transform/test/ir-to-dsl.test.ts` pins the four decompiled render lines, each with no `Catch_` token in the output. -`packages/transform/test/cfg-analysis.test.ts` pins the CFG no-op as an explicit regression case: a catch on the main flow gets a normal immediate dominator, never the virtual entry. -`packages/transform/test/synthesize-ids.test.ts` pins the id template and its reserved-pattern match. -The frozen `tests/golden/intermediate-catch.bpmn` fixture and `tests/intermediate-catch.round-trip.test.ts` exercise all four triggers together across a full DSL -> XML -> IR -> DSL cycle, asserting both IR idempotence and that the decompiled source recompiles with zero validation errors. -A Docker-gated end-to-end test deploys an example awaiting a message mid-flow, correlates it over REST, and proves the instance was genuinely waiting before correlation and reaches completion after, the one property none of the other layers can show on their own, since blocking is a runtime property rather than a compile-time one. - -## Pros and Cons of the Options - -### `await` - -- Good, because it names the one behavior all four supported triggers share, not just the one it happens to fit best. -- Good, because it reserves a word that costs nothing existing code already relies on. -- Neutral, because it is one more reserved word alongside `on`, `throw`, `emit`, and `alongside`, a small, fixed set this language already keeps deliberately short. - -### `wait` - -- Good, because it parses exactly as unambiguously as `await`. -- Bad, because it reads closer to a fixed delay than to a subscription that resolves when something happens, a description that fits the timer case and undersells the other three. - -### `receive` - -- Good, because it names the message case precisely. -- Bad, because it is wrong for the other three: nothing is received from a timer, a signal is broadcast rather than addressed, and a condition is evaluated rather than delivered. - -### An optional `name=ID` slot - -- Good, because it would let an author give a catch an explicit `goto` target. -- Bad, because it reintroduces the exact ambiguity ADR-0019's colon separator exists to avoid: Chevrotain flags it directly, and the parser reads the trigger word as a name, losing which of the three timer forms was meant. - -### Every BPMN catchable trigger, including error, escalation, compensation, cancel, and link - -- Good, because it would need no exclusion list and no refusal diagnostics for the excluded forms. -- Bad, because error, escalation, and compensation have no "something to wait for" meaning in this language's model. - They are always thrown and always handled by a racing `on`, and letting `await` also claim them creates two constructs that both plausibly catch the same trigger, with no rule for which one runs. -- Bad, because BPMN admits no cancel on a plain intermediate catch at all, only on a transaction sub-process's boundary and on the end event that gives that sub-process up, and link duplicates a job `goto` already does for a diagramming problem a single text file never has. - ADR-0028 surfaces that sub-process as the `attempt` block, where a cancel is written `end cancel` inside the block and caught by `on : cancel` beside it, positions no `await` on the main flow could have taken. - -## More Information - -Related decisions: ADR-0016 (soft trigger words and the `throw`/`emit` terminality rule, under which `await`'s own trigger and particle words stay soft identifiers, and from whose throw/handler split the reasoning that error and escalation are always thrown, never awaited, follows directly). -ADR-0017 (the payload surfaces for message, signal, timer, and conditional, all four of which `await` reuses verbatim, including the timer particle-to-definition mapping and the deferred conditional-narrowing refusal). -ADR-0018 (compensation as a subprocess undo block, the reason compensation gives an `await` nothing to wait for). -ADR-0019 (boundary events, the source of the live-parser ambiguity-check method this decision reuses twice, and the six-trigger boundary scope this decision's four-trigger catch scope deliberately does not match, since a boundary event's attachment and an intermediate catch's inline wait answer different questions about the same trigger vocabulary). -ADR-0014 (the honest import contract that `bpmn:intermediateCatchEvent` now follows instead of a blanket refusal). -ADR-0009 (the dominator-based restructuring analysis the catch needs no new entry-wiring case for, being an ordinary node reached by a normal incoming edge). -ADR-0010 (the structural-coordinate id scheme `Catch_` follows, and the re-keying rule that already excludes task and event ids, so the catch's id needs no new normalizer case either). - -Extended by ADR-0028 (work that can be given up), which surfaces the transaction sub-process as the `attempt` block and gives cancel the two positions BPMN does allow it, leaving this decision's four-trigger scope where it stands: a cancel is written on the end that gives a block up and caught beside that block, never awaited. - -Amended by ADR-0035, which admits `link` to the `await` trigger scope, gives `await` the optional trigger-first name slot `emit` already carries, and wires a link catch to the virtual entry beside a start and a boundary event. -The reasoning above for keeping link off the authoring surface stands as a claim about authoring; ADR-0035 adds link for the import direction, where a modeller's document already carries one and rewriting it to a `goto` would not round-trip. diff --git a/docs/decisions/0020-listeners-reuse-on-inside-the-attribute-block.md b/docs/decisions/0020-listeners-reuse-on-inside-the-attribute-block.md new file mode 100644 index 0000000..99f7d8b --- /dev/null +++ b/docs/decisions/0020-listeners-reuse-on-inside-the-attribute-block.md @@ -0,0 +1,90 @@ +--- +status: accepted +date: 2026-08-27 +decision-makers: Marlon Kranz +--- + +# Listeners reuse `on` inside the attribute block + +## Context and Problem Statement + +Operaton runs user code at points in an element's lifecycle that never appear as flow nodes. +An execution listener fires when a node starts or ends. +A task listener fires on one of several user task lifecycle events, and one of them, `timeout`, carries its own timer. +Each listener names its event and exactly one binding, a class, an expression, a delegate, or a script, which is the same exactly-one rule a service task binding already carries. + +BPMNscript already uses `on` to catch a BPMN event: `on error(E409) { ... }` guards a block, and a boundary form docks that catch onto one activity. +A listener is not a caught event: it never appears in the diagram, and it fires on the element's own lifecycle rather than on something the process throws. +Writing it as `on create(class: "...")` spells that lifecycle callback with the same word a catch uses, so one word covers two ideas. +Whether the listener surface earns its own keyword or reuses `on` and accepts that ambiguity is the first question here. + +Three more are settled by the engine rather than by taste, because a shape the surface calls legal has to deploy. +`BpmnParse.parseTaskListeners` accepts six event words, so a surface word for the assignment event that is not the engine's writes an `event` attribute the deployment refuses and cannot read a real file back. +`BpmnParse.parseTimeoutTaskListener` reads the listener's `id` and adds an error when it is null, since the id is baked into the timer job's handler configuration and keys `TaskDefinition.addTimeoutTaskListener`, so a timeout listener needs an id even though no author writes one. +`BpmnParse.checkActivityInputOutputSupported` adds an error for an `operaton:inputOutput` on a `bpmn:subProcess` whose `triggeredByEvent` is `true`, which is exactly where a host-less `on` handler's `input` and `output` lines would lower. +A host-less handler also lowers to two BPMN elements, so a last question follows: which of the two carries the engine attributes written on the statement? + +## Decision Drivers + +- Every reserved keyword is a name an author permanently loses for a variable or a step, and `listener` is a name a real process might want. +- A listener's shape is fixed by the engine and checkable while typing: one event from a finite set, exactly one binding, a timer only when the event is `timeout`. +- A program the validator accepts must deploy, and a document that deploys must import and rebuild to one that still deploys (ADR-0012). +- One fact about the language lives once: the event words, the parameter hosts and the id scheme are read off one table each. +- The timer clause, the fenced script block, and the `class`, `expression`, and `delegate` keys exist, so reusing them costs less than specifying new forms for listeners. +- No new key on the surface for something the engine derives, and no second spelling to document. + +## Considered Options + +- Reuse `on` inside the attribute block, letting position decide whether it is a listener or a caught event: the language names one idea once, the timer clause and the bindings carry over unchanged, and nothing is reserved, at the price of a meaning that is positional, so a reader must check what encloses the line. +- Add a distinct `listener { ... }` keyword: the word alone would say the block is an engine callback rather than a flow node, keeping apart two things BPMN itself keeps apart, but it spends a reserved word on an idea the language already has a word for, and every file pays that cost whether or not it registers a listener. +- Spell every event as the engine does, mint the timeout listener's id on export, and refuse parameters on a host-less handler: one spelling per fact, the wire carries it as written, and the id needs no key the author never reads. +- Keep a shorter `assign` on the surface and map it to `assignment` on the wire: one event word would then have two spellings, one in the language and one in every diagram and engine log, and the mapping would be the one place the event table is not the whole truth. +- Give the timeout listener an `id` key, keep the parameter grant, and move the mapping onto the trigger start event: an authored id would survive a round trip, but `BpmnParse.parseStartEvents` calls `ensureNoIoMappingDefined` on every start event, so the nested start cannot take the mapping either, and the key would be the only listener setting the engine derives rather than reads. + +## Decision Outcome + +Chosen: reuse `on` inside the attribute block, spell every event as the engine does, mint the timeout listener's id on export, and refuse an io mapping on a handler. +`on` already reads as "when this happens, run that" everywhere it appears, and a keyword buys its clarity with a permanently reserved word. +Each of the other three rules follows from one engine method and adds nothing to the surface. + +The event word is a soft word, like a trigger word: every task listener event lexes as an identifier, and the validator checks each against the element kind it was written on. +`start` and `end` lex as statement keywords instead, so the grammar names those two as the listener event position's exceptions. +An `on` clause becomes an execution listener or a task listener by its event word alone, since the two event sets never overlap, so the surface never has to spell the distinction the XML draws between `operaton:executionListener` and `operaton:taskListener`. + +Every `timeout` task listener is written with the id `_timeout_`, `n` counting the task's timeout listeners from one in document order. +An id the document already holds is stepped past, the way a synthesized element's id is (ADR-0010). + +An `on` handler takes no `input` or `output` line in either form, since the engine refuses a mapping on a boundary event and on an event sub-process alike. +The attribute block rule for a handler therefore grants no parameters, and the validator's existing parameter-host message names the kinds that take one. + +Engine attributes on a handler statement land on the element that survives printing. +A hosted handler lowers to one `bpmn:boundaryEvent`, so there is no choice. +A host-less handler lowers to two elements instead, a `bpmn:subProcess` and a nested trigger `bpmn:startEvent`, and its attributes go on the sub-process because the synthesized start event is elided on print. +The timer job's own settings, `jobPriority`, `retryCycle` and `exclusive`, go on that trigger start event instead, since the timer job is the start's, and the printer lifts them back into the head. + +### Consequences + +- Good, because no word is reserved. + `listener`, `create`, and `timeout` stay free for a step or a variable name. +- Good, because the timer clause, the script block, and the three binding keys are reused as they stand. + Highlighting, completion, and the binding diagnostic extend to listeners for free. +- Good, because every program the validator accepts deploys, and a Modeler-authored file with an assignment listener or an id-carrying timeout listener imports without a refusal or a warning. +- Good, because a listener's binding is a list of its own rather than the members every other element carries. + A listener's block nests no form, no input or output parameter, and no other listener; the one member it holds beside its bindings is an injected field (ADR-0018). +- Bad, because a reader must check the enclosing context to know which `on` they are looking at: inside a task's braces it is a callback, at statement position it is a catch. +- Bad, because a timeout listener's authored id is replaced by the minted one on a round trip, so a document that names its timeout listeners rebuilds under the tool's names. +- Bad, because a value a host-less handler's body needs has to be mapped on a step inside the body rather than on the handler. + +### Confirmation + +`packages/language/test/validating.test.ts` pins `on assignment` accepted and `on assign` refused with the list, and a parameter on either handler form refused with the parameter-host message. +`packages/transform/test/ir-to-xml.test.ts` pins two timeout listeners on one task written as `_timeout_1` and `_timeout_2`, and the second stepping past a task already holding its id. +`packages/transform/test/xml-to-ir.test.ts` pins `event="assignment"` imported, `event="assign"` refused, and a listener id consumed without a warning. +The frozen pairs `tests/golden/listeners.{bpmnscript,bpmn}` and `tests/golden/input-output.{bpmnscript,bpmn}` carry the three shapes and import without a warning. + +## More Information + +The intermediate representation carries a listener as an event plus a four-way tagged binding mirroring `ServiceTaskBinding`, and a `timeout` task listener also carries the timer the event layer already models. +Operaton behaviour was read from `BpmnParse.parseTaskListeners`, `BpmnParse.parseTaskListener`, `BpmnParse.parseTimeoutTaskListener`, `BpmnParse.checkActivityInputOutputSupported`, `BpmnParse.ensureNoIoMappingDefined` and `TaskListener`. + +Related decisions: ADR-0010 (deterministic structural ids), ADR-0012 (the import contract this surface honors), ADR-0018 (the field a listener's block holds), ADR-0021 (engine attributes as named IR fields). diff --git a/docs/decisions/0021-external-tasks-as-a-service-task-binding.md b/docs/decisions/0021-external-tasks-as-a-service-task-binding.md deleted file mode 100644 index 08e10d9..0000000 --- a/docs/decisions/0021-external-tasks-as-a-service-task-binding.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -status: accepted -date: 2026-08-16 -decision-makers: Marlon Kranz ---- - -# External Tasks as a Service Task Binding, Not a Separate Keyword - -## Context and Problem Statement - -The grammar has two statement keywords that both compile to a `bpmn:serviceTask`: `service`, carrying a `class`, `expression`, or `delegate` binding, and `external`, carrying a `topic`. -Both keywords take the same shape and produce the identical BPMN element; only the DSL surface distinguishes them. -The intermediate representation already models this as one type. -`ServiceTaskBinding` in `packages/transform/src/ir/types.ts` is a tagged union of `class` | `expression` | `delegateExpression` | `external`, and ADR-0006 already describes a service task as bound to "a Java class, a JUEL expression, a delegate expression, or an external topic" in one sentence, not as two separate constructs. - -Does the DSL surface need its own keyword for a binding the IR, the validator's discriminator check, and the emitted XML already treat as one of four variants on the same element, or can `external` fold into `service` as that fourth binding? - -## Decision Drivers - -- ADR-0006 committed the IR to modeling every service-task execution form, including the external-worker form, as one tagged union rather than as separate node kinds. - A grammar carrying a second keyword for a case the IR already unifies is an asymmetry with no consumer that benefits from it. -- ADR-0013 commits the language to minimizing required syntax and to naming constructs by what the user means rather than by BPMN vocabulary. - Two keywords for the same element ask an author to remember an extra word for a distinction the runtime output does not make. -- A reserved keyword is a name an author can never choose for a variable or a step, so every keyword this language adds is a name it permanently takes away from process authors. - `external` is an ordinary English word a real process is fairly likely to want for a variable or a step name. - -## Considered Options - -- Keep `external` as its own statement keyword -- Fold `external` into `service` as a fourth binding attribute - -## Decision Outcome - -Chosen option: "Fold `external` into `service` as a fourth binding attribute", because all four service-task execution forms already produce exactly one BPMN element, the IR already unifies them under `ServiceTaskBinding`, and ADR-0013 already commits this language to the smaller keyword surface. - -A service task's settings gain `topic` as a fourth legal key alongside `class`, `expression`, and `delegate`. -The validator's exactly-one-binding check now requires exactly one of the four, naming all four in its error message, and the printer emits `service X(topic: "...")` for the binding the same way it already emits `service X(delegate: "...")` for a delegate expression. -A fifth binding, `type`, joins the four under the same check (ADR-0042). -The emitted BPMN XML is unchanged: `operaton:type="external"` alongside `operaton:topic` is exactly what the `external` keyword produced before, since the IR and the XML generator never referenced the DSL keyword in the first place. -`external` stops being reserved and parses as an ordinary identifier, freeing it for a variable or step name the way any other non-keyword does. - -### Consequences - -- Good, because there is one keyword surface to teach: an author reads "a service task can call a class, an expression, a delegate expression, or an external worker" as four attributes of the same statement, instead of two statements that happen to compile to the same element. -- Good, because boundary-event host enumerations, the reserved-step-name check, and every other place the grammar or validator listed the two task kinds side by side collapse to naming `service` once, removing a class of enumeration that had to be kept in sync across the grammar, the validator, and the docs. -- Good, because `external` is no longer reserved, so a process that wants a variable or step literally named `external` can have one. -- Bad, because a diagram viewer or a reader skimming XML sees `operaton:type="external"` as a distinguishing marker that the DSL source no longer surfaces as a distinguishing keyword; the binding is still visible, just as a setting in a `service` step's parens rather than as the statement's own word. - -## Pros and Cons of the Options - -### Keep `external` as its own statement keyword - -An external task is, operationally, a different kind of thing from the other three bindings. -Deploying it means running a separate worker process that polls the engine for work on a topic, rather than shipping code the engine invokes directly inside its own JVM. -A dedicated keyword would flag that operational fork at the point an author writes the step, the same way the language already uses a dedicated keyword to distinguish a `user` task's human actor from a `service` task's automated one. - -- Good, because the keyword itself documents an operational difference, a separately deployed worker, that an attribute value among an element's settings does not surface as prominently. -- Neutral, because Operaton itself does not model `external` as a distinct element either; the engine's own `bpmn:serviceTask` with `operaton:type="external"` is exactly the asymmetry this option would preserve on the DSL surface only. -- Bad, because it reserves a second keyword, and therefore a second English word an author can never use as an identifier, for a distinction that produces no distinct BPMN element and that the IR, the validator, and the printer already collapse into one type. - -### Fold `external` into `service` as a fourth binding attribute - -`topic` becomes a fourth legal key in a `service` task's settings, validated by the same exactly-one-of-four check the other three bindings already share. - -- Good, because it matches the IR ADR-0006 already committed to: one `ServiceTaskBinding` union, one emitted element, one DSL statement. -- Good, because it follows ADR-0013's rule directly: the grammar drops a keyword instead of keeping one that carries no information the runtime output does not already carry elsewhere. -- Good, because `external` is freed as an ordinary identifier. -- Bad, because the operational-fork argument above is real: nothing in the `service Ship(topic: "...")` surface tells a reader, without already knowing what `topic` means, that this step runs in a separately deployed process instead of inside the engine. - -## More Information - -The validator's zero-binding and more-than-one-binding messages name all four keys (`class`, `expression`, `delegate`, `topic`), so the fourth binding is discoverable from the error text alone. -Related decisions: ADR-0006 (the IR between the AST and BPMN XML, which carries Operaton's semantics under vendor-free names) already modeled `external` as a `ServiceTaskBinding` variant rather than its own IR node; this decision brings the DSL surface in line with that model. -ADR-0013 (target users without BPMN knowledge and minimize boilerplate) is the standing rule this decision applies. - -Amended by ADR-0038, under which the `topic` binding carries the three things the engine reads beside the topic and under no other binding: a `taskPriority` setting, `property` lines, and `error when ` mappings. - -Amended by ADR-0042, which adds `type` as a fifth binding, the engine's own discriminator for a mail or shell task it builds itself. diff --git a/docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md b/docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md new file mode 100644 index 0000000..073b60e --- /dev/null +++ b/docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md @@ -0,0 +1,99 @@ +--- +status: accepted +date: 2026-08-27 +decision-makers: Marlon Kranz +--- + +# Operaton engine attributes as named IR fields + +## Context and Problem Statement + +The importer reports everything it cannot represent in the `extensionAttribute` warning category, and Operaton's execution settings all land there: `asyncBefore` and its siblings, job priority, the retry cycle, `operaton:inputOutput`, execution and task listeners, and every user-task assignment attribute beyond `assignee` and `formKey`. +A process that round-trips through this tool comes out without them, which makes the tool unusable on a file a real deployment produced. +None of these settings changes control flow: an async continuation decides where the engine commits a transaction, an input parameter decides what a delegate reads, and a listener decides what runs alongside a step. +Carrying them needs no change to gateway synthesis, only somewhere to put roughly twenty values and a mapping on each of the four transforms. + +Two carriers make the placement question harder than one field per node. +A gateway is the one flow node in this surface with no keyword and no name of its own, since `if`, `while`, `do...while`, `parallel`, and a multi-branch `await` each synthesize one or two from block structure and the restructurer elides every one it matches back to a statement. +Operaton still reads five execution settings off every gateway it deploys, and a file a real deployment produced can set any of the five on any of its gateways. +A timer's job settings are not all read off the node that carries the timer either: `BpmnParse.parseTimer` builds the job a timer creates from `operaton:exclusive` on the `bpmn:timerEventDefinition` element and from the priority stored on the activity that carries the definition. + +Where should these settings live in the IR, under what names, which elements can take which, and which element does each one get written on? + +## Decision Drivers + +- The IR's shape and its no-vendor-prefix naming convention (ADR-0007), which twenty fields arriving at once have to fit rather than amend. +- The validator checks attribute keys against element kind, and round-trip idempotence compares IR against IR, so a carried setting has to be a typed field a structural comparison can see, not opaque text. +- Three settings are not flat: an input or output parameter's value can be a list, a map, or an inline script, and a listener carries an event plus one of four bindings. +- A setting the script writes must reach the job it names, and the surface keeps one `exclusive` key per statement rather than one key per job an element may create. +- Every gateway is still synthesized and matched back, so a setting cannot cost a gateway its textual anonymity. +- The one bracket shape already holds scalar settings in a parens, and a join gateway sits beside its split under one statement, so the two need told-apart spellings inside one parens rather than a second parens. +- The import contract: what changes execution is refused, what does not is carried or warned, and nothing drops in silence. + +## Considered Options + +- Named vendor-neutral fields on the IR node, which extend what the IR already does for `assignee`, `formKey`, and the service-task binding and let the type system do the validation work, since a listener's binding is a tagged union of four variants and one with two bindings or none cannot be constructed. + A setting nobody has named yet is not carried, so keeping up with the engine means editing code rather than widening a container. +- A generic extension bag keyed by qualified attribute name, filled from import and written back verbatim, which would carry every Operaton attribute at once, including ones nobody has looked at, but implies an untyped DSL surface where any key compiles and a typo produces an attribute the engine never reads and never complains about. +- Openly `operaton:`-prefixed field names such as `operatonAsyncBefore`, which would show a reader which fields are BPMN's own and leave room for a second engine, but contradict the naming rule already set (ADR-0007), since `assignee`, `formKey`, and the service-task binding are engine settings under plain names, so a prefix means renaming them or keeping two conventions in one file. +- A gateway's settings in a parens on the statement head, the join's under a `join`-prefixed spelling, reusing the bracket shape every element already has at no grammar cost beyond a settings slot on five statement rules and putting the join's setting next to the split's, at the price of ten keys a reader tells apart by prefix alone. +- A second parens for the join gateway, which would need no prefix, but puts two adjacent parens on a statement head, a shape nothing else in the grammar has, and leaves the second one empty on `while`. +- A `gateway` keyword introducing an authored gateway that carries a setting the way any other named element does, which reserves a word for an element the grammar deliberately keeps unauthored, and an authored gateway gains an id, stops eliding, and breaks the restructurer's round trip. +- Each timer job setting written on the element the engine reads it from, so the documented spelling runs as written with no new key and no warning, at the price of the lock being written in two places on a timer carrier with an async flag, the one shape that can disagree with itself. +- A warning on a host-less timer handler's job settings, pointing the author at an explicit `start` inside the body, which leaves a documented spelling unable to reach the job it names and answers with a warning. +- A second surface key for the timer job's lock, beside the one for the continuation job, which spends a key on a distinction the element the engine reads already makes, and would put one lock key per job on every statement. + +## Decision Outcome + +Chosen options: named vendor-neutral fields on the IR node, a gateway's settings in a parens on the statement head, and each timer job setting written on the element the engine reads it from. +The IR already carries `assignee`, `formKey`, and the service-task and call-activity bindings under plain names, so the naming extends a precedent rather than amending one. + +Each setting becomes an optional field on the IR node that owns it, named for what it means rather than how it serializes, so the retry cycle is `retryCycle`, not `failedJobRetryTimeCycle`. +Flow-node settings group into one mixin interface and input/output into a second, both as tagged unions so an illegal combination is unrepresentable: `JobSettings` carries the five job settings alone, and `EngineAttributes` is the wider mixin that adds listeners, for everything that keeps a textual identity to attach one to. +The naming rule holds at the boundary: values are stored under plain names, and `irToXml` adds the `operaton:` prefix at the one point where it builds the moddle element. + +The engine reads the five off a gateway as it does off an activity. +`BpmnParse.parseExclusiveGateway`, `parseInclusiveGateway`, `parseParallelGateway`, and `parseEventBasedGateway` each call `parseAsynchronousContinuationForActivity` for `asyncBefore`, `asyncAfter`, and `exclusive`, and `createActivityOnScope` for `jobPriority` through `parsePriority`, while `DefaultFailedJobParseListener.parseActivity` reads the retry cycle the same way. +A gateway has no textual identity to hang an attribute on, so its five settings are authored in a parens on the head of the statement that synthesizes it, the split's under their plain spelling and the join's under `join` plus the spelling. +One parens rather than two, because a statement already reads as one construct, and a second parens would ask an author to place a setting on a gateway they cannot see or name. +Execution listeners stay off every gateway kind, since there is nothing there to attach one to. + +`parseEventBasedGateway` refuses `asyncAfter` on the gateway it deploys, so a multi-branch `await` head refuses the same setting and an import carrying it throws `UnsupportedEventFeatureError` rather than importing a document that never deployed. +`joinAsyncAfter` is unaffected, since the join a race falls through to is an ordinary exclusive gateway. +The printer recovers a gateway's settings onto the statement head it prints them under, and a gateway that reaches no head, an unstructured jump target or a degraded split among them, loses them under the `droppedSetting` warning. + +A timer's job settings follow the same rule, each written on the element the engine reads it from. +`DefaultFailedJobParseListener.parseStartEvent`, `parseBoundaryEvent` and `parseIntermediateCatchEvent` read the retry cycle off the event element when its type is a timer, while `operaton:exclusive` on the event tag is read by `BpmnParse.parseAsynchronousContinuation` alone, which configures the async continuation job, and with no async flag there is no such job. +A node carrying a timer therefore writes its `exclusive` on the `bpmn:timerEventDefinition` as well as on the event tag: the definition's copy locks the timer job, the tag's copy the continuation job an async flag creates. +The moddle descriptor declares the attribute on the definition, so it parses as a typed boolean and the default `true` is omitted on write. + +A host-less `on timer(...)` handler lowers `jobPriority`, `retryCycle` and `exclusive` onto the trigger start event it synthesizes, and `asyncBefore` and `asyncAfter` onto the event sub-process, whose continuation job they create, since nothing that creates a timer job looks at the sub-process. +The printer lifts the three back into the `on timer` head as long as the start prints no statement of its own, and leaves them on the `start` line when it does, so an authored start inside the body keeps its settings where it wrote them. +Every other host-less handler writes its settings on the event sub-process. + +### Consequences + +- Good, because the validator can restrict attribute keys per element kind, and the vendor prefix stays on the serialization boundary, so the IR reads the same regardless of whether the reader knows Operaton. +- Good, because the IR stays a typed model: a field on a user task but not a start event is a compile error at every call site, and a round-trip comparison sees a field rather than a string to parse. +- Good, because a file a real deployment produced imports its gateway settings instead of losing them, and the two `asyncAfter` refusals, on a wait's head and on import, agree with each other and with what Operaton itself refuses to deploy. +- Good, because `exclusive: false` on a timer start, a hosted timer, an `await timer` and a host-less timer handler configures the job the timer creates, and a host-less `on timer` with `jobPriority` or `retryCycle` deploys a job that carries them. +- Bad, because every engine setting is a mapping surface on all four transforms: a grammar key, a validator entry, a lowering, an XML mapping, an import read, and a printer case. +- Bad, because a setting Operaton supports and the IR does not name is dropped with a warning rather than carried. +- Bad, because ten keys sit in one parens on three statement kinds, so a reader has to know which five are the join's before knowing which gateway a value tunes. +- Bad, because a gateway that never reaches a statement loses its settings on print; `droppedSetting` reports the loss rather than preventing it. +- Bad, because a timer carrier with an async flag writes `operaton:exclusive` twice, and a document that sets the two apart imports with one of them changed and a warning. + +### Confirmation + +The `gateway-settings` golden pair under `tests/golden` carries all five plain and all five join-prefixed keys, both `parallel` element kinds included, and its suite asserts the settings survive DSL to XML to DSL and back; the frozen pair `tests/golden/engine-attributes.{bpmnscript,bpmn}` carries the lock on the hosted timer's definition. +The validator table in `packages/language/test/validating.test.ts` pins the loop and `await` refusals and the pruned-join warning. +`packages/transform/test/operaton-moddle.test.ts` pins the typed attribute on the definition and its omission at the default, and `packages/transform/test/ir-to-xml.test.ts` pins the definition and the tag both carrying the lock on a boundary timer and an await, and neither when none is written. +`packages/transform/test/xml-to-ir.test.ts` pins the gateway carry, the `asyncAfter` refusal, the definition's value on a boundary and an event sub-process start, the tag's as the fallback, the one warning when the two differ, and the warning under which a timer-started event sub-process's own timer job key is dropped. +`packages/transform/test/ast-to-ir.test.ts` and `packages/transform/test/ir-to-dsl.test.ts` pin the three settings on the synthesized start, the async flags on the sub-process, the lifted head, the authored start keeping its own line, and `droppedSetting`. + +## More Information + +Each field's serialized form is documented beside its declaration in `packages/transform/src/ir/types.ts`, where the five job settings sit beside `JobSettings`, and the prefix is applied only in `packages/transform/src/ir-to-xml.ts`. +The join spellings are derived at `JOIN_KEY_BY_ENGINE_KEY` and the statements that take them are listed at `GATEWAY_STATEMENT_RULES`, both in `packages/language/src/vocabulary.ts`. +The timer behaviour was read from `BpmnParse.parseTimer`, `BpmnParse.parseAsynchronousContinuation`, `BpmnParse.createActivityOnScope` and `DefaultFailedJobParseListener.parseStartEvent`. +ADR-0007 sets the IR shape and the naming rule this decision applies. diff --git a/docs/decisions/0022-engine-attributes-as-named-ir-fields.md b/docs/decisions/0022-engine-attributes-as-named-ir-fields.md deleted file mode 100644 index 137c8ce..0000000 --- a/docs/decisions/0022-engine-attributes-as-named-ir-fields.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -status: accepted -date: 2026-08-27 -decision-makers: Marlon Kranz ---- - -# Carry Operaton Engine Attributes as Named IR Fields - -## Context and Problem Statement - -The importer reports everything it cannot represent in the `extensionAttribute` warning category, and Operaton's execution settings all land there: `asyncBefore` and its siblings, job priority, the retry cycle, `operaton:inputOutput`, execution and task listeners, and every user-task assignment attribute beyond `assignee` and `formKey`. -A process that round-trips through this tool comes out without them, which makes the tool unusable on a file a real deployment produced. -None of these settings changes control flow. -An async continuation decides where the engine commits a transaction, an input parameter decides what a delegate reads, and a listener decides what runs alongside a step. -Carrying them needs no change to gateway synthesis, only somewhere to put roughly twenty values and a mapping on each of the four transforms. - -Where in the IR should these settings live, and under what names? - -## Decision Drivers - -- ADR-0006 fixed the IR's shape and its no-vendor-prefix naming convention, so twenty fields arriving at once has to fit that convention rather than be assumed to. -- The validator checks attribute keys against element kind, and round-trip idempotence compares IR against IR, so a carried setting has to be a typed field a structural comparison can see, not opaque text. -- Three settings are not flat: an input or output parameter's value can be a list, a map, or an inline script, and a listener carries an event plus one of four bindings. - -## Considered Options - -- Named vendor-neutral fields on the IR node -- A generic extension bag keyed by qualified attribute name -- Openly `operaton:`-prefixed field names - -## Decision Outcome - -Chosen option: "Named vendor-neutral fields on the IR node", because the IR already carries `assignee`, `formKey`, and the service-task and call-activity bindings this way, extending a precedent rather than amending one. - -Each setting becomes an optional field on the IR node that owns it, named for what it means rather than how it serializes, so the retry cycle is `retryCycle`, not `failedJobRetryTimeCycle`. -`asyncBefore`, `asyncAfter`, and `exclusive` are stored only when they diverge from the engine's off/off/on defaults, and flow-node settings group into one mixin interface while input/output groups into a second, both as tagged unions so an illegal combination is unrepresentable. -The naming rule from ADR-0006 still applies at the boundary: values are stored under plain names, and `irToXml` adds the `operaton:` prefix at the one point where it builds the moddle element. - -A gateway synthesized from an `if`, `while`, `do...while`, `parallel`, or a multi-branch `await` has no textual identity to hang an attribute on (ADR-0010). -Since ADR-0040, its five job settings are authored on the statement head instead. - -### Consequences - -- Good, because the validator can restrict attribute keys per element kind, and the vendor prefix stays on the serialization boundary, so the IR reads the same regardless of whether the reader knows Operaton. -- Good, because the IR stays a typed model: a field on a user task but not a start event is a compile error at every call site, and a round-trip comparison sees a field rather than a string to parse. -- Bad, because every engine setting is now a mapping surface on all four transforms: a grammar key, a validator entry, a lowering, an XML mapping, an import read, and a printer case. -- Bad, because a setting Operaton supports and the IR does not name is still dropped with a warning rather than carried (`operaton:field` is the current example). - -## Pros and Cons of the Options - -### Named vendor-neutral fields on the IR node - -- Good, because it extends what the IR already does for `assignee`, `formKey`, and the service-task binding, keeping one convention rather than two. -- Good, because the type system does validation work directly: a listener's binding is a tagged union of four variants, so one with two bindings or none cannot be constructed. -- Bad, because a setting nobody has named yet is not carried, so keeping up with the engine means editing code rather than widening a container. - -### A generic extension bag keyed by qualified attribute name - -- Good, because a map filled from import and written back verbatim would carry every Operaton attribute at once, including ones nobody has looked at. -- Bad, because the DSL surface it implies is untyped: any key would compile, and a typo would produce an attribute the engine never reads and never complains about. - -### Openly `operaton:`-prefixed field names - -- Good, because fields like `operatonAsyncBefore` would let a reader see which fields are BPMN's own versus a single engine's, and a second engine could later be added without a name collision. -- Bad, because it contradicts the naming rule ADR-0006 already set: `assignee`, `formKey`, and the service-task binding are engine settings under plain names today, so a prefix means renaming them or keeping two conventions in one file. - -## More Information - -Each field's serialized form is documented beside its declaration in `packages/transform/src/ir/types.ts`, and the prefix is applied only in `packages/transform/src/ir-to-xml.ts`. -The import side records what is carried and what is still dropped in `packages/transform/src/xml-to-ir.ts` and `packages/transform/README.md`. - -Related decisions: ADR-0006 (IR shape and naming rule, applied here), ADR-0007 (the `operaton:` moddle extension), ADR-0008 and ADR-0010 (block-structured grammar and synthesized ids, which set the gateway limit), ADR-0023 (DSL spelling for listeners). - -Amended by ADR-0040, which gives the four gateway kinds the `JobSettings` mixin, authored on the statement head, so the gateway limit above holds for listeners alone. - -Amended by ADR-0047, under which a timer carrier's `exclusive` is written on the `bpmn:timerEventDefinition` as well as on the node, since `BpmnParse.parseTimer` locks the timer job from the definition. diff --git a/docs/decisions/0022-repetition-the-for-clause-and-its-per-run-job-settings.md b/docs/decisions/0022-repetition-the-for-clause-and-its-per-run-job-settings.md new file mode 100644 index 0000000..3a133e5 --- /dev/null +++ b/docs/decisions/0022-repetition-the-for-clause-and-its-per-run-job-settings.md @@ -0,0 +1,91 @@ +--- +status: accepted +date: 2026-08-31 +decision-makers: Marlon Kranz +--- + +# Repetition: the `for` clause and its per-run job settings + +## Context and Problem Statement + +Any BPMN activity may carry a `bpmn:multiInstanceLoopCharacteristics` child, which makes the engine run it once per element of a collection or a fixed number of times. +A grammar with no way to say so refuses such a document on import and stops the round trip at the first repeated activity. +That element also carries job settings of its own. +`BpmnParse.parseAsynchronousContinuationForActivity` hands a multi-instance activity's `bpmn:multiInstanceLoopCharacteristics` element to `parseAsynchronousContinuation`, so the three async settings written there mean one job per run, where the same settings on the activity element mean one job around the whole repetition. +`DefaultFailedJobParseListener.parseActivity` reads a retry cycle off that element the same way, and a document a real deployment produced can carry any of the four on it. + +Where does the repetition sit on a statement, what does it spell, which shapes does the surface decline to carry, and where does a per-run job setting go? + +## Decision Drivers + +- A keyword binds to what the author means rather than to the BPMN attribute behind it. +- An imported activity has to come back under the id the document gave it. +- The import contract refuses what changes the run and warns only about drops that do not. +- A Langium keyword is lexer-global, so a word spent on a clause is a name lost everywhere in a file. +- Operaton decides what is legal here, and a surface stricter than the engine owes a reason. +- The one bracket shape holds every scalar setting in a parens on the statement head, and a per-carrier prefix already tells a second carrier's settings apart inside one parens (ADR-0021). + +## Considered Options + +- A header clause between a statement's id and its settings, which reads as something done to one statement, which is what the engine does with it, and leaves every statement the settings it already had, at the cost of four keywords and a touch on every statement rule that emits an activity where a settings key would have touched none. +- Settings keys, `times:`, `each:`, `as:`, and `sequential: true`, which name the BPMN attribute rather than what the author means, and cannot constrain what the clause constrains, since `as: "line"` with no `each:` is writable as a setting and is one of the deployments Operaton refuses. +- A wrapping statement, `for each x in c { ... }`, which over more than one statement needs a synthesized sub-process to hang the repetition on, so an imported repeated user task comes back inside a container the document never had under an id this tool invented, and which restricted to one statement is this clause with a pair of braces around it. +- One head for both a count and a collection, since the two are not exclusive in Operaton. +- Two heads, `for each ... in ...` for a collection and `repeat ` for a count, which would refuse a legal document carrying both or need a third spelling for the combination. +- The element variable required, which costs an error path and a test to reject content the engine deploys and runs. +- The element variable optional, matching what the engine accepts. +- `bpmn:standardLoopCharacteristics` imported as a step that runs once, which accepts every document Operaton deploys and runs, on every host the loop can sit on including an event handler, at the price of dropping the element the document wrote and keeping only a warning that names it. +- `bpmn:standardLoopCharacteristics` refused, which rejects a document the engine deploys and runs exactly as if the element were absent. +- Per-run job settings as four keys in the statement's own parens, prefixed `run`, reusing the bracket shape at no grammar cost and reusing the derivation a join gateway's spellings use, so the validator, the printer, and the importer read one function for both prefixes, at the price of a parens holding nine keys at once. +- A second parens attached to the `for` clause itself, which would sit a run's settings next to the clause that creates the runs, but is a second bracket shape for four settings that already have a home elsewhere. +- The per-run settings spelled inside the clause's own grammar, which is a phrase grammar rather than a keyed settings list, so folding four settings into it needs new syntax for each one. + +## Decision Outcome + +Chosen options: the header clause, one head, the optional element variable, importing a standard loop as a step that runs once, and four `run`-prefixed keys in the statement's own parens. + +The clause sits between the id and the optional settings on the statements that emit an activity, and `for`, `each`, `sequentially`, and `until` are hard keywords, because in that position only a keyword can tell the parser a clause has started. +No file in the repo uses any of the four in an identifier position, and the four built into a live parser draw no ambiguity report from Chevrotain's self-analysis. + +One head carries both intents because Operaton allows both at once: `MultiInstanceActivityBehavior.resolveNrOfInstances` reads the cardinality first and falls through to the collection, and `evaluateCollectionVariable` still binds the element per run whenever a collection is set. +Parallel is the unmarked form, because Operaton's `BpmnParse` defaults `isSequential` to false and this tool elides every attribute matching an engine default. +An element variable is optional with a collection, since `for each in batches` deploys and runs, and refusing it for authoring convenience is the call ADR-0016 declined to make for a receive task with no message name. +The whole construct is one optional field on the shape the activity nodes already share, so it reaches every kind without adding an arm to any exhaustive switch in the transform. + +`runAsyncBefore`, `runAsyncAfter`, `runExclusive`, and `runRetryCycle` sit in the same parens a repeated statement's own five settings already occupy, each spelled by `runSettingKey(key) = prefixedSettingKey('run', key)`, the same function a join gateway's spellings use with a different prefix. +There is no `runJobPriority`: `createActivityOnScope` reads a job priority off the activity element alone, and `parseActivity` swaps the scope to the multi-instance body before `createActivityOnScope` stores it, so a repeated step's plain `jobPriority` already prices each run's job. +No statement kind that repeats also synthesizes a gateway, so a `run`-prefixed key and a `join`-prefixed key never both sit in one parens. + +On the XML side the loop element carries the plain, unprefixed attribute names, since it is already its own element, distinct from the activity's, and only the DSL surface needs a prefix where the step's settings and the loop's share one parens. +The moddle declaration makes the plain names legal and typed on that element in the first place: `AsyncCapable.extends` in `operaton-moddle.json` gains `bpmn:MultiInstanceLoopCharacteristics` beside `bpmn:FlowNode`, which upstream camunda-bpmn-moddle already declares there through `Collectable`'s own superclass, and without it the loop element's `asyncBefore` and friends parse into `$attrs` as untyped strings, invisible to every typed reader. +The four therefore import back as the matching `run`-prefixed settings rather than refusing. + +Two refusals on import are this tool's limit rather than the engine's, and both exist because the printer has no second spelling to write the content back in. +A `bpmn:loopCardinality` body that is neither a run of digits nor an expression refuses, since Operaton's `resolveLoopCardinality` reads `+3` as three while this printer writes a count only as a plain number or as an expression, and an `operaton:elementVariable` or `bpmn:inputDataItem` name outside the grammar's `ID` terminal refuses, since the clause writes the name each run sees as a bare identifier and an element named `größe` would come back as a file this language cannot parse. +Everything Operaton's own parse rejects, a repetition with neither a count nor a collection among it, refuses rather than importing into a process that cannot deploy. + +### Consequences + +- Good, because a document the engine runs more than once imports, prints, and recompiles byte for byte, including one carrying async or retry settings on its multi-instance element. +- Good, because no new IR shape was needed: `LoopCharacteristics` already existed, and every read, write, import, and print helper built for `JobSettings` applies to it unchanged, keyed by a different function. +- Neutral, because a repeated step's own `asyncBefore` and `asyncAfter` buy one job around the whole loop, `parseAsynchronousContinuationForActivity` reading a host element's async attributes onto the repetition, while `runAsyncBefore` and `runAsyncAfter` in the same parens buy one job per run. +- Neutral, because `operaton:collection` and `bpmn:loopDataInputRef` are one field to Operaton's `parseMultiInstanceLoopCharacteristics`, read in that order, so they import into one field here and the `operaton:` spelling is what gets written back. + The BPMN slot holds the text of a variable name rather than a reference to an element in the document. + `bpmn:inputDataItem` and `operaton:elementVariable` pair the same way, and a document setting both spellings of either pair gets a warning naming the one that was dropped. +- Neutral, because a decompiled program declares `var : any` for every collection it iterates by name, one case of the rule that the print declares every variable it reads. +- Neutral, because `nrOfInstances`, `nrOfActiveInstances`, `nrOfCompletedInstances`, and `loopCounter` are seeded into the process's variable table wherever something repeats, so `until (nrOfCompletedInstances >= 2)` validates with no declaration. + Operaton's `MultiInstanceActivityBehavior` sets `loopCounter`, and its two concrete behaviors set the three counters. +- Bad, because four ordinary English words stop being available as identifiers anywhere in a file. +- Bad, because a repeated statement's parens can hold nine keys, five plain and four `run`-prefixed, so a reader has to track which govern the whole loop and which govern each run. + +### Confirmation + +`packages/language/test/` pins every form of the clause token by token, its placement against the id and the settings, the output-parameter rule, the seeded variables, and what the position completes to, and the validator table in `validating.test.ts` pins the without-a-clause refusal, the `runJobPriority` refusal, and the repeatable kinds that admit the four `run` keys. +`packages/transform/test/` pins the lowering, each refusal by error class and by a substring of its message, each shadowing warning, the emitted child, the loop element's written attributes, the import back into `LoopCharacteristics`, and the printed line. +The frozen pair `tests/golden/repetition.{bpmnscript,bpmn}` carries every form of the clause across eight statements of one process and the four `run` keys on at least one repeated statement, and `tests/repetition.round-trip.test.ts` compares the compiled XML byte for byte and requires an import with no warning at all. +`tests/e2e/repetition.test.ts` deploys to a real Operaton and drives it over REST, offering a parallel repetition's three runs at once and a sequential one's one at a time on the same activity, ending a repetition after two runs by completion condition, driving a service task by count, binding each element of a collection in turn, never running the step inside at a count of zero, and showing `runAsyncBefore: true` make the job executor create one job per run rather than one job around the whole loop. + +## More Information + +The clause's fields and the four job settings are declared beside `LoopCharacteristics` in `packages/transform/src/ir/types.ts`, `runSettingKey` and `RUN_ENGINE_KEYS` beside `JOIN_KEY_BY_ENGINE_KEY` in `packages/language/src/vocabulary.ts`, and the moddle declaration in `packages/transform/src/operaton-moddle.json`. +ADR-0021 carries the per-carrier prefixing this decision reuses with a different prefix. diff --git a/docs/decisions/0023-event-roots-from-usage-and-declared-codes.md b/docs/decisions/0023-event-roots-from-usage-and-declared-codes.md new file mode 100644 index 0000000..9861740 --- /dev/null +++ b/docs/decisions/0023-event-roots-from-usage-and-declared-codes.md @@ -0,0 +1,120 @@ +--- +status: accepted +date: 2026-07-17 +decision-makers: Marlon Kranz +--- + +# Event root elements derived from usage, and declared codes + +## Context and Problem Statement + +BPMN keeps an error or an escalation in two places. +A `bpmn:Error` or `bpmn:Escalation` root element sits under `bpmn:Definitions`, one per code, carrying for errors the message text (`operaton:errorMessage`). +Every start, end, or intermediate-throw event that catches or raises that code carries its own `errorEventDefinition` or `escalationEventDefinition` and references the root by id. +Nothing about using a code in an event definition says that a root has to exist, so the bookkeeping sits one level up, in the document's root-element list, away from where the code is written. + +The audience writes processes, not BPMN documents, so that list is not theirs to maintain. +A code still needs an identity the editor can address. +A site that raises a code and a site that catches it are otherwise two string literals that happen to match, so there is nothing to jump to, nothing to rename, and a typo is a second code rather than a mistake. + +Two further properties of the event surface are ones BPMN's own vocabulary does not settle for a textual language. +What makes a raised event end its path rather than notify and continue, and whether the words naming event kinds and catch-parameter fields have to be reserved in order to work. + +## Decision Drivers + +- Jump-to-definition, find-references, rename and completion are the contribution a textual DSL makes over a graphical editor, and a payload the language server cannot resolve is where that contribution stops. +- A mistyped code has to be a diagnostic at the site that wrote it, rather than a second code the export writes a root for. +- BPMN lets an event definition reference no root at all, a catch-all, so the surface must not force a code where the author wrote none and must not synthesize a root for a code that never resolves to one. +- The round trip is byte-exact against frozen artifacts, so import has to reproduce the codes a document arrives with, including a code no identifier can spell and a root nothing raises, and a printed statement has to mean the same thing wherever it sits in its block. +- Whatever resolves a code has to leave every other identifier alone. + A bare word in `condition(ready)` or in `if (amount > 100)` is a variable, and the undeclared-variable warning is the diagnostic its author needs. +- Error and escalation are the first two event kinds, with message, signal, timer and conditional behind them, so the design should not spend a keyword, or accumulate a root-element obligation, that each later kind repeats. +- Every element is spelled `verb Id(settings)`, so a declaration with a shape of its own would be the one construct left over. + +## Considered Options + +- Root elements derived from usage, deduped by code, so nothing about the document's root-element list reaches the author. +- A `Definitions`-level IR root modeling `{ process, rootElements }` explicitly, which mirrors BPMN's own document structure with no derivation step to get wrong, but changes every consumer signature, the CLI, all four transforms and every existing test, to carry data the declaration already supplies, and still leaves something to create, id and look up root objects: the compiler, making the explicit model ceremony, or the author, reintroducing the registry management this decision exists to avoid. +- A declaration in the process header that every use site references by a bare name, which gives a code a definition to jump to and one home for its message text. +- No declaration, with the validator checking each site against the set of codes it collects from the document, which needs no scope provider, no linker wording and no filtering of link failures, and keeps a bare word a bare word to the rest of the toolchain, but produces a diagnostic and nothing else, where the editor features that motivate the design read the reference graph. +- A quoted code at every use site, so a code used once costs one site and no more, but a string literal is invisible to the language server, so the editor features have no reference to read and a typo becomes a second code the export writes a root for. +- A cross-reference alternative of its own in the shared paren rule, which would put the distinction in the grammar where a reader would find it in one place, but does not parse: `error(OUT_OF_STOCK)` and `condition(ready)` are the same tokens in the same position, and the arm made Chevrotain report `AMBIGUOUS_ALTERNATIVES` and resolve the ambiguity by parsing the condition as a reference. +- The reference on `VarRef`, the rule every identifier in an expression already builds, with the two readings of a bare word separated by scope. +- `throw error(...)` as the only spelling for raising an error. +- A second spelling on the end statement, `end Finished error(...)`, beside the `terminate` and `cancel` an `end` already carries, which would keep a labeled error end its caption, since `end` carries a label and `throw` does not, but makes one IR shape carry two printed forms the printer has to choose between. +- Terminality decided by the keyword, as a property of the word itself. +- Terminality decided by statement position, last in a block compiling to an end event and anything else to an intermediate throw, which removes the `emit` keyword but is unsound under round trip. +- `error`, `escalation`, `code` and `message` lexed as soft, validated identifiers. +- The same four reserved as keywords, which would draw highlighting and completion from the generated grammar and the parser's keyword table with no provider code, but a Langium keyword is lexer-global, so reserving `message` for the event surface reserves it everywhere and breaks `var message: string` anywhere in a file, on an audience for whom `message`, `code` and `error` are ordinary variable names. + +## Decision Outcome + +Chosen: root elements derived from usage rather than modeled explicitly, a declaration in the process header that every use site references by a bare name, the reference carried on `VarRef` with the two readings of a bare word separated by scope, terminality decided by the keyword rather than by statement position, and trigger kinds and binding fields lexed as validated identifiers rather than keywords. +Each keeps BPMN's own bookkeeping out of the author's hands wherever the compiler can carry it, and spends required syntax only where the alternative breaks round-trip fidelity, gives up a cross-reference, or collides with the audience's own variable names. + +An error or escalation code is declared once, beside the `var` declarations at the top of the process body, and named wherever it is raised or caught. +The declaration is `verb Id(settings)` like every other element, so no construct is left over. +`message` is the text a thrown error carries at runtime, the one piece of root data usage cannot supply, and a code no identifier can spell lives on the declaration as a `code` setting, so the use site is a bare name in every case and the awkward spelling is written once instead of at each of its uses. + +Root elements are derived, not modeled. +`irToXml` walks the whole IR once, collects every distinct error and escalation code in use anywhere in the document, and synthesizes one `bpmn:Error` or `bpmn:Escalation` root per distinct code, wiring every event definition carrying that code to the same root through `errorRef` or `escalationRef`; a declaration adds one root for a code nothing raises. +A definition without a code, a catch-all, gets no ref and contributes no root, because BPMN treats a ref-less catch as any error, so there is nothing for it to point at. +Root ids are sanitized from the code and de-collided against the rest of the document, under the same rule a synthesized id follows (ADR-0010). + +The name and the code both reach BPMN, `@name` holding the declared name and `errorCode` or `escalationCode` holding the code, and they are the same text wherever the code is identifier-shaped. +`operaton:errorMessage` lives on the root rather than at the throw site, because two throws sharing a code share one root and a message at the throw site would need a mechanism to reconcile disagreeing copies. +Import reads the pair back off each root, taking the root's `name` where a declaration can be written with it and minting one from the code otherwise (`claimDeclarationName`, `packages/transform/src/synthesize-ids.ts`), so a root nothing raises imports as a declaration nothing uses. +An imported document whose two roots share a code but disagree on the message is refused rather than merged, since collapsing them would change what a throw carries at runtime. + +The reference sits on `VarRef` rather than on an alternative of its own, and this was measured against the grammar rather than reasoned about: the parser cannot separate `error(OUT_OF_STOCK)` from `condition(ready)`, since both are one identifier inside the same `SettingsParens`. +So every identifier in every expression builds the same node, and what separates a code from a variable is the scope it resolves in. +`BpmnScriptScopeProvider` gives a bare word the enclosing process's declarations of its own kind in a code position, and `EMPTY_SCOPE` everywhere else. +Holding the scope to one kind is what makes `escalation(X)` naming an error fail rather than reach the XML as a second root, since an error and an escalation are separate event definitions even under one code. +Outside a code position the link fails by design, and `BpmnScriptDocumentValidator` drops that failure before it becomes a diagnostic, so an ordinary expression sees the undeclared-variable warning and nothing else. + +Throw and emit decide terminality by keyword, not by position, so `throw` always ends its path and `emit` always continues into whatever follows it. +A single verb whose compiled form depends on being last in its block looks appealing, one fewer keyword, but it does not survive the round trip. +Take an IR shape the importer must be able to reproduce, a branch that fires an escalation and rejoins the main flow, `split -> A -> escalation-intermediate-throw -> join`. +Printed inside its branch block, that statement is the last statement of the block simply because the branch has nothing after it, not because the event ends anything. +A position-decided desugarer reads "last in block", turns the statement back into an escalation end event on the way in, and drops the join edge the original graph had, so the same printed text would mean two different graphs depending on information the text itself does not carry. +`throw` also matches the intuition every reader brings from exception handling in a general-purpose language, and both escalation forms, end event and intermediate throw, stay independently printable. + +`emit` is a general continuing verb, not an escalation-specific one, so error, which has no continuing BPMN form, has no `emit`, and later event kinds that fire and continue reuse the same verb instead of growing their own. +Giving every kind its own throw-and-continue verb turns the surface into a vocabulary quiz. + +Trigger kinds and binding fields are validated identifiers, not keywords: `on`, `throw`, `emit` and `alongside` are the only words this event layer reserves. +The reason is the audience, not parsing convenience. +`message` and `code` are two of the most ordinary variable names in Java-style code, and a Langium keyword is lexer-global, so there is no way to reserve a word only inside `on`, `throw` and `emit` and leave it free everywhere else. +What a keyword gives away for free is rebuilt explicitly, each part in the mechanism meant for it: a validator checks each word against its small legal set and names the options directly in the diagnostic, a semantic-token provider marks exactly the AST properties that carry meaning, so `on error` highlights `error` while `var error` stays plain, and completion items are offered at the same positions. +This also pre-pays the next event kinds, since `message` and `signal` become validated trigger values at no additional reserved-word cost. + +### Consequences + +- Good, because a code has a definition to jump to, a name to rename across every site at once, and a completion list wherever one is legal. +- Good, because an unresolved code is an error naming the declaration to add, rather than a warning about an undeclared variable that reads as though the author meant a variable. +- Good, because every user of one code shares exactly one root element, so a code's message text has exactly one place to live, never several that could disagree. +- Good, because a `bpmn:Error` or `bpmn:Escalation` root nothing raises survives the round trip as a declaration, which a design deriving roots from usage alone has nowhere to put. +- Good, because a code no identifier can spell has one home, so a document arriving with `order.failed` round-trips without that text appearing at every site that raises it. +- Good, because terminality is a property of the word printed, readable without looking at what follows in the block. +- Good, because the soft-word design costs zero additional reserved words when the message and signal triggers arrive. +- Bad, because every code needs a declaration line, so an author who writes a single `throw error(...)` in a five-step process pays a header line for one use. + The cost is accepted rather than argued away: it buys editor behaviour for a code with several sites, and it buys nothing at all for a code with one. +- Bad, because a name and a code can differ, so a reader of `throw error(OrderFailed)` cannot see the text the engine matches on without reading the header. +- Bad, because the grammar cannot carry the distinction the surface makes, so it is spread over a scope provider, a linker that rewords the failure, and a document validator that drops the failures the author never asked to resolve. + A reader looking for why `condition(ready)` is not a code has three files to find rather than a grammar rule. +- Bad, because a catch-all definition contributes no root element to inspect, so there is no single element a tool can point to for "this code, however it is caught". +- Bad, because the soft-word design moves work from the grammar into four separate mechanisms, validator, semantic tokens, completion and parser-error messages, that all have to agree on the same two small word sets. +- Bad, because a document whose two roots for one code disagree cannot be imported at all, even when the disagreement reads as cosmetic to a human ("Payment declined" versus "Payment was declined"). + The importer has no way to tell a cosmetic disagreement from a meaningful one, so it refuses both alike. + +### Confirmation + +`packages/transform/test/ir-to-xml.test.ts` asserts the derivation directly: a document using one code from a handler, a throw, and a catch-all handler produces exactly one root element referenced by every coded use and none by the catch-all, with dedicated cases for id sanitization and collision suffixing. +`packages/transform/test/ast-to-ir.test.ts` and `ir-to-dsl.test.ts` pin the branch-tail counterexample as a round-trip case: an intermediate escalation throw as the last statement of a branch block prints as `emit` and re-imports as the same intermediate throw, never an end event. +`packages/language/test/validating.test.ts` pins the soft-word behaviour, `var message: string` and a task named `error` validating cleanly while an unrecognized trigger word produces the options-naming diagnostic, and `scoping.test.ts` pins the scope in both directions, a bare word in a code position resolving to a declaration of its own kind alone and the same word in an `if` condition resolving to nothing. +The golden pair `tests/golden/event-handlers.{bpmnscript,bpmn}` carries a declaration whose code no name can spell, `error GatewayTimeout(code: "gateway.timeout")`, and a declaration nothing raises or catches, `error STOCK_UNAVAILABLE(...)`, and both gates see them, the byte comparison against the frozen `.bpmn` and the IR-equality comparison across `DSL -> IR -> XML -> IR -> DSL` (`tests/event-handlers.round-trip.test.ts`). + +## More Information + +ADR-0007 keeps vendor- or serialization-only data at the IR-to-XML boundary rather than in the IR; `errorDecls` and `escalationDecls` are the one exception, and they are an exception because they cannot be derived from usage, not because the boundary rule was relaxed. +Langium's scope provider, linker and document validator are the three seams the bare-name reference uses, and the reason the soft-word design rebuilds highlighting and completion as explicit language-server providers rather than through the keyword table. diff --git a/docs/decisions/0023-listeners-on-the-attribute-block.md b/docs/decisions/0023-listeners-on-the-attribute-block.md deleted file mode 100644 index 56bb119..0000000 --- a/docs/decisions/0023-listeners-on-the-attribute-block.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -status: accepted -date: 2026-08-27 -decision-makers: Marlon Kranz ---- - -# Listeners Reuse `on` Inside the Attribute Block, Not a Second Keyword - -## Context and Problem Statement - -Operaton runs user code at points in an element's lifecycle that never appear as flow nodes. -An execution listener fires when a node starts or ends. -A task listener fires on one of several user task lifecycle events, and one of them, `timeout`, carries its own timer. -Each listener names its event and exactly one binding, a class, an expression, a delegate, or a script. -That is the same exactly-one rule a service task binding already carries (ADR-0021). - -BPMNscript already uses `on` to catch a BPMN event. -`on error(E409) { ... }` guards a block (ADR-0016), and a boundary form docks that catch onto one activity (ADR-0019). -A listener is not a caught event. -It never appears in the diagram, and it fires on the element's own lifecycle rather than on something the process throws. -Writing it as `on create(class: "...")` spells that lifecycle callback with the same word a catch uses, so one word now covers two ideas. - -This decision settles whether the listener surface earns its own keyword or reuses `on` and accepts that ambiguity. -A host-less handler also lowers to two BPMN elements, so a second question follows: which one carries the engine attributes written on it? - -## Decision Drivers - -- Every reserved keyword is a name an author permanently loses for a variable or a step, and `listener` is a name a real process might want (ADR-0016). -- A listener's shape is fixed by the engine and checkable while typing: one event from a finite set, exactly one binding, a timer only when the event is `timeout`. -- Whatever the compiler reads has to print back as the same model, and the import contract commits this tool to warning rather than dropping what it cannot write (ADR-0014). -- The timer clause, the fenced script block, and the `class`, `expression`, and `delegate` keys exist, so reusing them costs less than specifying new forms for listeners. - -## Considered Options - -- Reuse `on` inside the attribute block, letting position decide whether it is a listener or a caught event -- Add a distinct `listener { ... }` keyword - -## Decision Outcome - -Chosen option: "Reuse `on` inside the attribute block", because `on` already reads as "when this happens, run that" everywhere it appears. -The alternative buys its clarity with a permanently reserved word. - -The event word is a soft word, like a trigger word. -`create`, `assign`, `complete`, `update`, `delete`, and `timeout` lex as identifiers, and the validator checks each against the element kind it was written on. -`start` and `end` lex as statement keywords instead, so the grammar names those two as the listener event position's exceptions. -A `timeout` listener carries a timer of its own, written as an `after`, `at`, or `every` particle and a time, for example `on timeout after "PT8H"(delegate: "${escalationHandler}")`. -Its parens hold `class`, `expression`, or `delegate` under the same exactly-one rule a service task binding carries. -It can replace them with a fenced script tagged with a language instead, exactly as a script task's body is written. -An `on` clause becomes an execution listener or a task listener by its event word alone, since the two event sets never overlap. -On a user task, `on end` registers an execution listener and `on complete` registers a task listener, so the surface never has to spell the distinction the XML draws between `operaton:executionListener` and `operaton:taskListener`. - -Engine attributes on a handler statement land on the element that survives printing. -A hosted handler lowers to one `bpmn:boundaryEvent`, so there is no choice. -A host-less handler lowers to two elements instead, a `bpmn:subProcess` and a nested trigger `bpmn:startEvent`, and the attributes go on the sub-process because the synthesized start event is elided on print. - -### Consequences - -- Good, because no word is reserved. -- Good, because the timer clause, the script block, and the three binding keys are reused as they stand. - Highlighting, completion, and the binding diagnostic extend to listeners for free. -- Good, because a listener's binding is a list of its own rather than the members every other element carries. - A listener still cannot nest a form, an input or output parameter, or another listener. -- Bad, because a reader must check the enclosing context to know which `on` they are looking at: inside a task's braces it is a callback, at statement position it is a catch. - -## Pros and Cons of the Options - -### Reuse `on` inside the attribute block - -- Good, because the language names one idea once: `on error` and `on create` both mean "when this happens, run that." -- Good, because the timer clause and the four bindings carry over unchanged. -- Good, because nothing is reserved, so `listener`, `create`, and `timeout` stay free for a step or a variable name. -- Bad, because the meaning of `on` becomes positional, so a reader must check what encloses the line before knowing what it does. - -### Add a distinct `listener { ... }` keyword - -- Good, because the word alone says the block is an engine callback, not a flow node. -- Good, because it keeps apart two things BPMN itself keeps apart: a caught event is a flow element, a listener is an extension on one. -- Neutral, because it would still borrow the timer clause and the binding keys. -- Bad, because it spends a reserved word on an idea the language already has a word for, and every file pays that cost whether or not it registers a listener. - -## More Information - -The intermediate representation carries a listener as an event plus a four-way tagged binding mirroring `ServiceTaskBinding`. -A `timeout` task listener also carries the timer the event layer already models. -Amended by ADR-0032, which gives a listener's own brace block a field member alongside its bindings, because a field is none of the form, parameter, or nested-listener shapes this decision ruled out. -Amended by ADR-0047, under which a host-less timer handler's `jobPriority`, `retryCycle` and `exclusive` go on the trigger start event rather than the sub-process, since the timer job is that start's, and the printer lifts them back into the head while the start prints no statement. -Amended by ADR-0046, which spells the assignment event `assignment`, the word `BpmnParse.parseTaskListeners` reads, and writes every `timeout` listener with the id `BpmnParse.parseTimeoutTaskListener` requires. -Amended by ADR-0054, which drops the one-listener-per-event rule: `CoreModelElement.addListenerToMap` and `TaskDefinition.addTaskListener` both append rather than replace, so several listeners on one event run in the order they are written, and `TaskDefinition.addTimeoutTaskListener` keys each `timeout` listener by its id, so several of them each fire on their own timer. - -Related decisions: ADR-0016 (soft words), ADR-0017 (the timer particle clause), ADR-0019 (the hosted form), ADR-0021 (the exactly-one binding rule), ADR-0022 (engine attributes as named IR fields), and ADR-0014 (the import contract this surface honors). diff --git a/docs/decisions/0024-event-trigger-payloads-paren-slot.md b/docs/decisions/0024-event-trigger-payloads-paren-slot.md new file mode 100644 index 0000000..cb59621 --- /dev/null +++ b/docs/decisions/0024-event-trigger-payloads-paren-slot.md @@ -0,0 +1,73 @@ +--- +status: accepted +date: 2026-07-18 +decision-makers: Marlon Kranz +--- + +# Event trigger payloads in one paren slot + +## Context and Problem Statement + +The event layer's `on` handler already hosts two trigger kinds, error and escalation, sharing one payload shape: an optional code string and an optional parenthesized binding list. +Four more trigger kinds join the same `on` surface, message, signal, timer, and conditional, and between them they need three payload shapes the existing rule does not have. +Message and signal carry a name string, structurally identical to error/escalation's code. +Timer carries something different: a word choosing which of BPMN's three timer forms applies, followed by a time expression. +Conditional carries a boolean expression, which wants the same parenthesized slot error/escalation already use for bindings, but for an unrelated purpose: a condition, not a catch parameter. + +Fitting three payload shapes into one handler surface, without forking `on` into a separate grammar rule per trigger kind and without reserving a new keyword for each shape, is the central question this decision resolves. +Two narrower questions ride along with it: how a timer's payload text becomes one of BPMN's three timer definition kinds without silently picking the wrong one, and how much of BPMN's conditional-evaluation narrowing the surface should expose given that the DSL's audience has no reason to know it exists. + +## Decision Drivers + +- One `on` handler surface has to host all six trigger kinds without forking into six grammar rules or growing a reserved word for every new payload shape, and the soft-word design already in place for trigger names should not be reopened to make room for new payloads. +- A condition is a real expression, not a string. + It needs the same round-trip and variable-participation guarantees the language's `if` conditions already have, not a demotion to opaque text that a validator, a symbol table, or a highlighter cannot see inside. +- A timer's payload must resolve to exactly one of BPMN's three time-definition kinds. + Because a wrong resolution changes what the engine schedules, not just how the text reads, guessing the kind from the value's shape is a real correctness risk, not a cosmetic one. +- Message and signal need something for the engine to key its subscriptions on, and BPMN keeps that identity in root elements the same way it already does for error and escalation codes, so whatever the surface settles on must not reintroduce the declare-before-use boilerplate the language otherwise avoids. +- Camunda's conditional-evaluation narrowing attributes change when a condition is checked, so a surface that cannot express them would turn a silent drop on import into a behavior change. + +## Considered Options + +- One paren slot holding either a binding list or a condition, disambiguated structurally, which needs no new word and keeps the parameter-list reading the binding shape was built for. +- A binding-marker keyword freeing the parens for bindings only, writing them after a marker such as `as (...)`, which spends a new reserved word on a collision structural lookahead already dissolves for free and breaks the parameter-list reading (`on error(X, code: c, message: m)`, read like `catch (Exception e)`) that motivated the binding shape in the first place. +- A string-wrapped condition, written as a quoted literal instead of a real expression, which reads worse than the language's own `if (...)` and throws away everything a real expression AST buys, including undeclared-variable checking, type checking, symbol participation, and precise highlighting. +- Reserving `condition` as a keyword to dispatch the parens, which would be a lexer-global reservation, unusable as an ordinary variable name anywhere in a file, for exactly the reason the trigger and binding words already stay soft identifiers. +- Timer particles, `after`, `at` and `every`, as validated identifiers mapping one-to-one to BPMN's three timer forms, which read as the English they mean and cannot silently resolve to the wrong form. +- A single particle with the timer form inferred from the value's shape, where an ISO date looks like a date and a duration string looks like a duration, which shifts a scheduling decision onto pattern-matching the value's text: a mistyped ISO string, or an expression the engine evaluates at runtime rather than a literal the compiler can inspect, becomes a different timer kind than the one the author meant, with no diagnostic pointing at the mismatch. +- Deferring the conditional narrowing attributes and refusing them on import rather than dropping them. +- Surfacing them as a fourth payload shape, narrower than the other three and carrying nothing but an engine-side optimization hint the audience has no reason to know about. +- Dropping them silently on import, which would leave the imported process re-checking its condition on every variable change instead of the one the original author scoped it to. +- Message and signal roots derived from usage, keyed by name, with no declaration form, since the name is both the root's only data and its key. +- An explicit root declaration mirroring the error message declaration, which generalizes a mechanism that exists only because the error message text has no other source to two triggers for which usage already supplies every property in full. + +## Decision Outcome + +Chosen: one paren slot disambiguated structurally, timer particles mapping one-to-one with no inference, conditional narrowing deferred and refused rather than dropped, and message and signal roots derived from usage. +Each keeps the same principle: spend a new payload shape or a new reserved word only where the alternative loses something the surface cannot get back, whether round-trip fidelity, execution-accurate scheduling, or truthful runtime semantics, and nowhere else. + +The parentheses after a trigger's code stay a single grammar position that holds either a binding list or a condition expression, and which one is present is decided structurally, not by which trigger word precedes it. +A binding list is a field name followed by a variable name, two identifiers in a row, and the expression sub-language never places two identifiers adjacently, since every accessor starts with `.` or `[` and every operator level requires an operator token in between. +The parser therefore tells the two apart by looking at the second token after the opening parenthesis, which settles it before any per-trigger legality is considered, and whether a given trigger may use bindings, a condition, or neither is the kind of position rule the validator already owns for every other soft word. + +A timer's payload is a particle word followed by a time expression, each particle mapping to exactly one of BPMN's timer forms, a duration, a fixed date, or a repeating cycle. +The particle is a plain identifier, checked in position by the validator like every other soft trigger word, not a keyword. + +BPMN's conditional event definition can additionally narrow when a condition is re-evaluated, to a named variable or a specific kind of variable change, rather than re-checking on every change. +This narrowing is an optional, engine-side evaluation optimization: a handler behaves correctly without it, re-checking on any variable change instead of a specific one, so the surface does not expose it. +Because the attributes change when the engine re-checks a condition, they are runtime semantics rather than cosmetic metadata, so importing a document that carries them refuses rather than dropping them, and the refusal names the attribute so a future surface has somewhere to land it. + +A message or signal root carries exactly one piece of information the engine cares about: its name, which is also the identity the engine keys its subscriptions and correlations on. +Because the name is both the only data and the natural key, the root is fully derivable from wherever the name is used, whether a handler, a throw, or an emit, with no separate declaration form and no new field on the process's structure, and every use of the same name anywhere in the document resolves to the same root. +This differs from the error and escalation declarations ADR-0023 settles: a code is declared in the process header and named at each of its sites, which gives it a cross-reference and gives its thrown message one place to live, since two throw sites sharing a code might disagree on wording. +A message or signal name has neither need, so no declaration form is introduced for one, and ADR-0023's tooling argument would apply to a message and a signal name too, which is a decision of its own and is not taken here. + +### Consequences + +- Good, because none of the four decisions spends a new reserved word: message, signal, timer, and conditional slot into the existing soft-word and paren machinery at zero reservation cost, the same guarantee the error/escalation design already made for the trigger set as a whole. +- Good, because one verb pair, `throw` and `emit`, continues to span every trigger kind that has a throw form at all. + Signal joins error and escalation under the same rule instead of growing its own vocabulary, so the reader who has learned the rule once for error and escalation does not have to relearn it for signal. +- Good, because the condition payload stays a real, checkable expression rather than an opaque string, so a condition handler gets the same variable checking, symbol participation, and highlighting an `if` statement already gets. +- Bad, because a document carrying the conditional narrowing attributes cannot be imported at all, even though the underlying evaluation difference is invisible to a reader who does not already know Camunda's narrowing mechanism exists. + The refusal is correct but reads as stricter than the visible difference suggests. +- Bad, because timer's three particles are one more small vocabulary to learn than a single particle would have been, paid once in exchange for a scheduling decision that cannot silently resolve to the wrong timer kind. diff --git a/docs/decisions/0024-event-triggers-on-start-end-and-throw.md b/docs/decisions/0024-event-triggers-on-start-end-and-throw.md deleted file mode 100644 index 44c0371..0000000 --- a/docs/decisions/0024-event-triggers-on-start-end-and-throw.md +++ /dev/null @@ -1,157 +0,0 @@ ---- -status: accepted -date: 2026-08-29 -decision-makers: Marlon Kranz ---- - -# Event Triggers at the Process Boundary: Four Start Kinds, `terminate` on `end`, and the Message End on `throw` - -## Context and Problem Statement - -Every event trigger this language writes is caught or raised inside a process that is already running. -No trigger reaches the two positions that bracket a process. -So a correlated order, a broadcast, and a nightly schedule all have to be modeled outside the process they start. -A branch that decides nothing else should continue cannot stop a sibling parked on a user task. -Seven BPMN elements sit in that gap: a message, signal, timer, or condition start event, a terminate end event, a message end event, and a message intermediate throw. -Where does each one land, given that `start`, `end`, `throw`, and `emit` could all plausibly carry the clause? -And which of them does Operaton actually execute? -The specification is more permissive at the start position than the engine is, in a direction that produces no error. - -## Decision Drivers - -- One IR shape has to print in exactly one form. - Otherwise the printer arbitrates on every decompile by a rule invisible to whoever reads the result. -- The honest import contract (ADR-0014) refuses what changes the run and warns only about drops that do not. - So Operaton's parser rather than the BPMN specification decides what is worth emitting. -- Whatever import accepts, the decompiler has to be able to write back. -- Every trigger word is a soft identifier (ADR-0016), so a new clause stays unambiguous by token position alone. - A rule that reads unambiguously to a human is not evidence. - -## Considered Options - -- `throw message("Name")` and `emit message("Name")` for the message end and the message intermediate throw -- `end E message("Name")`, the message end as a clause on the end statement -- `end terminate(label: "...")` for the terminate end event -- `throw terminate` -- Message, signal, timer, and condition as the triggers a start event carries -- Every trigger the specification allows on a start event, adding error, escalation, and compensation -- A start trigger only on a process's own start event -- A start trigger on any start event, including a sub-process's and an event handler's - -## Decision Outcome - -Chosen options: `throw message("Name")` and `emit message("Name")` for the message end and the message intermediate throw. -`end terminate(label: "...")` for the terminate end. -Message, signal, timer, and condition as the triggers a start event carries. -A process's own start event as the only position that may carry one. -The terminate is the one place here where the option costing no grammar was not taken. -`end` carries the label a diagram's terminate node needs, and `throw` drops one. - -`end E message("Name")` still parses, deliberately. -A kind belonging on `throw` earns a validator message naming the statement to write, instead of a parse error (`endTriggerMessage`). -The validator refuses the two illegal start positions the same way, each with its own wording (`checkStartEvent`, `packages/language/src/bpmn-script-validator.ts`). - -The accepted set is `START_TRIGGERS` (`packages/language/src/vocabulary.ts`). -Import refuses what lies outside it with wording about the degradation rather than about the element type (`IGNORED_START_SUBJECTS` and `readStartTrigger`). -A conditional start is accepted rather than refused, because the engine dispatches on it where it ignores an error, escalation, or compensation one, so the set that stops where the engine stops includes it. - -A container has one start event, already implied by requiring an explicit `start` to be the first statement of its body (`checkStartPosition`). -A modeler document carrying two of them is refused by name and by count (`refuseMultipleStartEvents`, `packages/transform/src/xml-to-ir.ts`). - -Neither clause needs a new reserved word. -`name=ID` is mandatory and comes first in both the `StartEvent` and `EndEvent` rules (`packages/language/src/bpmn-script.langium`). -The second token therefore decides everything: an `ID` is the trigger, a `(` the settings, and a `{` the members. -Nothing else can appear there, because every statement in this grammar is keyword-led. -Both rules were built into a live Langium parser with Chevrotain's self-analysis on. -The generator reported no ambiguity. -`terminate` and the four start words therefore stay soft identifiers, and `var terminate: string` parses beside `end Done terminate`. - -One class of name cannot be imported at all, for a lexical rather than a semantic reason. -`RAW_TEMPLATE` is declared before `STRING` (`packages/language/src/bpmn-script.langium`), so a quoted name opening with `${` lexes as a raw expression. -Import refuses such a name wherever a throw or catch position resolves its root (`resolveNamedRootRef`, `packages/transform/src/xml-to-ir.ts`). -The diagnostic names the `#{...}` form that survives. -A message start refuses both spellings, because Operaton rejects an expression anywhere in that name. - -### Consequences - -- Good, because the message end and the message intermediate throw cost no grammar and read like the signal end and signal emit beside them. - One IR shape keeps one printed form. -- Good, because the four start triggers reuse the payload surfaces and name-keyed roots ADR-0017 already built. - The set stops where the engine stops, so an import either runs as it reads or refuses and names the element that stopped it. -- Bad, because a thrown message carrying a send implementation refuses on import rather than importing without it. - `operaton:class`, `expression`, `delegateExpression`, `type`, `topic`, and an `` child are what make the engine really send the message. - Dropping one would turn a real send into a no-op. -- Bad, because a terminate end always prints, even when its id was synthesized (`isElidedOnPrint`, `packages/transform/src/ir-to-dsl.ts`). - A modeler document decompiles to `end EndEvent_1 terminate`, and re-parsing reports the reserved prefix (ADR-0010). -- Bad, because a branch that terminates does not rejoin. - An `if` whose branch ends at a terminate prints as a `goto`, and a `parallel` whose branch terminates leaves its join with one incoming flow (`joinContinuation`). - -### Confirmation - -`packages/language/test/parsing.test.ts` and `validating.test.ts` pin the two clauses token by token and every diagnostic behind them. -`packages/transform/test/xml-to-ir.test.ts` pins each refusal by error class and message. -`ast-to-ir.test.ts` and `ir-to-dsl.test.ts` pin the lowering and the printed line for every authored shape. -`packages/cli/test/decompile-contract.e2e.test.ts` pins that a refused trigger exits nonzero and writes no output file. -The frozen pair `tests/golden/event-positions.{bpmnscript,bpmn}` holds a message start, an `emit message`, a `throw message`, and a terminate end. -`tests/event-positions.round-trip.test.ts` compares the compiled XML byte for byte and requires an import with no warning at all. -`tests/new-constructs.round-trip.test.ts` carries the timer, signal, and conditional starts. -`tests/e2e/event-positions.test.ts` deploys three processes to a real Operaton through Testcontainers. -A correlated message with no instance to aim at creates one, a broadcast signal creates one with both branches active, and a timer parks a job. -The terminate cancels a sibling branch parked on a user task, and both thrown messages pass the token through to completion. -That last result is what shows an implementation-free message throw deploys and runs. - -## Pros and Cons of the Options - -### `throw message("Name")` and `emit message("Name")` - -- Good, because both statements already parse a trigger word, an optional name, and a quoted code, so it needs no grammar. -- Bad, because an id matching the synthesized shape drops off a printed throw, so a `goto` cannot target a nameless message end. - -### `end E message("Name")` - -- Bad, because one IR shape would carry two printed forms, and the printer would have to choose per kind. - -### `end terminate(label: "...")` - -- Good, because `end` carries a label, so a diagram's terminate node keeps its caption through a round trip. -- Good, because it says what the element does, which is to end rather than to raise. - -### `throw terminate` - -- Bad, because `throw` means raise this event and end this path, and a terminate raises nothing and ends every path. -- Bad, because `throw` drops a label, so importing a labeled terminate would warn about a caption a diagram carries. - -### Message, signal, timer, and condition on a start - -- Good, because these are the four the engine builds a start behavior for, so what is written is what runs. - -### Every trigger the specification allows on a start event - -- Bad, because Operaton's `BpmnParse` ignores an error, escalation, or compensation definition in `parseProcessDefinitionStartEvent` and builds a `NoneStartEventActivityBehavior` instead. -- Bad, because such a document would import and print without a diagnostic, then start unconditionally. - -### A trigger only on a process's own start - -- Good, because Operaton rejects a trigger on an embedded sub-process start, so accepting one would produce an undeployable file. -- Good, because an event handler's trigger has exactly one home, the `on` header, so nothing competes for it. - -### A trigger on any start - -- Bad, because a sub-process start carrying one is a deployment failure rather than a degradation. - -## More Information - -Superseded by ADR-0026 in the one consequence above recording a thrown message's send implementation as refused on import, which that surface now carries; the rest of this decision stands. -ADR-0026 (task kinds on the authoring surface) supersedes the message-throw implementation refusal recorded above: Operaton reads that implementation off the `bpmn:messageEventDefinition`, so the same attributes on the event itself are inert. - -Amended by ADR-0034, which lifts the one-start-per-container rule for a process body and leaves it standing for a subprocess, a transaction, and an event handler. - -Amended by ADR-0044, which lifts the lexical refusal of a message or signal name opening with `${`; the refusal of an expression on a message start stays, for the engine's reason. - -Related decisions: ADR-0016 (soft trigger words and the `throw`/`emit` terminality rule). -ADR-0017 (the payload surfaces the four start triggers reuse, including the timer mapping). -ADR-0014 (the honest import contract behind every refusal here). -ADR-0013 (the reason a start trigger is a clause rather than a declaration). -ADR-0020 (the live-parser ambiguity check and the rejected `await ` shape). -ADR-0009 (the restructuring that prints a terminating branch as a `goto`). -ADR-0010 (the reserved synthesized-id prefixes a printed terminate end meets). diff --git a/docs/decisions/0025-authoring-inclusive-and-event-based-gateways.md b/docs/decisions/0025-authoring-inclusive-and-event-based-gateways.md deleted file mode 100644 index 0ae5416..0000000 --- a/docs/decisions/0025-authoring-inclusive-and-event-based-gateways.md +++ /dev/null @@ -1,171 +0,0 @@ ---- -status: accepted -date: 2026-08-30 -decision-makers: Marlon Kranz ---- - -# Inclusive and Event-Based Gateways: Conditioned `parallel` Branches, a Multi-Branch `await`, and No Gateway Label - -## Context and Problem Statement - -Every BPMN element the language has taken on so far had a construct to hang off. -`bpmn:inclusiveGateway` and `bpmn:eventBasedGateway` have nothing to attach to, because under ADR-0008 no author ever writes a gateway. -The desugarer synthesizes every gateway from block structure, and the decompiler elides every one it matches. -That elision is what makes the round trip idempotent, and the grammar states the premise in its own header (`packages/language/src/bpmn-script.langium`). -Both kinds are refused on import today (`mapContainerChildren`, `packages/transform/src/xml-to-ir.ts`). - -So the question is not which keyword spells "inclusive gateway". -It is whether block structure can express the intent well enough that the compiler still picks the element. -A gateway's `name` survives XML to IR to XML, then dies at the print hop, where no gateway has a statement form to carry it (`normalizeContainer`, `tests/helpers/normalize-ir.ts`). - -## Decision Drivers - -- ADR-0013 binds two rules: a keyword names what the user means rather than the BPMN element, and the compiler must not require what it can infer. -- Gateway elision is what ADR-0009's decompiler relies on for idempotence. - A gateway with textual identity gains an authored id and stops round-tripping under ADR-0010's positional scheme. -- Operaton does not police the two kinds equally: an inclusive gateway gets no structural validation, an event-based gateway a list of them. -- Trigger words are soft identifiers by design (ADR-0016), so a rule proves nothing until a live parser accepts it. -- ADR-0014's contract covers `xmlToIr` only, and `irToDsl` returns a bare `string` (`packages/transform/src/ir-to-dsl.ts`). - A drop on the way out has no channel to be reported through. - -## Considered Options - -- For the inclusive split, conditioned `parallel` branches headed by `if` and `else` -- For the inclusive split, branches headed by a bare parenthesized condition -- For the inclusive split, a separate construct with its own reserved keyword -- For the fallback branch, the compiler routes the default flow to the join when no `else` is written -- For the fallback branch, a written `else` required whenever every other branch is conditioned -- For the race, a multi-branch `await` opened by a brace instead of a trigger word -- For the race, a distinct keyword such as `race` -- For the race's join, a synthesized exclusive join the block falls through -- For the race's join, self-contained branches that rejoin by `goto` -- For the gateway label, no slot, with the print-side drop reported through a warnings channel out of `irToDsl` -- For the gateway label, a slot on `if`, `while`, `parallel`, and the two new forms -- For the gateway label, no slot, and the drop stays silent -- A `gateway` keyword, or a flat node-and-edge escape hatch - -## Decision Outcome - -Both gateways stay fully synthesized and fully elided. -Nothing in the surface names a gateway, and ADR-0010's positional ids keep working unchanged. -Every form below parsed with zero ambiguity warnings in a live Langium parser with Chevrotain self-analysis on. - -A branch of a `parallel` block may be headed by `if (condition)`, by `else`, or by nothing at all. -The compiler picks the element: no condition anywhere means `bpmn:parallelGateway`, and a condition on any branch means `bpmn:inclusiveGateway` on the fork and the join. - -```bpmnscript -process expense-approval { - var amount: number - var repeatCustomer: boolean - - parallel { - if (amount > 10000) { user Audit } - if (repeatCustomer) { service RecordReceipt(topic: "receipts") } - else { user ManualTriage } - } -} -``` - -Operaton takes every non-default flow that has no condition or a true one, and adds the default only when that set comes out empty (`InclusiveGatewayActivityBehavior.execute`). -The default flow is always emitted and never asked for. -With an `else` it points at that branch; without one it goes straight to the join, the rule `lowerIf` already applies at the same position (`packages/transform/src/ast-to-ir.ts`). -That is a lowering rule rather than a validator rule because `BpmnParse.parseInclusiveGateway` validates nothing. -An all-conditional gateway without a default deploys, runs, then throws a stuck execution when nothing matches (`InclusiveGatewayActivityBehavior.execute`). -A branch carrying no condition is in the taken set every time, which is why an `else` written beside one is rejected: the set never comes out empty and the fallback behind the `else` never runs. - -The race is a multi-branch `await`. -`await` already means the token stops here until this resolves, and a race is that meaning over a set. - -```bpmnscript -process order-shipping { - await { - message("PaymentReceived") { service ShipOrder(topic: "shipping") } - timer("P3D") { user ChaseCustomer } - } -} -``` - -A branch is a trigger header in the payload grammar `on` and `await` share, its settings, then the body. -Two branches are the minimum, enforced by the grammar as `ParallelStatement` already does (`bpmn-script.langium`). -The triggers are the four `await` already takes, which is also everything Operaton accepts in this position (`BpmnParse.parseIntermediateCatchEvent`). -Reusing the word costs nothing, because `await {` cannot parse today: the `IntermediateCatchEvent` shape takes a trigger word after the keyword (`bpmn-script.langium`). - -The race falls through to a synthesized exclusive join, reusing `if`'s join and its pruning when every branch terminates (`pruneUnreachableJoin`, `ast-to-ir.ts`). -Operaton gives every catch event behind an event-based gateway the start behavior `CANCEL_EVENT_SCOPE` (`BpmnParse.parseIntermediateCatchEvent`), so exactly one branch of a race ever runs. - -An inclusive pair reuses `Gateway__fork` and `Gateway__join` unchanged, since exactly one `parallel` statement sits at any structural coordinate. -The race adds one template, `Gateway__race`, whose segment word joins the reserved-name pattern. - -Neither `if`, `while`, `parallel`, nor either new form gains a label slot, and the reason is structural. -One construct lowers to two gateways, so a single slot carries at most half of what an imported document may hold. -Instead `irToDsl` gains a warnings channel, and the dropped label is one of the reports it carries; which reports the print hop makes and what each one costs is `PrintWarningCategory` and the warnings built beside it in `packages/transform/src/ir-to-dsl.ts`. -Its signature moves from `string` to a result carrying source and warnings, as ADR-0014 did to `xmlToIr`, since a channel a caller can skip is a silent drop. - -This extends the structured surface rather than breaching it. -Every gateway is still derived from block structure, none is written, and none is named, so the grammar header's claim holds word for word. - -Both gateways move from a blanket refusal to a carry. -An inclusive gateway carries with no exceptions, reading `name` and `default`, because Operaton refuses nothing about it. -An event-based gateway carries, refusing three shapes, each following a refusal in Operaton's own parser: - -- an outgoing flow whose target is not a `bpmn:intermediateCatchEvent` (`BpmnParse.parseEventBasedGateway`) -- a downstream catch carrying a link definition (`BpmnParse.parseIntermediateCatchEvent`), which `mapIntermediateCatchEvent` turns away wherever a link appears, so the gateway needs no rule of its own for it -- a downstream catch reached by more than one path (`BpmnParse.parseSequenceFlow`), counted over every path in - -The last is wider than the engine's rule: Operaton lets a path through where it leaves an event-based gateway, so it takes a catch reached only by such paths, and this refuses that too. -Neither that shape nor the one Operaton refuses is authorable, so the extra width costs no script anything. - -`parseEventBasedGateway` rejects `operaton:asyncAfter` on the gateway itself as well. -Since ADR-0040 a multi-branch `await` head takes the gateway's job settings, so the validator refuses `asyncAfter` there under the same rule, and the import refuses a document carrying it rather than dropping it. - -### Consequences - -- Good, because both gateways stay synthesized and elided, so idempotence, the id scheme, and the control-flow analysis need no new rule and no new IR flag. -- Good, because neither construct spends a reserved word; reserving `race` would take `var race: string` and `user race` out of the language. -- Bad, because a conditioned `parallel` reads as one construct but compiles to either of two BPMN elements, so a BPMN-literate reader must read every branch. -- Bad, because `irToDsl` changes its return shape, touching every call site, and the drop that motivated it costs the reader a gateway's name rather than anything the process does. -- Bad, because the label comes back on a BPMN-to-BPMN pass but never on a BPMN-to-DSL one, so a decompiled and recompiled document loses every gateway label. -- Bad, because a conditional branch of a race can win without waiting (`EventBasedGatewayActivityBehavior.execute`), which no part of the surface shows. -- Bad, because an inclusive fork closed on an exclusive merge, the common hand-drawn shape, degrades to `goto`s a reader repairs by hand. - -### Confirmation - -Parser tests pin a `parallel` block mixing an `if` branch, a plain branch and an `else` branch, and a plain branch opening with a nested `if`/`else`. -`await { ... }` parses as a race while `await timer("PT1H")` still parses as a single catch. -Validation tests pin the four-trigger scope, the two-branch minimum as a parse failure, a second `else` branch on one statement, an `else` branch with no conditioned sibling, and an `else` branch beside a sibling carrying no condition. -Transform tests pin element selection, the default-flow rule, the race's join and its pruning, the inclusive carry, and each refusal by name. -They also pin elision of both new pairs and the dropped-label warning reaching the caller. -Golden fixtures assert IR idempotence after normalization and that the decompiled source recompiles cleanly. -A Docker-gated end-to-end test shows the losing race branch canceled and the inclusive join waiting for exactly the branches taken. - -## Pros and Cons of the Options - -- Conditioned branches on `parallel` add no keyword, no statement, and no id template, but the element choice is invisible in the block's keyword. -- A bare parenthesized condition parses as cleanly, but a parenthesized expression opening a line is a shape this language has nowhere else. -- A separate reserved keyword would make the element visible, but it spends a word and names the BPMN element rather than what the user means. -- Routing the default to the join reuses a rule `lowerIf` already applies, at the cost of a fallback invisible in the source. -- Requiring a written `else` would make that fallback visible, but it is required syntax for something the compiler can infer. -- A multi-branch `await` reserves no word and generalizes a meaning the keyword already carries. -- A distinct keyword could not be confused with anything, but it costs the language a name authors may want. -- A synthesized exclusive join with fall-through reuses `if`'s join and cannot duplicate a token, at the cost of a join that sees one. -- Self-contained branches rejoining by `goto` would match a hosted `on` handler, but would force a `goto` for the common timeout case. -- No label slot with the drop reported keeps every gateway elided and puts the label on the same channel as the print hop's other reports, at the cost of a signature change. -- A label slot is the only way a label survives a full BPMN to DSL to BPMN round trip, but a labeled gateway is an authored gateway. -- Leaving the drop silent costs nothing, and it is the one thing ADR-0014's contract exists to prevent. -- A `gateway` keyword or a flat node-and-edge form would express every gateway shape, but a gateway that prints re-parses with an authored id. - -## More Information - -Related decisions: ADR-0008 and ADR-0013 set the surface and its two rules, ADR-0009 gains an inclusive pattern and a race pattern, and ADR-0010 gains one id template. -ADR-0014 supplies the import contract extended here to the print hop, ADR-0016 the soft trigger words, and ADR-0017 the payload surface a race branch header reuses. -ADR-0019 supplies the live-parser method, and ADR-0020 the keyword and trigger scope the race inherits. -Operaton behavior was read from `BpmnParse.java`, `InclusiveGatewayActivityBehavior.java`, and `EventBasedGatewayActivityBehavior.java` in the `operaton/operaton` repository. -The documented restrictions are from . -The dataset behind Compagnucci, Corradini, Fornari and Re (BISE 66(1), 2024, DOI 10.1007/s12599-023-00818-7) has an event-based gateway in roughly 12 percent of its 38,863 models and an inclusive gateway in roughly 6 percent. - -Amended by ADR-0035, which admits a link definition on an intermediate catch and an intermediate throw on import. -The event-based gateway's second refusal now rests on the rule that no sequence flow may enter a link catch, which the importer enforces for every flow and so for a branch of a wait as well. - -Amended by ADR-0040, which gives every gateway synthesized here a settings parens on its statement head, so a multi-branch `await` now refuses `asyncAfter` by validation rather than by having nothing to write it on. - -Amended by ADR-0045, which reserves the inclusive fork's default flow only beside an `else` or when every branch is conditioned; beside an unheaded branch, which the engine takes every time, none is written. diff --git a/docs/decisions/0025-events-at-the-process-boundary.md b/docs/decisions/0025-events-at-the-process-boundary.md new file mode 100644 index 0000000..bf0d986 --- /dev/null +++ b/docs/decisions/0025-events-at-the-process-boundary.md @@ -0,0 +1,113 @@ +--- +status: accepted +date: 2026-08-29 +decision-makers: Marlon Kranz +--- + +# Events at the process boundary: starts, ends and throws + +## Context and Problem Statement + +Most event triggers this language writes are caught or raised inside a process that is already running. +The two positions that bracket a process are the open question. +A correlated order, a broadcast, and a nightly schedule would otherwise be modeled outside the process they start, and a branch that decides nothing else should continue could not stop a sibling parked on a user task. +Seven BPMN elements sit in that gap: a message, signal, timer, or condition start event, a terminate end event, a message end event, and a message intermediate throw. +Where does each one land, given that `start`, `end`, `throw` and `emit` could all plausibly carry the clause, and which of them does Operaton actually execute? +The specification is more permissive at the start position than the engine is, in a direction that produces no error. + +How many starts a process may carry is the second half of the same question. +Requiring an explicit `start` as the first statement of its container reads one start event per container off that rule, but Operaton draws the line elsewhere. +`BpmnParse.parseStartEvents` accepts any number of start events on a process definition, and only a scope that is not a process goes through `BpmnParse.parseScopeStartEvent`, which reports an error on the second start of an embedded sub-process, a transaction, or an event sub-process. +One start per container is therefore an invariant of a surface, not of the engine, and it costs real files: a modeler document with a plain start beside a message start, the ordinary shape of a process entered both by hand and by correlation. +So where may a second `start` sit on the page, what does the flow around it mean, and which facts about a start set are worth a diagnostic? + +## Decision Drivers + +- One IR shape has to print in exactly one form. + Otherwise the printer arbitrates on every decompile by a rule invisible to whoever reads the result. +- The honest import contract refuses what changes the run and warns only about drops that do not, so Operaton's parser rather than the BPMN specification decides what is worth emitting, and a refusal has to name an engine reason. +- Whatever import accepts, the decompiler has to be able to write back in a form that re-desugars to the same graph. +- Every trigger word is a soft identifier, so a new clause stays unambiguous by token position alone. + A rule that reads unambiguously to a human is not evidence. +- The page must say what the graph does. + Steps run top to bottom and sequence flow is never written out, so a statement the flow runs past without entering would be the one silent exception. +- The engine's start matrix is silent at deploy time in two places that bite at runtime, and this surface reports what the engine will not. +- No grammar change for the start set: `Statement` already admits a `start` anywhere in a block, so that half is a question of validation and lowering. + +## Considered Options + +- `throw message("Name")` and `emit message("Name")` for the message end and the message intermediate throw, which need no grammar, since both statements already parse a trigger word, an optional name, and a quoted code, but drop an id matching the synthesized shape off a printed throw, so a `goto` cannot target a nameless message end. +- `end E message("Name")`, the message end as a clause on the end statement, under which one IR shape would carry two printed forms and the printer would have to choose per kind. +- `end terminate(label: "...")` for the terminate end event, which says what the element does, to end rather than to raise, and carries a label, so a diagram's terminate node keeps its caption through a round trip. +- `throw terminate`, where `throw` means raise this event and end this path while a terminate raises nothing and ends every path, and where the missing label would make an imported labeled terminate warn about a caption a diagram carries. +- Message, signal, timer and condition on a start, the four the engine builds a start behavior for, so what is written is what runs. +- Every trigger the specification allows on a start event, adding error, escalation and compensation, which Operaton's `BpmnParse` ignores in `parseProcessDefinitionStartEvent`, building a `NoneStartEventActivityBehavior` instead, so such a document would import and print without a diagnostic and then start unconditionally. +- A trigger only on a process's own start event, since Operaton rejects a trigger on an embedded sub-process start and an event handler's trigger has exactly one home, the `on` header. +- A trigger on any start event, including a sub-process's and an event handler's, where a sub-process start carrying one is a deployment failure rather than a degradation. +- Flat sibling `start` statements in a process body, which need no grammar change and let one file carry the shape a modeler draws. +- A grouped entry block holding every start, which invents a container the engine has no counterpart for. +- A process body only by engine rule, or every container with the engine's refusal surfacing at deployment, the second of which hands the author a deployment failure the validator could have named. +- An error naming the ambiguity when a start follows a statement whose flow is still live. +- A start the flow runs past, joining its chain to the one before it, which refuses no page, but would be the one place where the page does not say what the graph does. + +## Decision Outcome + +Chosen: `throw message("Name")` and `emit message("Name")` for the message end and the message intermediate throw, `end terminate(label: "...")` for the terminate end, message, signal, timer and condition as the triggers a start event carries, a process's own start event as the only position that may carry one, and flat sibling `start` statements in a process body with an error for a start after a live chain and a warning for each of the two silent engine facts. +The terminate is the one place here where the option costing no grammar was not taken: `end` carries the label a diagram's terminate node needs, and `throw` drops one. + +`end E message("Name")` still parses, deliberately. +A kind belonging on `throw` earns a validator message naming the statement to write, instead of a parse error (`endTriggerMessage`), and the validator refuses the two illegal start positions the same way, each with its own wording (`checkStartEvent`, `packages/language/src/bpmn-script-validator.ts`). +The accepted trigger set is `START_TRIGGERS` (`packages/language/src/vocabulary.ts`), and import refuses what lies outside it with wording about the degradation rather than about the element type (`IGNORED_START_SUBJECTS` and `readStartTrigger`). +A conditional start is accepted rather than refused, because the engine dispatches on it where it ignores an error, escalation, or compensation one, so the set that stops where the engine stops includes it. + +Neither clause needs a new reserved word. +`name=ID` is mandatory and comes first in both the `StartEvent` and `EndEvent` rules (`packages/language/src/bpmn-script.langium`), so the second token decides everything: an `ID` is the trigger, a `(` the settings, and a `{` the members. +Nothing else can appear there, because every statement in this grammar is keyword-led, and both rules were built into a live Langium parser with Chevrotain's self-analysis on, which reported no ambiguity. +`terminate` and the four start words therefore stay soft identifiers, and `var terminate: string` parses beside `end Done terminate`. + +The attributes that make the engine really send a thrown message, `operaton:class`, `expression`, `delegateExpression`, `type`, `topic`, and an `` child, are read off the `bpmn:messageEventDefinition`, which is the surface the task kinds carry; the same attributes on the event element itself are inert. + +A `start` opens a chain and takes no incoming flow, so it may sit first in the process body, directly after another `start`, or after a statement whose flow always ends or redirects, and a start after a step whose flow is still live is refused with a message naming the fix, because either reading of that page invents a flow the author did not write. +That freedom holds for a process body only: an embedded `subprocess`, an `attempt` block, and an event-handler body keep exactly one start, at their head, because `parseScopeStartEvent` rejects the second start of any scope that is not a process definition, and a second start in one of them is refused on import by count (`checkStartEventCount`, `packages/transform/src/xml-to-ir.ts`), naming that engine reason, while a process is let through. + +Two warnings report what the engine decides in silence. +`BpmnParse.selectInitial` makes a lone start of any kind the default, and among several starts only a plain or timer start; with two or more starts and none of those two kinds the process has no default, and `ProcessDefinitionImpl.ensureDefaultInitialExists` throws when such a process is started by key, so the validator warns on the process name. +`BpmnParse.parseStartFormHandlers` binds a start form to the default start alone, so a `form` block on any other start is parsed and never offered, and the validator warns on that block. +Both stay warnings: the process deploys and runs, and being entered by message or signal is the point of writing such a process. + +The decompiler walks an unnamed plain start ahead of every other, since such a start is elided on print and re-derived by the compiler at the head of the body, so its chain has to be the first one printed, else the entry point is lost on the way back. +One corner is accepted rather than fixed. +A start whose first step is a synthesized gateway prints the branch when every start into that gateway prints, so `start A`, `start B`, `if (x) { ... }` round-trips clean, but the corner survives when one of those starts is the elided plain start, the one carrying the minted `StartEvent_` id and no content: its chain is walked first, the `if` printed for it consumes the gateway's out-edges, and the other start prints back as a dropped-edge marker. +No source spells that shape, since `goto` names an authored statement and never a synthesized gateway, so it reaches the printer through an imported document alone, and the authoring rule that keeps clear of it is to write each start ahead of a named step, which the golden fixture and the `support-ticket` example both obey. + +### Consequences + +- Good, because the message end and the message intermediate throw cost no grammar and read like the signal end and signal emit beside them. + One IR shape keeps one printed form. +- Good, because the four start triggers reuse the payload surfaces and name-keyed roots ADR-0024 already built. + The set stops where the engine stops, so an import either runs as it reads or refuses and names the element that stopped it. +- Good, because a process entered by hand and by message, a common reason to draw two starts, is one file and one diagram instead of two processes. +- Good, because the chain rule is one the reader can check on the page, and the desugarer never has to define the ambiguous shape. + Operaton does not reject a start event with an incoming flow: `BpmnParse.parseSequenceFlow` has no arm for a start-event destination, and `NoneStartEventActivityBehavior` inherits `FlowNodeActivityBehavior.execute`, which leaves the activity at once, so a start the flow runs past would deploy and run as a pass-through step, and the mistake would show only in history. + Refusing the shape is what rules that out. +- Neutral, because `initiator` is read off every start and set on the process definition by `BpmnParse.parseProcessDefinitionStartEvent`, last one wins, and the validator warns on every start but the last that names one. +- Neutral, because a message start whose name is already subscribed by another deployed definition fails in `BpmnDeployer.addMessageStartEventSubscription`, a fact about the deployment rather than the file, and out of this tool's reach. +- Bad, because a terminate end always prints, even when its id was synthesized (`isElidedOnPrint`, `packages/transform/src/ir-to-dsl.ts`). + A modeler document decompiles to `end EndEvent_1 terminate`, and re-parsing reports the reserved prefix. +- Bad, because a branch that terminates does not rejoin. + An `if` whose branch ends at a terminate prints as a `goto`, and a `parallel` whose branch terminates leaves its join with one incoming flow (`joinContinuation`). +- Bad, because the printed form of a process with several starts is not always the authored one: starts entering the same step print back to back above it, even one written after an `end` with a `goto` onto that step. + The graph is the same, which the idempotence block asserts, but the reader has to learn the shape. +- Bad, because the corner above means an imported document with an unnamed plain start into a shared synthesized gateway round-trips with a warning rather than cleanly. + +### Confirmation + +`packages/language/test/parsing.test.ts` and `validating.test.ts` pin the two clauses token by token and every diagnostic behind them, and the validator rows pin every position a start may take, the chain error, and both warnings. +`packages/transform/test/xml-to-ir.test.ts` pins each refusal by error class and message, `ast-to-ir.test.ts` and `ir-to-dsl.test.ts` the lowering and the printed line for every authored shape, and `packages/cli/test/decompile-contract.e2e.test.ts` that a refused trigger exits nonzero and writes no output file. +The frozen pair `tests/golden/event-positions.{bpmnscript,bpmn}` holds a message start, an `emit message`, a `throw message`, and a terminate end, and `tests/event-positions.round-trip.test.ts` compares the compiled XML byte for byte and requires an import with no warning at all; `tests/new-constructs.round-trip.test.ts` carries the timer, signal, and conditional starts, and the frozen pair `tests/golden/multiple-starts.{bpmnscript,bpmn}` holds four starts across two chains, its suite asserting at every hop that all four keep their trigger and none is the target of a sequence flow. +`tests/e2e/event-positions.test.ts` deploys to a real Operaton through Testcontainers: a correlated message with no instance to aim at creates one, a broadcast signal creates one with both branches active, a timer parks a job, the terminate cancels a sibling branch parked on a user task, and both thrown messages pass the token through to completion, which is what shows an implementation-free message throw deploys and runs. +The same suite starts `support-ticket`, a process with a plain and a message start, once by key and once by message, and each instance's history holds exactly its own start and the shared task, which is the assertion a pass-through start would fail. + +## More Information + +ADR-0024 carries the payload surfaces the four start triggers reuse, including the timer mapping. diff --git a/docs/decisions/0026-intermediate-catch-events.md b/docs/decisions/0026-intermediate-catch-events.md new file mode 100644 index 0000000..cbc5040 --- /dev/null +++ b/docs/decisions/0026-intermediate-catch-events.md @@ -0,0 +1,67 @@ +--- +status: accepted +date: 2026-07-24 +decision-makers: Marlon Kranz +--- + +# Intermediate catch events + +## Context and Problem Statement + +An `on` handler is a racing scope guard, compiling to an event sub-process that competes with the whole container it guards, and `throw` and `emit` go the other way, making the current path fire a trigger outward. +Neither covers `bpmn:intermediateCatchEvent`, a node sitting on the main sequence flow where the token stops in place until the trigger fires and then carries on to whatever follows. +Three questions settle together: what word introduces it, given that trigger words and timer particles are soft identifiers so only token position can disambiguate; whether it carries an author-chosen name; and which of BPMN's catchable event kinds this attachment point exposes. + +## Decision Drivers + +- Every trigger word and every timer particle is a soft identifier (ADR-0023), so a rule sharing that vocabulary has to be run through the parser generator and shown clean, not merely look plausible. +- The payload surfaces for message, signal, timer, and conditional triggers (ADR-0024) are already reused by `on` handlers and boundary events, so a third consumer should cost one dispatch case per layer rather than a second payload grammar. +- The restructuring analysis and the round-trip id normalizer have settled rules for what needs special handling, and a new construct should fit them rather than add an exception to either. +- An unsupported form on the wire is refused with a diagnostic naming what was refused, never dropped or approximated (ADR-0012). + +## Considered Options + +- `await`, which carries the meaning all four cases share, that execution stops here until this specific thing resolves, and is the word this audience already reads that way in mainstream asynchronous code. +- `wait`, which reads as a generic pause, closer to a fixed delay than to a subscription that resolves when something happens. +- `receive`, which names precisely what a message catch does and mislabels the other three: a signal is broadcast rather than addressed to anyone, a timer is not received from anywhere, and a condition is evaluated rather than delivered. +- An optional `name=ID` slot ahead of the trigger word, mirroring how a hosted `on` handler is named. +- Message, timer, signal, and conditional as the trigger scope, exactly the shapes the shared `readCatchEventDefinition` helper maps, the same function an `on` handler's start event and a boundary event's catch side both read through. +- Every BPMN catchable trigger, adding error, escalation, compensation, and cancel, which would need no exclusion list and no refusal diagnostics. + +## Decision Outcome + +Chosen options: `await`, a name that follows the trigger word rather than preceding it, and a scope of message, timer, signal, and conditional. +Link joins that scope for the import direction (ADR-0030). + +Before committing to the keyword, the rule was built into a live Langium parser with Chevrotain's self-analysis switched on (`createServicesForGrammar`) and the full trigger-payload battery was run through it. +Every form parsed with zero ambiguity warnings, including the case that matters: `await timer("PT1H")`, a bare identifier followed by a parenthesized payload, resolved to `trigger='timer'` with the duration as its payload. +`wait` and `receive` parse exactly as cleanly, so the choice between the three is not a parsing question. +Reserving `await` collides with no trigger word or timer particle in the grammar, and it appears as a bare identifier nowhere in the committed corpus, unlike `message` or `code`, which stay soft precisely because they are ordinary variable names. + +A trial grammar carrying the name ahead of the trigger was run through the same live-parser check and reported "Ambiguous Alternatives Detected inside IntermediateCatchEvent Rule": with a name and a trigger both bare identifiers, nothing decides which of the two the first word after `await` is. +That is not a cosmetic misparse. +A bare payload, an `at` key, and an `every` key map to a duration, a fixed date, and a repeating cycle, three different things the engine schedules, so a timer read as a name loses which one the author wrote. +The name therefore follows the trigger word, in the position `emit` already spells one. + +The four triggers are the only kinds this language treats as something a token can genuinely wait on. +Error and escalation are always thrown by `throw` or `emit` and always reacted to by a racing `on` handler, so letting `await` claim them would leave two constructs both plausibly catching the same trigger with no rule for which one fires. +Compensation has one meaning here, the undo block a subprocess declares with `on compensation` (ADR-0028), invoked by the engine's own compensation machinery and never something with an independent arrival. +BPMN gives cancel no position an `await` could occupy: a transaction sub-process's boundary catches one and an end event inside that sub-process raises it, and ADR-0029 surfaces that container as the `attempt` block with both of those positions. + +On import, an excluded definition, or more than one definition on the same element, is refused by name. +The XML schema does not stop a document from placing one of those on an intermediate catch element, so the refusal is a real check against input a foreign tool or a hand-edited file can produce. +The catch is the topological twin of `IntermediateThrowEvent`, `emit`'s compiled form, differing only in whether the token fires forward immediately or stops and waits, so the restructuring analysis reads it as an ordinary node with a normal immediate dominator and the round-trip id normalizer re-keys nothing. + +### Consequences + +- Good, because the full payload battery parses with zero ambiguity warnings, so `await` reserves one word and touches no existing rule's disambiguation. +- Good, because `await` names exactly the behavior all four triggers share rather than a word accurate for one of them and approximate for the rest. +- Good, because the catch is a topological twin of the intermediate throw, so forward emit, decompile, the restructuring analysis, and the round-trip normalizer each reuse existing machinery through one dispatch case. +- Good, because the trigger scope matches `readCatchEventDefinition` exactly, so import gains an honest refusal for the forms it cannot represent instead of a blanket refusal or a silent drop. +- Bad, because error, escalation, compensation, and cancel stay permanently unreachable from `await`, even on a hand-crafted document where the shape looks unremarkable to a reader who does not know BPMN restricts a plain intermediate catch element to a smaller set than a boundary event or an event sub-process's start accepts. + +### Confirmation + +`packages/language/test/` pins each trigger's parse, the timer case pinning that which of its three forms was written survives, that `var await` fails to parse while `var message: string` and a step named `every` still parse clean, and each rejected trigger word by its whole message. +`packages/transform/test/` pins the lowering, the emitted `*EventDefinition` shapes, the mapped shapes and the refusals, the decompiled render lines, the synthesized id, and the control-flow no-op as an explicit regression case: a catch on the main flow gets a normal immediate dominator, never the virtual entry. +`tests/golden/intermediate-catch.bpmn` and its round-trip suite exercise all four triggers across a full DSL -> XML -> IR -> DSL cycle, and a Docker-gated end-to-end test correlates a message mid-flow to show the instance genuinely waiting before correlation and completing after, the one property no other layer can show. diff --git a/docs/decisions/0026-task-kinds-on-the-authoring-surface.md b/docs/decisions/0026-task-kinds-on-the-authoring-surface.md deleted file mode 100644 index d313398..0000000 --- a/docs/decisions/0026-task-kinds-on-the-authoring-surface.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -status: accepted -date: 2026-08-30 -decision-makers: Marlon Kranz ---- - -# Task Kinds on the Authoring Surface: `step`, `send`, `receive`, and `decide` - -## Context and Problem Statement - -Four BPMN activity tags have no statement in this grammar: `bpmn:task`, `bpmn:sendTask`, `bpmn:receiveTask`, and `bpmn:businessRuleTask`. -A document carrying one imports as something the printer cannot write back, so the round trip stops there. -Each of the four needs a word, and every statement keyword is a name an author can never use again. -Which four words, where do their payloads sit, and how many IR kinds do four tags need? - -## Decision Drivers - -- ADR-0013 binds a keyword to what the author means rather than to the BPMN element behind it. -- A statement keyword cannot be soft: `Statement` being keyword-led is what lets `start S` tell its optional trigger from the next statement. -- A Langium keyword is lexer-global, so a word spent on a statement is a variable and step name lost everywhere in a file. -- ADR-0014 refuses what changes the run and warns only about drops that do not. -- ADR-0006 models the IR on what the engine does, so a distinction the engine does not make should not become an IR kind. - -## Considered Options - -- `step`, `send`, `receive`, and `decide` as the four words -- `task`, `notify`, `expect`, and `rule` -- The message name and the decision key in positional slots after the id -- The message name and the decision key as settings -- One IR node for the three service-task-like tags -- One IR kind per tag -- A receive task with no message name in scope -- A receive task with no message name refused on import - -## Decision Outcome - -Chosen options: `step`, `send`, `receive`, and `decide`, every payload in the settings, one IR node for the three service-task-like tags, and the nameless receive task in scope. - -Sharing a word with the BPMN tag is not what ADR-0013 forbids. -`user`, `service`, and `script` already do it, and each is legal because the word means something to a reader who has never seen BPMN. -`send`, `receive`, and `decide` are the verbs an author means, and ADR-0013 names a decision as a thing a keyword should name. -`task` was rejected for the generic kind because it is the one candidate carrying no meaning beyond the tag name. - -All four are hard keywords, so `step` stops being a variable name, a bare expression identifier, an attribute key, a listener event, a form-field id, and a map key. -No existing file uses any of the four, and the four rules built into a live Langium parser with Chevrotain's self-analysis on drew no ambiguity report. - -`decide` sits close to `if`, which is a decision in the same vocabulary. -It is accepted because the two never compete for a position: `if` takes a condition and opens branches, and `decide` is a leaf activity naming a decision table. - -Every payload sits in the settings rather than in a positional slot, as in `receive AwaitSettlement(label: "Wait for the payment", message: "PaymentSettled")`. -A label and a message name are both quoted strings, so an unkeyed one has no reading that position can settle. -The settings also bring reuse, since `call` already names its target there, and `binding` and `version` already carry the rule a decision step needs. - -One IR node covers `bpmn:serviceTask`, `bpmn:sendTask`, and `bpmn:businessRuleTask`. -Operaton's `BpmnParse` runs all three through `parseServiceTaskLike` when the tag carries a `class`, `expression`, `delegate`, or `topic` binding, so they share one node and an optional discriminator picks the tag. -A business rule task naming an `operaton:decisionRef` goes to Operaton's `parseDmnBusinessRuleTask` instead, and the `decision` binding that form carries is the one variant `service` and `send` refuse. -A send task therefore has no message semantics of its own: the engine gives it none, and `send X(class: "...")` is what makes it send anything. - -A receive task with no message name is in scope in both directions, with no refusal and no warning, as in `receive AwaitPickingSlot(label: "Wait for a picking slot")`. -Operaton's `parseReceiveTask` subscribes a receive task to a message only when `messageRef` is present, so a nameless one is a wait state the engine's signal API continues. -A named one derives its `bpmn:Message` root through the collector ADR-0016 already uses, so it shares that root with an `await message` of the same name. - -A decision step pins its table with `binding: latest`, `binding: deployment`, or `version: 3`, the call activity's surface reused down to the validator rule and the IR type. - -### Consequences - -- Good, because a message throw or end may now carry the implementation that makes the engine really send it, so this surface no longer treats every thrown message as a pass-through. - ADR-0024 refused such a document on import, and that refusal was aimed at the wrong element. - Operaton's `BpmnParse` calls `isServiceTaskLike` on the `bpmn:messageEventDefinition` in both throw positions and never inspects the `endEvent` or `intermediateThrowEvent`. - The same attributes written on the event are therefore inert, so the refusal rejected content the engine ignores. - The carry now lives on the definition, and an implementation on the event warns. -- Bad, because `operaton:resultVariable` on a thrown message's definition warns away while the binding beside it imports, and Operaton fills it from an `expression` binding's return value. - Operaton rejects the deployment outright when a `class` or `delegateExpression` binding carries one, so the expression form is the only one that loses anything. - It is a warned execution change where ADR-0014 asks for a refusal, recorded here as a deliberate boundary rather than left unstated. -- Bad, because `step` now carries two senses at once: the language's word for the activity that does nothing, and the ordinary noun for any activity in a process. - -### Confirmation - -`packages/language/test/` and `packages/transform/test/` pin the four statements token by token, every binding rule, the import of each tag, the emitted element with its derived root, and the printed line. -The frozen pair `tests/golden/task-kinds.{bpmnscript,bpmn}` holds all four kinds, both receive forms, both decision bindings, and a message end carrying an implementation. -`tests/task-kinds.round-trip.test.ts` compares the compiled XML byte for byte and requires an import with no warning at all. -`tests/e2e/task-kinds.test.ts` deploys that process to a real Operaton, walks the token past the step, the send, and the decision, and parks it at the receive task until the message is correlated. - -## Pros and Cons of the Options - -### `step`, `send`, `receive`, and `decide` - -- Good, because each word says what the author is doing, which is the test ADR-0013 sets, and three of the four are the verb itself. -- Bad, because four ordinary English words stop being available as identifiers anywhere in a file. - -### `task`, `notify`, `expect`, and `rule` - -- Bad, because `task` names the BPMN tag and nothing else, `notify` narrows a send to a notification, and `expect` and `rule` read worse than the verb beside `user` and `service`. - -### One IR node for the three service-task-like tags - -- Good, because the discriminator is optional, so every existing service-task literal in the sources and the test suites stays valid untouched, and the binding switch is written once. - -### One IR kind per tag - -- Bad, because it splits a shape Operaton itself does not split, and copies that binding switch through the writer, the printer, and the reader. - -### A receive task with no message name refused - -- Bad, because it costs an error path, a message, and a test to reject content the engine executes, which ADR-0014 reserves for shapes that change the run. -- Bad, because downgrading it to a validator warning is no cheaper: the goldens require a decompiled document to re-validate with no diagnostic at all. - -## More Information - -Supersedes the paragraph in ADR-0024 recording a thrown message's send implementation as refused on import, since this surface now carries it. -Related decisions: ADR-0013 (the rule that a keyword names what the author means, and the reason `task` is not one). -ADR-0014 (the honest import contract behind every refusal and every warning here). -ADR-0016 (the derived `bpmn:Message` roots a named receive task joins). -ADR-0021 (the service-task binding set the send task, the decision step, and the message throw all reuse). -ADR-0024 (the message end and message throw this decision gives an implementation). diff --git a/docs/decisions/0027-boundary-events-attached-to-an-activity.md b/docs/decisions/0027-boundary-events-attached-to-an-activity.md new file mode 100644 index 0000000..53847ea --- /dev/null +++ b/docs/decisions/0027-boundary-events-attached-to-an-activity.md @@ -0,0 +1,86 @@ +--- +status: accepted +date: 2026-07-23 +decision-makers: Marlon Kranz +--- + +# Boundary events attached to an activity + +## Context and Problem Statement + +Every `on` handler compiles to an event sub-process that guards its whole enclosing process or subprocess body (ADR-0023). +BPMN has a second, narrower attachment for the same catch vocabulary: a `bpmn:boundaryEvent` docked directly on one activity, which only catches while that activity is running and, depending on `cancelActivity`, either cancels it the moment the trigger fires or lets it keep running alongside the catch. +Giving `on` an optional host turns it into this second attachment form without introducing a competing keyword or a second handler construct. +Four questions settle together: how a host is spelled without colliding with the handler shape `on timer("PT2H")` that already parses; which of the language's catchable triggers may attach; what a hosted body compiles to, given that the host-less form's meaning must not move; and how a token that appears directly at a boundary event, without ever traversing the main flow, is represented in the dominator-based restructuring analysis (ADR-0014). + +## Decision Drivers + +- Every trigger word is a soft identifier (ADR-0023), so a new syntax slot cannot recover disambiguation from the trigger word's token type, only from structure the grammar contributes at parse time. +- The host-less handler's meaning is exercised by existing golden fixtures: a hosted handler is a new attachment form layered next to it, not a replacement. +- Operaton's own parser (`BpmnParse.parseBoundaryEvents`) is the authority on which triggers, and which host shapes, a boundary event may legally carry; a surface stricter than the engine owes a reason. +- The restructuring analysis (ADR-0014) is total over every valid IR, and a boundary event is the IR's first flow-element shape with outgoing edges but no incoming ones. +- The honest import contract (ADR-0012) forbids silent mangling on import, and compensation stays a subprocess undo block (ADR-0028) rather than gaining a second attachment mechanism. + +## Considered Options + +- A colon separator, `on Pack: error(X)`, which resolves the reading at the second token, well inside the two-token lookahead Langium's default parser handles without any grammar annotation. +- A bare space, `on Pack error(X)`, which is ambiguous with a handler that parses today, since a host-less handler carrying a code and a hosted handler carrying none both read as `on` followed by two identifiers, and the ambiguity sits in the token text rather than the token type, so no lookahead over token types can tell the two apart. +- A postfix clause, `on error(X) at Pack`, which reads left-to-right as "catch this, at this activity" but reuses the word `at` that a timer's date key already claims, recreating the same collision in a different position. +- Promoting the trigger words to real keywords and re-admitting them as identifiers through a data-type escape hatch, which touches roughly two dozen grammar productions rather than one and still leaves a residual ambiguous alternative between the keyword-led host form and the timer form, resolved by the generator's own alternative ordering and reported only as a warning on the build's stderr. +- A trigger scope read from the engine's own boundary parsing, so every boundary event the surface admits is one the engine deploys. +- Every catchable trigger the language has, which needs no per-trigger row but would let the surface author a `bpmn:BoundaryEvent` carrying a `compensateEventDefinition`. +- A self-contained body, wrapping nothing and rejoining only by an explicit `goto`, which matches what a bare `{ }` block already means everywhere else: a scope ends where its last statement ends, unless something inside it names where control goes next. +- Falling through to whatever statement follows the host, which needs no `goto` for a "retry, then continue" shape on an interrupting boundary but duplicates a non-interrupting host's still-running token and runs the rest of the process twice. +- Wrapping the body in a container of its own, as a host-less handler already is, which BPMN's boundary-event semantics rule out: the escape path has to be a flow element of the host's own container, so a `goto` back to the main flow would cross a container boundary. +- Wiring a boundary event to the analysis's virtual entry like a start event, against leaving it unwired, which makes every escape chain permanently un-restructurable regardless of how simple its own control flow is. +- A second analysis rooted at each boundary event, which would leave the main flow's dominance relationships untouched but is not supported by the restructurer's single-`Emitter`-per-container design, since two independent analyses over one node and edge set would have no way to agree on which of them owns a node reachable from both. +- A host-derived id, against reusing the positional `EventSubProcess_` scheme, which would drift the moment the decompiler reordered handlers to the end of a container's statement list. + +## Decision Outcome + +Chosen options: the colon separator, a scope read from the engine, a self-contained body, virtual-entry wiring, and host-derived ids. + +The scope is message, timer, signal, conditional, error, and escalation. +Escalation is restricted to a subprocess, an `attempt` block, a call, or a user task because Operaton gates that boundary on `attachedActivity.isSubProcessScope()`, and `parseTransaction` sets that flag exactly as `parseSubProcess` does, so an `attempt` block hosts an escalation on the same terms an ordinary block does. +Error reuses the same `alongside: false` restriction already enforced for a host-less error handler rather than adding a boundary-specific rule. +Compensation is excluded on the grounds ADR-0028 established for it generally: BPMN attaches compensation through a `bpmn:association` and an `isForCompensation` activity, a different attachment mechanism entirely, which also keeps the import-side `refuseIfForCompensation` guard honest inside the boundary event's own import path. +Cancel is excluded here because Operaton allows a cancel boundary on a transaction alone, and ADR-0029 surfaces that container as the `attempt` block and admits one there. + +Because a hosted handler's body is not its own container, the scope provider's flow-container walk, the same walk that decides what `goto` may resolve against, treats a hosted `on` as transparent: resolving a name from inside such a body walks straight past the handler to the real enclosing process or subprocess. +That is what makes `goto` legal in both directions across a hosted handler's body, since both a `goto` inside the body reaching the main flow and a main-flow `goto` reaching into the body resolve against the one container the lowering actually places their statements in. + +The restructuring analysis wires every boundary event to its container's virtual entry, unconditionally, the same way it already wires every start event. +This is the honest model: at runtime a token genuinely appears at a boundary event the moment its trigger fires while its host is running, without ever traversing a sequence flow into it. +Without this wiring, an escape chain has no immediate dominator at all, and the restructurer can recognize an `if`, a `while`, or a clean join only relative to a dominance relationship, so the chain could never print as anything but unstructured `goto`s. +The accepted trade-off is that a node reachable from both the main flow and an escape chain loses a tight immediate dominator, since neither the main flow's split nor the escape chain alone reaches it unconditionally, so an `if`/`else` whose join such a chain jumps into degrades to plain `goto`s on decompile. +The dominance result is correct: a region two independent entry points can both reach is genuinely not dominated by either one's split. + +A boundary event's id is `Boundary__`, collision-resolved against the same document-wide `taken` set the implicit start and end ids already share. +The one case this scheme does not fully disambiguate is two boundary handlers sharing a host and a trigger but differing in code: `on Pack: error(A)` and `on Pack: error(B)` are legal, non-duplicate handlers under the validator's `(host, trigger, code)` key, yet both base to the identical `Boundary_Pack_error`, so which one receives the `_2` suffix is positional. +That is stable in the generation direction, since lowering assigns suffixes in statement order and the decompiler reprints handlers in that same order, but it is exposed on import, where another tool may present the two in whatever order it chose. +Folding the code into the base id was rejected: no other id constructor sanitizes arbitrary author-supplied text into an id fragment, and a numeric suffix would still be needed for two `timer` boundaries on one host, which carry no engine subscription key and so are never rejected as duplicates. +The round-trip normalizer compensates on the comparison side instead, keying a boundary event's signature on the event definition's own payload rather than on the printed id, so two IR snapshots compare equal regardless of which physical id each assigns to which occurrence. + +### Consequences + +- Good, because the colon resolves the host/trigger ambiguity with no reserved word, no residual parser-generator warning, and no change to ADR-0023's soft-trigger-word design. +- Good, because the trigger scope admits only what Operaton's own parser accepts, so a boundary event the validator passes carries a trigger the engine deploys. + The converse does not hold: a compensation boundary is refused on attachment-mechanism grounds rather than on an engine refusal, and Operaton would deploy the document that refusal rejects. +- Good, because excluding compensation costs nothing new to build: the existing `on compensation` undo block already covers the granularity a boundary compensation event would have reached, and the import-side refusal needed only a new call site for the existing guard. +- Good, because the self-contained body is uniform across interrupting and non-interrupting boundaries, so there is one rule to learn instead of one rule per cancellation mode. +- Good, because wiring a boundary event to the virtual entry makes an escape chain's own control flow, including a nested `if`/`else`, restructurable on the same terms as the main flow. +- Good, because a host-derived id survives every reordering the decompiler's trailing-position rule performs, without adding a re-key rule for the generation direction. +- Bad, because a node reachable from both the main flow and an escape chain loses a tight immediate dominator, degrading a main-flow `if`/`else` whose join such a chain jumps into into `goto`s on decompile. +- Bad, because a host-less `on` handler written inside a hosted handler's body lowers into the outer container and therefore guards that whole container rather than just the escape path it is written inside, which follows necessarily from transparency and which an author cannot see from the source alone. +- Bad, because two boundary handlers sharing a host and a trigger but differing only in code cannot be told apart by id text before the positional collision suffix is applied, which is stable across a recompile but exposed on an import written by another tool. + +### Confirmation + +`packages/language/test/` pins the colon's disambiguation, the transparency rule in both directions, and the trigger scope, the escalation host restriction, and the compensation-has-no-host refusal, each with an exact-message assertion. +`packages/transform/test/` pins the promotion consequence of transparency, the virtual-entry wiring and the accepted dominance trade-off as an explicit regression case, the self-contained body including a nested `if`/`else` inside an escape chain restructuring cleanly, and the host-derived id template with its collision suffixing. +The frozen `tests/golden/boundary-events.bpmn` fixture and its round-trip suite exercise these decisions together across a real DSL -> XML -> IR -> DSL -> IR cycle, including the import-first direction that is this scheme's one open disambiguation case. + +## More Information + +Related decisions: ADR-0014 (dominator and post-dominator restructuring, the analysis this decision's entry wiring extends). +ADR-0029 (the `attempt` block, which adds `cancel` to this attachment axis on the one host kind that can catch it). diff --git a/docs/decisions/0027-repetition-on-the-authoring-surface.md b/docs/decisions/0027-repetition-on-the-authoring-surface.md deleted file mode 100644 index 80d2794..0000000 --- a/docs/decisions/0027-repetition-on-the-authoring-surface.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -status: accepted -date: 2026-08-31 -decision-makers: Marlon Kranz ---- - -# Repetition on the Authoring Surface: the `for` Clause - -## Context and Problem Statement - -Any BPMN activity may carry a `bpmn:multiInstanceLoopCharacteristics` child, which makes the engine run it once per element of a collection or a fixed number of times. -This grammar had no way to say so, so a document carrying one refused on import and the round trip stopped there. -Where does the repetition sit on a statement, what does it spell, and which shapes does the surface decline to carry? - -## Decision Drivers - -- ADR-0013 binds a keyword to what the author means rather than to the BPMN attribute behind it. -- ADR-0010 requires an imported activity to come back under the id the document gave it. -- ADR-0014 refuses what changes the run and warns only about drops that do not. -- A Langium keyword is lexer-global, so a word spent on a clause is a name lost everywhere in a file. -- Operaton decides what is legal here, and a surface stricter than the engine owes a reason. - -## Considered Options - -- A header clause between a statement's id and its settings -- Settings keys, `times:`, `each:`, `as:`, and `sequential: true` -- A wrapping statement, `for each x in c { ... }` -- One head for both a count and a collection -- Two heads, `for each ... in ...` for a collection and `repeat ` for a count -- The element variable required -- The element variable optional -- `bpmn:standardLoopCharacteristics` imported as a step that runs once -- `bpmn:standardLoopCharacteristics` refused - -## Decision Outcome - -Chosen options: the header clause, one head, the optional element variable, and importing a standard loop as a step that runs once. - -The clause reads `for each line in lines`, `for each in lines`, `for 3`, or `for 2 each line in lines`, with `sequentially` and `until ()` after it. -It sits between the id and the optional settings on the nine statements that emit an activity. -`for`, `each`, `sequentially`, and `until` are hard keywords, because in that position only a keyword can tell the parser a clause has started. -No file in the repo used any of the four in an identifier position, and the four built into a live parser drew no ambiguity report from Chevrotain's self-analysis. - -Settings keys were the cheap option. -They lose on ADR-0013, whose test `sequential: true` fails outright, being the XML attribute wearing a DSL hat. -A flat key/value list also cannot constrain what the clause constrains: `as: "line"` with no `each:` is writable as a setting, and it is one of the deployments Operaton refuses. - -The wrapping form loses on ADR-0010. -BPMN attaches a repetition to exactly one activity, so a wrapper holding two statements has to synthesize a sub-process to hang it on. -An imported repeated user task would then come back inside a container the document never had, under an id this tool invented, which is the identity ADR-0010 exists to protect. -A wrapper restricted to one statement is this clause with a pair of braces around it. - -One head carries both intents because Operaton allows both at once. -Its `MultiInstanceActivityBehavior.resolveNrOfInstances` reads the cardinality first and falls through to the collection, and `evaluateCollectionVariable` still binds the element per run whenever a collection is set. -Two heads would have to refuse that combination or grow a third spelling for it. - -Parallel is the unmarked form, because Operaton's `BpmnParse` defaults `isSequential` to false and this tool elides every attribute matching an engine default. -An element variable is optional with a collection: `for each in batches` is a collection that drives the count and binds nothing, which deploys and runs. -Refusing that for authoring convenience is the call ADR-0026 declined to make for a receive task with no message name. - -Operaton reads an `operaton:collection` value containing `{` as an expression and anything else as the literal name of a process variable, so the two readings are not interchangeable. -A collection spelled as a plain identifier therefore prints bare and everything else prints quoted, since a bare `order.lines` would name a variable that does not exist. -The whole construct is one optional field on the shape the seven activity nodes already share, so it reached every kind without adding an arm to any exhaustive switch in the transform. - -An `asyncBefore`, `asyncAfter`, `exclusive`, or `operaton:failedJobRetryTimeCycle` written on the `multiInstanceLoopCharacteristics` element imports as one of the four `run`-prefixed settings ADR-0041 gives the clause, rather than refusing. -An `operaton:jobPriority` there is reported as a drop instead, because Operaton reads a job priority only in `createActivityOnScope`, off the step, and the repetition's own scope is never built there, so the setting reaches no job. -An `operaton:outputParameter` on a repeated step refuses, because Operaton's `checkActivityOutputParameterSupported` rejects the deployment outright, and the validator stops an author writing one for the same reason. -A `bpmn:standardLoopCharacteristics` imports as a step that runs once, with a warning naming the drop, because Operaton's `parseMultiInstanceLoopCharacteristics` looks only for a `bpmn:multiInstanceLoopCharacteristics` child and returns null otherwise, so the document deploys and the step runs once regardless of what the import does. -A `bpmn:loopCardinality` body that is neither a run of digits nor an expression refuses, and that one is this tool's limit rather than the engine's. -Operaton's `resolveLoopCardinality` reads `+3` as three, while this printer writes a count only as a plain number or as an expression, so the body would come back spelled differently. -An `operaton:elementVariable` or a `bpmn:inputDataItem` name outside the grammar's `ID` terminal refuses on the same ground, since the clause writes the name each run sees as a bare identifier and has no second form for it: an element named `größe` would come back as a file this language cannot parse. -Operaton's own parse errors refuse here rather than importing into a process that cannot deploy: a repetition with neither a count nor a collection, an element variable with no collection, and an empty `bpmn:loopCardinality`. - -### Consequences - -- Good, because a document the engine runs more than once now imports, prints, and recompiles byte for byte, where before it stopped the import at the first repeated activity. -- Bad, because `asyncBefore: true` in a repeated step's settings no longer means what it meant before the clause existed. - Operaton's `parseAsynchronousContinuationForActivity` reads a host element's async attributes onto the repetition, so it is one job around the whole loop rather than one per run. - One job per run is a separate setting, `runAsyncBefore`, in the same parens (ADR-0041). -- Neutral, because `operaton:collection` and `bpmn:loopDataInputRef` are one field to Operaton's `parseMultiInstanceLoopCharacteristics`, read in that order, so they import into one field here and the `operaton:` spelling is what gets written back. - The BPMN slot holds the text of a variable name rather than a reference to an element in the document. - `bpmn:inputDataItem` and `operaton:elementVariable` pair the same way, and a document setting both spellings of either pair gets a warning naming the one that was dropped. -- Neutral, because a decompiled program declares `var : any` for every collection it iterates by name. -- Neutral, because `nrOfInstances`, `nrOfActiveInstances`, `nrOfCompletedInstances`, and `loopCounter` are seeded into the process's variable table wherever something repeats, so `until (nrOfCompletedInstances >= 2)` validates with no declaration. - Operaton's `MultiInstanceActivityBehavior` sets `loopCounter`, and its two concrete behaviors set the three counters. -- Bad, because four ordinary English words stop being available as identifiers anywhere in a file. - -### Confirmation - -`packages/language/test/` pins every form of the clause token by token, its placement against the id and the settings, the output-parameter rule, the seeded variables, and what the position completes to. -`packages/transform/test/` pins the lowering, each refusal by error class and by a substring of its message, each shadowing warning, the emitted child, and the printed line. -The frozen pair `tests/golden/repetition.{bpmnscript,bpmn}` carries every form of the clause across eight statements of one process, and `tests/repetition.round-trip.test.ts` compares the compiled XML byte for byte and requires an import with no warning at all. -`tests/e2e/repetition.test.ts` deploys to a real Operaton and drives it over REST, offering a parallel repetition's three runs at once and a sequential one's one at a time on the same activity. -A completion condition ends a repetition after two runs, a count drives a service task, a collection binds each element in turn, and a count of zero never runs the step inside. - -## Pros and Cons of the Options - -### A header clause between a statement's id and its settings - -- Good, because the repetition reads as something done to one statement, which is what the engine does with it, and every statement keeps the settings it already had. -- Bad, because it spends four keywords and touches nine grammar rules where the settings-key form would have touched none. - -### Settings keys - -- Bad, because `sequential: true` names the BPMN attribute rather than what the author means, which is the one thing ADR-0013 rules out. - -### A wrapping statement - -- Bad, because a wrapper over more than one statement needs a synthesized sub-process, so an imported activity comes back inside a container carrying an id the document never had. - -### Two heads - -- Bad, because a count and a collection are not exclusive in Operaton, so two heads would refuse a legal document or need a third spelling for the combination. - -### The element variable required - -- Bad, because a collection with no element variable deploys and runs, so the requirement costs an error path and a test to reject content the engine executes. - -### `bpmn:standardLoopCharacteristics` imported as a step that runs once - -- Good, because it accepts every document Operaton deploys and runs. - `parseMultiInstanceLoopCharacteristics` looks only for a `bpmn:multiInstanceLoopCharacteristics` child, so the engine treats a standard loop as absent and runs the step once, the same behavior this import gives it, on every host the loop can sit on, including an event handler. -- Bad, because the import drops the element the document wrote, keeping only a warning that names it, so a decompiled file carries no trace that the source declared a loop there. - -### `bpmn:standardLoopCharacteristics` refused - -- Bad, because Operaton deploys and runs a `bpmn:standardLoopCharacteristics` exactly as if it were absent, so refusing it rejects a document the engine executes the same way this import would import it. - -## More Information - -Amended by ADR-0041, which gives the clause four `run`-prefixed settings that write onto the `multiInstanceLoopCharacteristics` element itself and import an async, exclusive, or retry setting found there instead of refusing it. -Amended by ADR-0051, under which the print declares every variable it reads bare as `var : any`, so a collection iterated by name is one case of that rule rather than the only one. - -Related decisions: ADR-0010 (the synthesized ids a wrapping form would have had to invent). -ADR-0013 (the rule that a keyword names what the author means, and the reason `sequential: true` is not one). -ADR-0014 (the honest import contract behind every refusal and every warning here). -ADR-0026 (the same call on a receive task with no message name, reused here for a collection with no element variable). diff --git a/docs/decisions/0028-attempt-blocks-and-the-cancel-trigger.md b/docs/decisions/0028-attempt-blocks-and-the-cancel-trigger.md deleted file mode 100644 index df91ec2..0000000 --- a/docs/decisions/0028-attempt-blocks-and-the-cancel-trigger.md +++ /dev/null @@ -1,113 +0,0 @@ ---- -status: accepted -date: 2026-08-31 -decision-makers: Marlon Kranz ---- - -# Work That Can Be Given Up: the `attempt` Block and the `cancel` Trigger - -## Context and Problem Statement - -BPMN has a container for work that is abandoned as a unit: a `bpmn:transaction`, given up by an end event inside it carrying a `bpmn:cancelEventDefinition`, caught by a boundary event of the same kind. -Operaton rejects either cancel position outside such a container, so neither is expressible until the container is. -This grammar had no container to put them in, so any document carrying the element refused on import and the round trip stopped there. -What does the container head say, where may a cancel sit, and which shapes does the surface decline to carry? - -## Decision Drivers - -- ADR-0013 binds a keyword to what the author means rather than to the BPMN element behind it. -- ADR-0014 refuses what changes the run and warns only about drops that do not. -- A Langium statement keyword is lexer-global, so a word spent on a head is a name lost everywhere in a file. -- Operaton decides what deploys here, and a surface stricter than the engine owes a reason. - -## Considered Options - -- `attempt` as a second head on the existing sub-process rule -- `transaction` as the head, the BPMN element name -- A settings key on an ordinary block, `subprocess X(transaction: true)` -- A grammar rule of its own, with the second AST type that comes with it -- `cancel` carried on the `end` statement the way `terminate` is, against `throw cancel` -- A host-less `on cancel` handler -- A cancel end with no handler refused on import -- A non-interrupting cancel handler imported as an interrupting one - -## Decision Outcome - -Chosen options: `attempt` on the existing rule, `cancel` on the `end` statement, no host-less handler, the missing handler warned, and the non-interrupting handler refused. - -A block reads `attempt BookAndPay { ... }`, `end BookingAbandoned cancel` inside it gives that block up, and `on BookAndPay: cancel { ... }` catches it. - -`transaction` loses twice over. -It is the BPMN element name, the one thing ADR-0013 rules out. -It also promises what the engine does not deliver: Operaton's `BpmnParse.parseTransaction` installs the same `SubProcessActivityBehavior` that `parseSubProcess` installs for an ordinary block. -Nothing is atomic and no transaction protocol runs: the tag buys the engine's acceptance of the two cancel positions, and nothing rolls back except what the author's own undo blocks reverse. -`attempt` says what the author means, which is to run this block of work and, if it is given up, undo what it finished. - -A settings key fails the same test `sequential: true` failed in ADR-0027, since a boolean named after the element is that element wearing a DSL hat. -A rule of its own loses on cost: the AST node stays `SubProcess`, so the scope provider, the linker, and every rule keyed on a sub-process reach the new head untouched. -`attempt` is a hard keyword, so the name is lost everywhere in a file, while `cancel` stays a soft word that still lexes as an ordinary identifier. - -`cancel` is one word in both positions, which keeps the raise and the catch out of two vocabularies for one event. -It rides the `end` statement rather than `throw`, because it carries no code, always ends its path, and BPMN raises a cancel from an end event only. -`throw cancel` and `emit cancel` draw a diagnostic naming the right spelling instead. -A host-less handler lowers to an event sub-process, and Operaton's `parseScopeStartEvent` fails the deployment of one whose start event carries a cancel definition, so the surface rejects it too. - -Four shapes refuse on import, each because Operaton itself rejects the deployment: a cancel end outside such a block, a cancel handler on any other host, a second handler on one block, and the cancel definition on the host-less handler's start event named above. -`parseEndEvents` accepts a cancel end only where the container holding it is a transaction, so a branch inside the block is still inside it and a nested block is not. -The two boundary shapes are consecutive checks in `parseBoundaryCancelEventDefinition`. -A `cancelActivity="false"` handler refuses although the parser accepts it: `parseBoundaryEvents` gives it `ActivityStartBehavior.CONCURRENT_IN_FLOW_SCOPE`, and importing it as interrupting would change the run. - -A cancel end whose block carries no handler warns instead, on both sides. -Parsing the handler is what wires the pair, since `parseBoundaryCancelEventDefinition` calls `setCancelBoundaryEvent` on every `CancelEndEventActivityBehavior` among the block's direct children. -With no handler that reference stays null, and `CancelEndEventActivityBehavior.execute` opens on an `EnsureUtil.ensureNotNull` for it. -Taking the handler out of the booking example shows both halves: the document deploys, and the first run stops with "Could not find cancel boundary event for cancel end event Activity(BookingAbandoned): cancelBoundaryEvent is null". -Refusing would reject a document the engine accepts, which the import contract does not license, so both sides warn and name that runtime failure. -A handler on a block whose body holds no cancel end warns for the same reason, since only a cancel end hands the run to it, through `CancelEndEventActivityBehavior.doLeave`. - -### Consequences - -- Good, because a document the engine runs as work that can be given up now imports, prints, and recompiles byte for byte, where before it stopped the import at the container. -- Neutral, because giving up a block undoes the finished work of every child inside it that carries an undo block, and reaches nothing around it. - `CompensationUtil.collectCompensateEventSubscriptionsForScope` walks up from the cancel end and stops at the block itself, so no scope outside it is collected. - `createEventScopeExecution` registers the subscription when a child carrying an undo block completes, and `throwCompensationEvent` signals them newest first. -- Neutral, because the undo runs first and the escape path second, always in that order. - `CancelEndEventActivityBehavior.execute` throws the compensation synchronously, and only its `doLeave` hands the run to the handler. -- Good, because a block written with the new head and carrying no cancel anywhere is an ordinary block of work to the engine, which is what lets an imported transaction round-trip unchanged. -- Good, because such a block may carry its own `on compensation`, so an enclosing scope can undo it in turn. - Operaton's `CompensationUtil.hasCompensationEventSubprocess` asks only whether the handler is a sub-process scope triggered by an event, never what tag the block it undoes carries. -- Neutral, because `method` and `protocol` are reported on import and never written back, since `parseTransaction` reads no attribute of its own and the imported process runs exactly as the source document does. -- Bad, because one more ordinary English word stops being available as an identifier anywhere in a file. - -### Confirmation - -`packages/language/test/` and `packages/transform/test/` pin both heads, both cancel positions, the placement rules, the two pairing warnings, the lowering, and the printed line. -Every import refusal is pinned by its error class, and each one carrying wording of its own is compared against the whole message rather than a substring of it. -The exception is a cancel definition on a handler's start event, pinned by the event kind and the definition type the error records. -The frozen pair `tests/golden/transactions.{bpmnscript,bpmn}` nests the two heads inside each other each way round and hangs a cancel handler and an error handler on one block. -That block holds an ordinary block with an undo block of its own, so a run that gives it up has finished work to undo. -`tests/transactions.round-trip.test.ts` compares the compiled XML byte for byte and requires an import with no warning at all. -`tests/e2e/booking-attempt.test.ts` deploys to a real Operaton and drives it over REST, where the declined run leaves through the handler and the seat held earlier goes back on sale. -Only the run that gave the block up records it as canceled, and the run that paid carries the booking past the block untouched. - -## Pros and Cons of the Options - -### `attempt` as a second head on the existing sub-process rule - -- Good, because the word names the intent rather than the tag, which is the test ADR-0013 sets. -- Good, because it costs no AST type and no branch in anything that already asks whether a statement is a sub-process. - -### `transaction` as the head - -- Bad, because it is the BPMN element name and promises a rollback the engine never performs, so a reader learns the wrong thing twice. - -### A host-less `on cancel` handler - -- Bad, because it lowers to an event sub-process whose start event Operaton refuses to deploy, and BPMN has nothing that opens on a cancel. - -## More Information - -Related decisions: ADR-0013 (the rule that a keyword names what the author means, and the reason `transaction` is not one). -ADR-0014 (the honest import contract behind every refusal and every warning here). -ADR-0018 (the `on compensation` undo blocks that a given-up block runs, reached here with no `throw compensation` written anywhere in the source). -ADR-0019 (the boundary attachment this decision extends with one trigger on one host kind). -ADR-0020 (the intermediate catch that keeps cancel out of its four-trigger scope, whose two BPMN-legal positions this decision surfaces instead). diff --git a/docs/decisions/0028-compensation-as-subprocess-undo-block.md b/docs/decisions/0028-compensation-as-subprocess-undo-block.md new file mode 100644 index 0000000..dfc7c9e --- /dev/null +++ b/docs/decisions/0028-compensation-as-subprocess-undo-block.md @@ -0,0 +1,67 @@ +--- +status: accepted +date: 2026-07-18 +decision-makers: Marlon Kranz +--- + +# Compensation as a subprocess undo block + +## Context and Problem Statement + +BPMN gives a compensation handler two attachment points. +One is a boundary event on the activity being compensated, connected by an association to a separate activity marked `isForCompensation`, an activity that never runs in the normal flow and only executes when compensation reaches it. +The other is a compensation event sub-process, a `bpmn:subProcess` with `triggeredByEvent="true"` whose start event carries a `compensateEventDefinition`, nested directly inside the embedded sub-process it compensates. +The engine treats the two as alternatives for the same intent, reversing the completed work of an activity, but restricts the event-sub-process form to embedded sub-processes, not to a separate process instance a call activity spawns, and caps it at one per sub-process level. +The engine also does not support `waitForCompletion="false"` on a compensation intermediate throw event: the throw always blocks until the triggered compensation finishes, regardless of what the attribute says. +The question this decision settles is which of BPMN's two attachment shapes, or a third designed one, the DSL exposes for compensation, and how throwing compensation fits the `on`/`throw`/`emit` vocabulary the rest of the event layer already uses. + +## Decision Drivers + +- The engine restricts one of the two BPMN attachment forms, the compensation event sub-process, to embedded sub-processes and one per level; a surface that treats both forms as equally general everywhere would produce documents the engine does not support. +- The audience writes processes without BPMN vocabulary already established for `on`/`throw`/`emit`; a second attachment mechanism, boundary event, association, and a normally-dormant activity, for one event kind breaks the pattern every other trigger kind follows. +- `waitForCompletion="false"` and `activityRef`-targeted throws only matter in combination with boundary-attached handlers; neither has a use once boundary handlers are out of scope. +- The honest import contract (ADR-0012) already distinguishes constructs the DSL refuses from ones it silently drops; a construct this decision does not model needs to land on one side of that line, not the other. + +## Considered Options + +- Compensation handlers only as a subprocess's `on compensation` block, thrown with the existing `throw`/`emit` pair and no code, which reuses the subprocess construct and the event layer's own vocabulary and matches the engine's restriction to an embedded sub-process, at the cost of one wrapping `subprocess` per step where per-activity granularity is wanted. +- Boundary compensation events with an associated `isForCompensation` activity, which lets a single activity carry its own handler without a wrapper, at the cost of a whole second attachment axis and a kind of activity that is dormant except when triggered, with no equivalent anywhere else in the grammar. +- A throw targeted at a specific activity by name (`activityRef`), which only has a target worth naming once boundary-attached handlers exist to be the targets, and which would need a reference-resolution mechanism the event layer does not otherwise require. +- A dedicated `compensate` verb, kept separate from `emit`, which reads unambiguously as compensation but grows the continuing-throw vocabulary to two words for one behavior, and would have to be a reserved keyword, unlike every trigger name and binding field. + +## Decision Outcome + +Chosen option: "Compensation handlers exist only as a subprocess's `on compensation` block, thrown with the existing `throw`/`emit` pair and no code", because it keeps compensation inside the vocabulary the rest of the event layer already uses, and the rejected alternatives either duplicate a granularity the subprocess construct already provides or only pay for themselves once that duplication exists. + +An undo block lowers to a `bpmn:subProcess triggeredByEvent="true"` nested inside the `bpmn:subProcess` it undoes, whose start event carries a `compensateEventDefinition`. +The at-most-one rule and the container rule are the engine's own restriction rather than a choice of this surface, and a process has no completed enclosing scope to reverse, so `on compensation` at process level is rejected rather than approximated. +Compensation reaches an undo block by two routes: `throw compensation` or `emit compensation` in an enclosing scope, and a cancel end that gives up an enclosing `attempt` block, where Operaton's `CancelEndEventActivityBehavior` throws the compensation itself before handing the run to that block's cancel handler. + +Compensation joins `throw`/`emit` on the same terms every other trigger kind already established (ADR-0023). +It carries no code, because compensation has no name to correlate against, unlike error or escalation, so there is nothing for a code string to select. +`emit compensation` always waits for the triggered undo to finish before falling through, matching the engine, which does not honor `waitForCompletion="false"` in the first place; the surface has no attribute for a behavior the engine ignores. + +Boundary compensation events, `activityRef`-targeted throws, `waitForCompletion="false"`, and `isForCompensation` activities are all refused on import under the honest import contract (ADR-0012), each with a diagnostic naming the construct rather than a silent drop or an approximation. +When the boundary event and the `isForCompensation` activity are paired through a `bpmn:association`, the diagnostic also names the compensated activity, the boundary event, and the handler, and prints the `subprocess`/`on compensation` rewrite an author would write by hand. + +BPMN's boundary-event form exists chiefly to compensate a single activity rather than a whole embedded sub-process. +BPMNscript reaches the same granularity without a second mechanism: wrapping the one step that needs to be undoable in its own `subprocess` gives it its own `on compensation` block, scoped to exactly that step. + +### Consequences + +- Good, because a saga, several steps, each undoable, unwound in reverse when a later step fails, round-trips through the DSL and executes on the engine using only constructs the surface already has: `subprocess`, `on`, `throw`, `emit`. +- Good, because compensation reuses the same verb pair, the same catch-block reading, and the same container rule every other trigger kind already follows. +- Good, because an import carrying the boundary-event pattern, a targeted throw, or `waitForCompletion="false"` refuses outright, rather than reporting nothing and reproducing a different execution than the one the imported document specifies. +- Bad, because an author porting an existing BPMN model that uses boundary compensation events must restructure it, pulling the compensated activity and its handler into a `subprocess`, rather than have the importer translate the pattern automatically. +- Bad, because per-activity compensation on many independent steps means one small `subprocess` wrapper per step; the surface trades a second attachment mechanism for a per-step wrapping requirement instead. +- Neutral, because compensation's ordering, reverse of execution, invocation count, and the variable snapshot an undo block sees are entirely engine behavior, outside anything this surface models. + +### Confirmation + +Round-trip coverage in `packages/transform/test` pins an undo block lowering to a nested `triggeredByEvent` sub-process with exactly one `compensateEventDefinition`-carrying start event, `throw compensation` and `emit compensation` lowering to a compensation end event and a compensation intermediate throw without an `activityRef`, and each of the four refused shapes producing a distinct refusal rather than a silently altered import. +`packages/language/test/validating.test.ts` pins the container rule, including the rejection of a second undo block in one `subprocess` or one `attempt`. +Two suites under `tests/e2e/` deploy to a real Operaton and confirm that an undo block executes on a running engine rather than only lowering to the right shape: an `emit compensation` raised by a process-level error handler reaching the undo block of a subprocess that had already completed, and a completed subprocess's undo steps running once the `attempt` block around it is given up. + +## More Information + +Related decisions: ADR-0029 (work that can be given up, whose cancel end reaches these same undo blocks with no `throw compensation` written anywhere in the source). diff --git a/docs/decisions/0029-one-bracket-shape-for-every-element.md b/docs/decisions/0029-one-bracket-shape-for-every-element.md deleted file mode 100644 index 1a1b595..0000000 --- a/docs/decisions/0029-one-bracket-shape-for-every-element.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -status: accepted -date: 2026-09-06 ---- - -# One bracket shape for every element - -## Context and Problem Statement - -The surface spells a value three different ways depending on where the value sits. -An engine setting takes `=` inside an attribute block, as in `user Review { assignee = "demo" }`. -A form field type and a variable type take `:`, as in `amount: number` and `var score: number`. -A label and a trigger particle take bare position, as in `start Placed "Order placed"` and `on Review timer after "PT2H" alongside`. - -Nothing about an element decides which of the three applies. -An author has to remember the spelling per position rather than derive it from the shape, and the printer has to choose one per position rather than emit one form. - -The same brace carries two unrelated jobs on top of that. -`{ }` holds engine settings on a task and holds the flow inside a subprocess, and a reader cannot tell which without reading the contents. - -Which surface shape should every element share? - -## Decision Drivers - -- One rule an author can derive the next line from, rather than three rules indexed by position. -- A delimiter that says what kind of content it holds, so settings and flow are distinguishable before they are read. -- No new reserved words, since ADR-0002 puts the vocabulary in one place and ADR-0008 already fixed the grammar as structured rather than prose. -- A shape the printer can emit mechanically, because every construct round-trips through `ir-to-dsl`. -- Diff behaviour, since a process under version control is edited attribute by attribute. - -## Considered Options - -- Colon configuration, where `:` introduces every detail, type, value and label alike -- Brackets and blocks, where `( )` carries what an element is and `{ }` carries what runs inside it -- Keyword prose, where small words such as `is`, `means`, `assigned` and `catching` replace the punctuation -- Off-side, where indentation carries the structure and every detail is a `key: value` child -- Decorators, where each attribute is its own `@name value` line above a bare `keyword id` -- Binding form, where every element is written as an assignment, `Id = kind(name = value)` - -## Decision Outcome - -Chosen option: "Brackets and blocks", because it is the only option that makes the delimiter carry the distinction the three spellings were failing to make. - -The shape is `verb Id(attributes) { children }`. -`( )` holds the scalar settings that describe the element. -`{ }` holds everything with internal structure, which means the flow inside a container and the member lists on a step. -An element with no members has no brace, so `user Review(label: "Review the order", assignee: "demo")` is a complete statement, and an element with no settings has no parens. - -It adds no reserved words. -The `:` inside the parens is the separator the form block and the `var` declaration already use, so the change removes two of the three spellings rather than introducing a fourth. - -### What the shape alone does not decide - -The variant was drafted as a rendering of one process rather than as a grammar, and four of its lines do not parse unambiguously as written. -Each is settled here, because leaving any of them to the implementation would decide the language by accident. - -A handler keeps the colon after its host, as in `on Pack: error(OUT_OF_STOCK, code: c)`. -Dropping it makes a hostless handler carrying a code and a hosted handler carrying none both read as `on` followed by two identifiers. -No lookahead separates those, because the trigger words are soft identifiers and the host is a cross-reference the parser cannot resolve while it is parsing. -The colon is already in the grammar for this reason, so keeping it costs nothing and settles the same question for `throw` and `emit`. - -A trigger keeps its own slot, so an event-bearing element reads `verb Id trigger(payload, settings) { children }`. -The payload is positional, since `TriggerPayloadRule` gives every trigger at most one code, and a key would spell that code's role twice. -So a message names itself, `start Placed message("OrderReceived")`, and an error names its declaration, `throw error(OUT_OF_STOCK)`. -An error is raised with `throw` rather than on an end event, which ADR-0024 settled and this decision does not reopen: `throw` already lowers to a `bpmn:endEvent` carrying an `errorEventDefinition`, so an `end` spelling would be a second way to write one element. -A timer reads its bare payload as a duration, `timer("PT2H")`, and takes `at` or `every` as a key when it means a date or a cycle. -A listener's own timer clause stays positional, as in `on timeout after "PT8H"(class: "...")`, because a listener is a callback rather than an element and its parens already carry the binding that says what to run. - -Form blocks, listeners, io parameters and call mappings live in the braces. -The parens carry settings that describe the element itself, and those four are lists with their own members, so they belong to the same half of the shape as flow does. -This is why `{ }` is defined above as everything with internal structure rather than as what runs: a form does not run, and forcing it into a parenthesised value would nest a listener's own settings two levels deep. -A form field takes the same shape in turn, `id: type "label" = default (settings) { members }`, its constraints in the parens and an enum's values and its property lines in the braces (ADR-0037). -An external task's `error when ` mappings live in the braces too, one member line each (ADR-0038). - -A setting with no value is written as a bare word inside the parens, as in `timer("PT2H", alongside)`. -The parens therefore hold a payload, then keyed settings, then bare flags, told apart by whether a `:` follows. - -### Consequences - -- Good, because an author who has seen one element can write any other, and the delimiter alone says whether a construct nests. -- Good, because the printer emits one shape rather than choosing a spelling per position, which removes a class of difference between an authored file and a generated one. -- Good, because the brace disambiguation that a settings block and a body block needed disappears; a `{` now opens children and nothing else. -- Bad, because a bare timer payload means a duration, so `timer("P1D")` and `timer(every: "P1D")` differ by a word while their strings look identical. -- Bad, because an event binding written `code: c` is no longer distinguishable by shape from a setting, so the rule that a binding declares a variable while a setting reads one moves from the grammar into the validator. -- Bad, because a lone identifier in parens is a payload rather than a flag, which `condition(ready)` needs an ordering rule to resolve rather than a shape. -- Bad, because a flag is only bare to the reader: `alongside`, `sequentially` and `local` are keywords and never lex as identifiers, so the grammar names the legal flags rather than accepting any word in that position. -- Bad, because every example, fixture and document showing the surface is respelled once, and the four TextMate grammars cannot import the vocabulary, so each needs a test that fails when it drifts. - -### Confirmation - -Every construct prints through `ir-to-dsl` and re-parses through the compiler, so a shape the printer emits but the parser rejects fails the suite rather than reaching a file. -The golden fixtures under `tests/golden` and the examples under `examples/spring-boot/processes` compile in the suite, so the respelling is confirmed by the same gate that confirms the language. - -A handler and a listener are told apart today by which brace encloses them, and this decision puts both in the same one. -What separates them afterwards is the handler's own body block, which is mandatory where a listener has none once its settings move into the parens. -The word after `on` plays no part in it, so the parse stays unambiguous even if a trigger word and a listener event ever collide. -A collision would still leave `on ` meaning two things to a reader and to the validator, so the two vocabularies are held disjoint by a test rather than by the grammar. - -The TextMate keyword alternation must stay equal to the grammar's reserved words, since three of the four grammars are generated or copied and are gitignored, which makes a stale artifact invisible in review. - -## Pros and Cons of the Options - -### Colon configuration - -- Good, because there is one character to learn and nothing depends on position. -- Bad, because it is wordier than what it replaces, with every label becoming its own `label:` line. -- Bad, because the brace still carries two jobs, which leaves half the original problem in place. - -### Brackets and blocks - -- Good, because one shape covers every element without adding a keyword. -- Good, because the two delimiters split the two kinds of content, so nesting is visible before the content is read. -- Bad, because settings lists are longer and wrap. -- Bad, because `user Review(...)` reads like a function call rather than a step. - -### Keyword prose - -- Good, because it reads almost like English and forgives a reader seeing the language for the first time. -- Bad, because every added word, `is`, `means`, `assigned`, `catching`, has to be learned and spelled correctly, which trades punctuation the author can see for vocabulary the author must recall. -- Bad, because it adds reserved words to a grammar ADR-0008 kept structured on purpose. - -### Off-side - -- Good, because it uses the fewest separators of any option and the page stays quiet. -- Bad, because whitespace becomes meaningful, so a paste at the wrong indentation changes the model instead of failing to parse. -- Bad, because the printer has to indent exactly on the way back from BPMN, which makes every generated file a whitespace risk. - -### Decorators - -- Good, because the line is always `keyword id`, and one attribute per line keeps diffs clean. -- Bad, because it is read bottom-up, with the configuration before the element it belongs to. - -### Binding form - -- Good, because it sits closest to the data and maps onto a builder API. -- Bad, because it reads like configuration rather than a flow, and `if` and `on` stand out as foreign in it. - -## More Information - -The six options were drafted as full renderings of the same process, `order-handling`, so each was judged on a realistic file rather than a fragment. - -Amended by ADR-0037, which gives a form field the parens-and-braces shape this decision gives every element. - -Amended by ADR-0038, which adds an external task's error mappings to the members the braces hold. - -Related decisions: ADR-0008 (the structured grammar this shape fills in, which decided against prose but not the bracket unification). -ADR-0030 (error and escalation codes as declared names, which fills the one payload slot this shape left holding quoted text). -ADR-0013 (the target audience, the reason the function-call reading of `user Review(...)` is a cost worth naming rather than ignoring). -ADR-0017 (the trigger payload surfaces, whose `parens` discriminator changes meaning here, since every trigger now takes parens and the field says which payload they carry). -ADR-0023 (listeners on the attribute block, whose title describes a block this decision splits). diff --git a/docs/decisions/0029-work-that-can-be-given-up.md b/docs/decisions/0029-work-that-can-be-given-up.md new file mode 100644 index 0000000..c6b1328 --- /dev/null +++ b/docs/decisions/0029-work-that-can-be-given-up.md @@ -0,0 +1,73 @@ +--- +status: accepted +date: 2026-08-31 +decision-makers: Marlon Kranz +--- + +# Work that can be given up: `attempt` and `cancel` + +## Context and Problem Statement + +BPMN has a container for work abandoned as a unit: a `bpmn:transaction`, given up by an end event inside it carrying a `bpmn:cancelEventDefinition`, caught by a boundary event of the same kind. +Operaton rejects either cancel position outside such a container, so neither is expressible until the container is. +This grammar had no container to put them in, so any document carrying the element refused on import and the round trip stopped there. +The question this decision settles: what does the container head say, where may a cancel sit, and which shapes does the surface decline to carry? + +## Decision Drivers + +- ADR-0006 binds a keyword to what the author means rather than to the BPMN element behind it. +- ADR-0012 refuses what changes the run and warns only about drops that do not. +- A Langium statement keyword is lexer-global, so a word spent on a head is a name lost everywhere in a file. +- Operaton decides what deploys here, and a surface stricter than the engine owes a reason. + +## Considered Options + +- `attempt` as a second head on the existing sub-process rule, which names the intent rather than the tag and costs no AST type and no branch in anything that already asks whether a statement is a sub-process. +- `transaction` as the head, which is the BPMN element name and promises what the engine does not deliver: `BpmnParse.parseTransaction` installs the same `SubProcessActivityBehavior` that `parseSubProcess` installs for an ordinary block, so nothing is atomic, no transaction protocol runs, and nothing rolls back except what the author's own undo blocks reverse. +- A settings key on an ordinary block, `subprocess X(transaction: true)`, which fails the same test `sequential: true` failed for repetition (ADR-0022), since a boolean named after the element is that element wearing a DSL hat. +- A grammar rule of its own, which loses on cost: keeping the AST node `SubProcess` is what lets the scope provider, the linker, and every rule keyed on a sub-process reach the new head untouched. +- `cancel` carried on the `end` statement the way `terminate` is, rather than `throw cancel`, because it carries no code, always ends its path, and BPMN raises a cancel from an end event only. +- A host-less `on cancel` handler, which lowers to an event sub-process whose start event `parseScopeStartEvent` fails the deployment of, and which BPMN gives nothing to open on. +- A cancel end with no handler refused on import, rather than warned about, which would reject a document the engine deploys. +- A non-interrupting cancel handler imported as an interrupting one, rather than refused, which would change the run. + +## Decision Outcome + +Chosen options: `attempt` on the existing sub-process rule, `cancel` on the `end` statement, no host-less handler, the missing handler warned rather than refused, and the non-interrupting handler refused. + +A block reads `attempt BookAndPay { ... }`, `end BookingAbandoned cancel` inside it gives that block up, and `on BookAndPay: cancel { ... }` catches it. +`attempt` says what the author means: run this block of work and, if it is given up, undo what it finished. +It is a hard keyword, so the name is lost everywhere in a file, while `cancel` is one word in both of its positions and stays a soft word that still lexes as an ordinary identifier, keeping the raise and the catch out of two vocabularies for one event. + +Four shapes refuse on import, each because Operaton itself rejects the deployment: a cancel end outside an `attempt` block, a cancel handler on any other host, a second handler on one block, and the cancel definition on a host-less handler's start event. +`parseEndEvents` accepts a cancel end only where the container holding it is a transaction, so a branch inside the block is still inside it and a nested block is not, and the two boundary shapes are consecutive checks in `parseBoundaryCancelEventDefinition`. +A `cancelActivity="false"` handler refuses although the parser accepts it: `parseBoundaryEvents` gives it `ActivityStartBehavior.CONCURRENT_IN_FLOW_SCOPE`, and importing it as interrupting would change the run. + +A cancel end whose block carries no handler warns instead, on both sides. +Parsing the handler is what wires the pair: `parseBoundaryCancelEventDefinition` calls `setCancelBoundaryEvent` on every `CancelEndEventActivityBehavior` among the block's direct children. +With no handler that reference stays null, and `CancelEndEventActivityBehavior.execute` opens on an `EnsureUtil.ensureNotNull` for it, so the document deploys and the first run stops with "Could not find cancel boundary event for cancel end event Activity(BookingAbandoned): cancelBoundaryEvent is null". +Refusing would reject a document the engine accepts, which the import contract does not license, so both sides warn and name that runtime failure. +A handler on a block whose body holds no cancel end warns for the same reason, since only a cancel end hands the run to it, through `CancelEndEventActivityBehavior.doLeave`. + +### Consequences + +- Good, because a document the engine runs as work that can be given up now imports, prints, and recompiles byte for byte, where before it stopped the import at the container. +- Good, because a block written with the new head and carrying no cancel anywhere is an ordinary block of work to the engine, which is what lets an imported transaction round-trip unchanged. +- Good, because such a block may carry its own `on compensation`, so an enclosing scope can undo it in turn: Operaton's `CompensationUtil.hasCompensationEventSubprocess` asks only whether the handler is a sub-process scope triggered by an event, never what tag the block it undoes carries. +- Neutral, because giving up a block undoes the finished work of every child inside it that carries an undo block, and reaches nothing around it. + `CompensationUtil.collectCompensateEventSubscriptionsForScope` walks up from the cancel end and stops at the block itself, `createEventScopeExecution` registers the subscription when a child carrying an undo block completes, and `throwCompensationEvent` signals them newest first. +- Neutral, because the undo runs first and the escape path second, always in that order: `CancelEndEventActivityBehavior.execute` throws the compensation synchronously, and only its `doLeave` hands the run to the handler. +- Neutral, because `method` and `protocol` are reported on import and never written back, since `parseTransaction` reads no attribute of its own and the imported process runs exactly as the source document does. +- Bad, because one more ordinary English word, `attempt`, stops being available as an identifier anywhere in a file. + +### Confirmation + +`packages/language/test/` and `packages/transform/test/` pin both heads, both cancel positions, the placement rules, the two pairing warnings, the lowering, and the printed line. +Every import refusal is pinned by its error class, each one carrying wording of its own compared against the whole message rather than a substring of it; the exception is the cancel definition on a handler's start event, pinned by the event kind and the definition type the error records. +The frozen pair `tests/golden/transactions.{bpmnscript,bpmn}` nests the two heads inside each other each way round, hangs a cancel handler and an error handler on one block, and holds an ordinary block with an undo block of its own so a run that gives it up has finished work to undo; `tests/transactions.round-trip.test.ts` compares the compiled XML byte for byte and requires an import with no warning at all. +`tests/e2e/booking-attempt.test.ts` deploys to a real Operaton and drives it over REST: the declined run leaves through the handler and the seat held earlier goes back on sale, only the run that gave the block up records it as canceled, and the run that paid carries the booking past the block untouched. + +## More Information + +Related decisions: ADR-0012 (the honest import contract behind every refusal and every warning here). +ADR-0028 (the `on compensation` undo blocks a given-up block runs, reached here with no `throw compensation` written anywhere in the source). diff --git a/docs/decisions/0030-error-and-escalation-codes-as-declared-names.md b/docs/decisions/0030-error-and-escalation-codes-as-declared-names.md deleted file mode 100644 index 2cd5f95..0000000 --- a/docs/decisions/0030-error-and-escalation-codes-as-declared-names.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -status: accepted -date: 2026-09-07 -decision-makers: Marlon Kranz ---- - -# Error and escalation codes as declared, referenced names - -## Context and Problem Statement - -BPMN keeps a code in two places. -A `bpmn:Error` or `bpmn:Escalation` root element sits under `bpmn:Definitions` and carries the code, and every event definition that raises or catches that code references the root by id. -ADR-0016 settled that split by deriving the root from usage, so an author writes the code at the use site and the compiler builds the root behind it. - -That is enough for a code that is only thrown and caught. -It does not give the code an identity the editor can address. -A site that raises a code and a site that catches it are two string literals that happen to match, so there is nothing to jump to, nothing to rename, and a typo in one of them is a second code rather than a mistake. -ADR-0001 makes editor support the core value of a textual DSL, and a quoted code is the one payload shape none of that support reaches. - -What gives an error or escalation code an identity the language server can resolve, without reopening the paren shape ADR-0029 settled? - -## Decision Drivers - -- Jump-to-definition, find-references, rename and completion are the contribution a textual DSL makes over a graphical editor (ADR-0001). - A payload the language server cannot resolve is where that contribution stops. -- A mistyped code has to be a diagnostic at the site that wrote it, rather than a second code the export writes a root for. -- Every element is spelled `verb Id(settings)` after ADR-0029, and the error declaration is the last construct with a shape of its own. -- The round trip is byte-exact against frozen artifacts, so import has to reproduce the codes a document arrives with, including a code no identifier can spell and a root nothing raises. -- Whatever resolves a code has to leave every other identifier alone. - A bare word in `condition(ready)` or in `if (amount > 100)` is a variable, and the undeclared-variable warning is the diagnostic its author needs. - -## Considered Options - -For a code's identity: - -- A declaration in the process header that every use site references -- No declaration, with the validator checking each use site against the set of codes it collects from the document -- A quoted code at each use site, which is what ADR-0016 settled - -For where the reference is carried: - -- A cross-reference alternative of its own in the shared paren rule -- The reference on `VarRef`, the rule every identifier in an expression already builds, separated by scope - -For raising an error: - -- `throw error(...)` as the only spelling -- A second spelling on the end statement, `end Finished error(...)` - -## Decision Outcome - -Chosen: a declaration in the process header, referenced by a bare name at every use site, with the reference carried on `VarRef` and the two readings of a bare word separated by scope. - -An error or escalation code is declared once, beside the `var` declarations at the top of the process body, and named wherever it is raised or caught. - -```bpmnscript -process order-processing { - error PAYMENT_DECLINED(message: "The payment was declined") - error OrderFailed(code: "order.failed") - escalation MANUAL_REVIEW - - user Pay - throw error(PAYMENT_DECLINED) - - on Pay: escalation(MANUAL_REVIEW) { user Review } -} -``` - -The declaration is `verb Id(settings)` like every other element, so ADR-0029's shape covers the surface with no construct left over. -`message` is the text a thrown error carries at runtime, the one piece of root data usage cannot supply and the reason ADR-0016 gave the declaration a form at all. -A code no identifier can spell lives on the declaration as a `code` setting, so the use site is a bare name in every case and the awkward spelling is written once instead of at each of its uses. - -The name and the code are both carried into BPMN, `@name` holding the declared name and `errorCode` or `escalationCode` holding the code, and they are the same text wherever the code is identifier-shaped. -Import reads the pair back off each root: `readCodeDecls` synthesizes one declaration per `bpmn:Error` and `bpmn:Escalation`, taking the root's `name` where a declaration can be written with it and minting one from the code otherwise (`claimDeclarationName`, `packages/transform/src/synthesize-ids.ts`). -A root nothing raises therefore imports as a declaration nothing uses, so `unreferencedRoot` covers `bpmn:Message` and `bpmn:Signal`, plus an error or escalation root carrying no code at all, which nothing can key it by. - -The reference sits on `VarRef` rather than on an alternative of its own, and this was measured against the grammar rather than reasoned about. -The parser cannot separate `error(OUT_OF_STOCK)` from `condition(ready)`, since both are one identifier inside the same `SettingsParens`, and adding a cross-reference arm to `ParenItem` made Chevrotain report `AMBIGUOUS_ALTERNATIVES` and parse the condition as a reference to a declaration. -So every identifier in every expression builds the same node, and what separates a code from a variable is the scope it resolves in. -`BpmnScriptScopeProvider` gives a bare word the enclosing process's declarations of its own kind in a code position, and `EMPTY_SCOPE` everywhere else. -Holding the scope to one kind is what makes `escalation(X)` naming an error fail rather than reach the XML as a second root, since an error and an escalation are separate event definitions even under one code. -Outside a code position the link fails by design, and `BpmnScriptDocumentValidator` drops that failure before it becomes a diagnostic, so an ordinary expression sees the undeclared-variable warning and nothing else. - -Rejected: collecting the codes a document uses and checking each site against that set in the validator. -It reports the same typo, and it is the smaller implementation, but a diagnostic is all it produces. -The four editor features above read the reference graph, so a design without a reference gives up exactly what motivates the change. - -Rejected: `end Finished error(...)` as a second way to raise an error. -`throw` is the statement that raises one, which ADR-0024 settled, and it already lowers to a `bpmn:endEvent` carrying an `errorEventDefinition`. -An `end` spelling adds a reading without adding a meaning, and leaves one IR shape with two printed forms for the printer to choose between, which is the argument ADR-0024 rejected `end E message("Name")` on. - -### Consequences - -- Good, because a code has a definition to jump to, a name to rename across every site at once, and a completion list wherever one is legal. -- Good, because an unresolved code is an error naming the declaration to add, rather than a warning about an undeclared variable that reads as though the author meant a variable. -- Good, because a `bpmn:Error` or `bpmn:Escalation` root nothing raises survives the round trip as a declaration, which a design deriving roots from usage alone has nowhere to put. -- Good, because a code no identifier can spell has one home, so a document arriving with `order.failed` round-trips without that text appearing at every site that raises it. -- Bad, because every code needs a declaration line. - That is the ceremony ADR-0016 declined on purpose, and an author who writes a single `throw error(...)` in a five-step process pays a header line for one use. - The cost is accepted rather than argued away: it buys editor behaviour for a code with several sites, and it buys nothing at all for a code with one. -- Bad, because a name and a code can differ, so a reader of `throw error(OrderFailed)` cannot see the text the engine matches on without reading the header. -- Bad, because the grammar cannot carry the distinction the surface makes, so it is spread over a scope provider, a linker that rewords the failure, and a document validator that drops the failures the author never asked to resolve. - A reader looking for why `condition(ready)` is not a code has three files to find rather than a grammar rule. - -### Confirmation - -The golden pair `tests/golden/event-handlers.bpmnscript` and `tests/golden/event-handlers.bpmn` carries the two shapes this decision adds rather than respells: a declaration whose code no name can spell, `error GatewayTimeout(code: "gateway.timeout")`, and a declaration nothing raises or catches, `error STOCK_UNAVAILABLE(...)`. -Both gates see them, the byte comparison against the frozen `.bpmn` and the IR-equality comparison across `DSL -> IR -> XML -> IR -> DSL`, so a name that fails to reach the root or a root that fails to import as a declaration fails the suite (`tests/event-handlers.round-trip.test.ts`). - -`packages/language/test/scoping.test.ts` pins the scope in both directions: a bare word in a code position resolves to a declaration of its own kind and to no declaration of the other kind or of another process, while the same word in an `if` condition resolves to nothing and keeps its undeclared-variable warning. - -## Pros and Cons of the Options - -### A collected code set checked in the validator - -Every use site stays a bare word, and the validator gathers the codes the document declares and reports a site naming none of them. - -- Good, because it needs no scope provider, no linker wording and no filtering of link failures: one validator rule replaces three mechanisms. -- Good, because a bare word stays a bare word to every other part of the toolchain, so nothing else has to know a code position exists. -- Bad, because it produces a diagnostic and nothing else. - The editor features that motivate the change read the reference graph, and a validator rule builds none. - -### A cross-reference alternative in the shared paren rule - -`ParenItem` would gain an arm matching a reference directly, so a code would be a distinct node from an expression and the scope would not have to separate them. - -- Good, because the distinction would live in the grammar, where a reader looking for it would find it in one place. -- Bad, because it does not parse. - `error(OUT_OF_STOCK)` and `condition(ready)` are the same tokens in the same position, and the arm made Chevrotain report `AMBIGUOUS_ALTERNATIVES` and resolve the ambiguity by parsing the condition as a reference. - -### `end Finished error(...)` - -An error would also be raisable by naming it on an end statement, next to the `terminate` and `cancel` that an `end` already carries. - -- Good, because `end` carries a label and `throw` does not, so a labeled error end would keep its caption. -- Bad, because one IR shape would carry two printed forms and the printer would have to choose one, which is what ADR-0024 rejected `end E message("Name")` on. - -## More Information - -Amends ADR-0016, whose derived-root design this decision keeps and whose rejection of explicit declarations it reverses on an argument that decision did not weigh. -Amends ADR-0014, whose warned-construct list no longer covers a coded error or escalation root, and ADR-0017, whose contrast between message and signal roots and the error root's declaration now runs along a different line. - -Related decisions: ADR-0029 (the bracket shape the declaration takes, and the ordering rule that makes a lone identifier in parens a payload). -ADR-0024 (the verbs at the process boundary, and the reason an error is raised with `throw` alone). -ADR-0010 (the sanitize-then-suffix collision rule, which the name minted for an unspellable code follows the same way a synthesized id does). -ADR-0002 (Langium, whose scope provider, linker and document validator are the three seams this decision uses, none of which the grammar could replace). - -Amended by ADR-0038, which adds a third site naming a declared code beside a throw and a catch: an external task's `error when ` mapping, whose code resolves in the same scope and derives the same root. diff --git a/docs/decisions/0030-link-events-for-import-round-trip-symmetry.md b/docs/decisions/0030-link-events-for-import-round-trip-symmetry.md new file mode 100644 index 0000000..fe33c1f --- /dev/null +++ b/docs/decisions/0030-link-events-for-import-round-trip-symmetry.md @@ -0,0 +1,91 @@ +--- +status: accepted +date: 2026-09-12 +decision-makers: Marlon Kranz +--- + +# Link events for import round-trip symmetry + +## Context and Problem Statement + +BPMNscript's `await` trigger scope covers message, timer, signal, and conditional, and leaves link out: BPMN's off-page connector for splitting one diagram across pages, a problem a single `.bpmnscript` file never has, since `goto` already names a jump inside one textual source. +That choice holds for authoring, but it says nothing about import. +A modeller's existing document can carry a link pair, and the importer refuses `bpmn:linkEventDefinition` by name on both the intermediate throw and the intermediate catch, so such a document does not import at all. +Reading a link pair as a `goto` would not fix this under the honest import contract (ADR-0012), which lets the importer warn on a drop that changes nothing the engine runs but never lets it rewrite what is on the wire into a shape the surface prefers: a `goto` compiles to one sequence flow, so the re-exported XML would hold one edge where the incoming document held two events. +The question this decision settles is whether a construct authoring does not need should exist anyway, for better import coverage of existing models and round-trip symmetry. + +## Decision Drivers + +- Import coverage of existing models. + Modelers built on bpmn-js, the Camunda Modeler among them, offer both ends of a link pair, and a document drawn with one is refused today. +- Round-trip symmetry under ADR-0012. + A construct the importer maps has to re-export as the same XML, and a link pair mapped to a `goto` does not. +- Reuse of what is built. + ADR-0024's payload surfaces already carry a quoted name for message and signal, and ADR-0023's terminality rule already decides from the printed words whether a statement ends its chain; a fifth catch kind should cost one dispatch case per layer, not a second mechanism. +- The engine's own link semantics. + Operaton correlates the two ends by name in one table per parsed file, creates no activity for the throw, and rewires flows at deploy time; those facts decide most of the validator rules before this surface gets a say. +- The cost of a second way to spell a jump. + Two constructs with one meaning is a real price, and this decision has to say what is bought for it. + +## Considered Options + +- Keep refusing a link definition on import, which adds no grammar rule, no vocabulary row, and no second way to jump, but leaves every diagram using BPMN's own off-page connector unimportable, on a refusal the import contract does not ask for, since the engine deploys and runs a link pair. +- Import a link pair as a `goto`, and never author one, which changes the authoring surface not at all and shows the reader a jump in the form this language already has, but re-exports two events and their definitions as one edge, which round-trip symmetry rules out by construction, and leaves the printer deciding which `goto` edges were links from a fact the IR no longer holds. +- A full surface, `emit link` and a named `await link`, which maps one-to-one in both directions and reuses the payload surface, the required-name check, and the completion snippet message and signal already have, at the price of two constructs for one jump and a name slot on every `await` to make one kind of `await` nameable. + +## Decision Outcome + +Chosen: the full surface, `emit link ToRetry("Retry")` and `await link AtRetry("Retry")`, because it is the only option under which a document carrying a link pair imports and re-exports as itself. + +The verb is `emit` rather than `throw` because `throw` lowers to `bpmn:endEvent` and a link throw is a `bpmn:intermediateThrowEvent`. +`emit link` nonetheless ends its chain, as a `goto` does: `BpmnParse.parseIntermediateThrowEvent` records the throw in `eventLinkSources` and returns before creating an activity, so a flow leaving the throw finds no source and `BpmnParse.parseSequenceFlow` reports it as an invalid source, and the token continues at the catch and nowhere else. +This is the one `emit` whose chain ends; the two printed words `emit link` decide it, never position, so the round-trip argument for terminality read off the text (ADR-0023) still holds. + +The link name is quoted text, `link("Retry")`, like a message or signal name. +It keys the engine's own table and declares nothing, so it is not a declared code in the sense ADR-0023 gives error and escalation. +`await` gains the optional name slot `emit` has, legal on every trigger rather than on a link catch alone, because a link catch has to be nameable to keep its id across an import and a link-only slot would need a validator rule with no engine reason behind it. + +In the IR both ends carry `{ kind: 'link'; linkName: string }` and no flow runs between them. +The precedent is `BoundaryEvent.attachedToRef`, a node with no incoming flow wired to another by a reference rather than by an edge, not `SubProcess.element`, which is a tag on one node with no graph consequence. +The IR holds no reference field between the two ends because the engine matches them by name alone; an id reference would be a second fact that can drift from the first. +The restructuring analysis wires a link catch to its container's virtual entry the same way it wires a start event and a boundary event, since nothing flows into it: without that edge nothing in its chain has an immediate dominator, and a branch inside a rework chain prints as `goto`s instead of an `if`. +The element `name` attribute is stamped from the link name on both ends: `BpmnParse.parseIntermediateLinkEventCatchBehavior` warns at deploy when the two differ and recommends they match, and modelers render the element name as the label, so stamping it keeps the deployment clean and the diagram readable. + +The validator enforces the rules the engine decides, each in the tier the engine's behaviour justifies. +One `await link` per name in the whole file, at any depth, because `eventLinkTargets` is a field of `BpmnParse` that is never cleared between scopes; this is stricter than `goto`, whose targets are container-scoped, and it is the one place link is less expressive than what it duplicates. +Both ends in one container, because `BpmnParse.parseScope` parses a nested scope's activities and their flows before the parent registers its own catches, so a throw inside a subprocess never finds a catch in the parent, and `ScopeImpl.findActivityAtLevelOfSubprocess` refuses the other direction. +A throw with no catch of its name is an error, the analogue of an unresolved `goto`, since `parseSequenceFlow` refuses to deploy a flow into a link source whose name has no target; a catch with no throw is a warning, since the engine deploys it, a modeller's document can carry one, and printed output from an import has to re-validate without an error. +Engine settings and listeners on `emit link` are refused one per item, because the throw gets no activity, while the same items on `await link` are allowed, because `BpmnParse.parseIntermediateCatchEvent` runs `parseAsynchronousContinuationForActivity` and `parseExecutionListenersOnScope` for a link catch like any other. + +Nothing enters the catch is the one rule stricter than the engine: `parseSequenceFlow` gives a flow into the catch an ordinary transition and `IntermediateCatchLinkEventActivityBehavior.execute` leaves at once, so the shape deploys and runs as a pass-through. +This surface refuses it anyway, because a link target in a diagram never has an incoming flow, so the shape is not one a modeller's document produces, and the printer has to be able to place an imported catch after a statement whose flow has already ended, which a live fall-through into the catch would make ambiguous. + +What a link pair buys at runtime is nothing. +`IntermediateCatchLinkEventActivityBehavior.execute` is a single `leave`, and `HistoryParseListener.parseIntermediateCatchEvent` skips a link catch by type, so history never records it. +Against a plain sequence flow the pair adds one activity instance that never waits and an optional async point on the catch. +That is why the argument for this surface is notation fidelity and not execution: a link in a `.bpmnscript` file exists so the file can say what the diagram said. +`goto` stays the way an author jumps, and link is not recommended in hand-written source. + +### Consequences + +- Good, because a document carrying a link pair imports instead of being refused, and it re-exports as the same two events under the same name, which the honest import contract (ADR-0012) requires and a `goto` rewrite cannot give. +- Good, because link costs one dispatch case per layer: a fifth catch kind on the payload surfaces of ADR-0024, one `case 'link'` in each direction of the transform, and one row in the trigger vocabulary. +- Good, because every `await` is now nameable, so an author can `goto` a specific wait. +- Bad, because two constructs now spell one jump, and a reader who meets `emit link` has to be told it is a `goto` in the diagram's own notation. +- Bad, because `emit` is no longer always continuing; `emit link` is the one form that ends its chain, read off the trigger word. +- Bad, because link names are unique per file where a `goto` target is unique per container, so two subprocesses cannot each have a link called `Retry`. +- Bad, because a link catch nobody emits is dead code the validator can only warn about, since the engine deploys it and an imported document may carry one. +- Bad, because an async setting or a listener on `emit link` is refused rather than honoured, and nothing on the surface hints at why until the diagnostic says so. +- Bad, because the importer and the printer have to handle a graph with two disconnected components. + +### Confirmation + +`packages/transform/test/` pins the emitted pair in one object, the lowering to two disconnected nodes carrying one link name, and that nothing leaves the throw and nothing enters the catch at either hop. +`packages/language/test/` pins the name slot on `await`, a `goto` reaching a named await, the completion of `link` after `emit` and after a bare `await` but not in a race branch header, and the table "Validation - link events", one row per validator rule above, each asserting the complete diagnostic list. +The import half is confirmed by the frozen pair `tests/golden/link-events.{bpmnscript,bpmn}` with `tests/link-events.round-trip.test.ts`, which pins the five link events under their ids and link names at every hop and a printed source that recompiles clean. +The Docker-gated case in `tests/e2e/event-positions.test.ts` deploys `order-rework` compiled and again round-tripped and runs the same journey on both, the one place the claim that the pair deploys and runs can be shown. + +## More Information + +Related decisions: ADR-0012 (the honest import contract, under which a rewrite of what is on the wire is not an option). +ADR-0031 (the race, whose branch headers refuse `link` because the engine refuses a link catch after an event-based gateway). diff --git a/docs/decisions/0031-carry-bpmn-documentation.md b/docs/decisions/0031-carry-bpmn-documentation.md deleted file mode 100644 index 62704fa..0000000 --- a/docs/decisions/0031-carry-bpmn-documentation.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -status: accepted -date: 2026-09-10 -decision-makers: Marlon Kranz ---- - -# Carry bpmn:documentation wherever a name is carried - -## Context and Problem Statement - -`bpmn:documentation` sits on `bpmn:BaseElement`, so it can appear on almost any element a document declares. -Import reads the child and drops it, reporting only a warning; there is no authoring surface, so nothing written in this language can produce it on export. -It is the one piece of authored, human-facing content this tool refuses to carry, which keeps the letter of the honest-import contract (ADR-0014) while working against everything else that contract exists for. - -## Decision Drivers - -- The honest-import contract (ADR-0014): nothing is dropped in silence, so whatever this surface still cannot carry must be named rather than swallowed. -- ADR-0029's one bracket shape: a new authoring surface is a setting inside an existing element's parens or it is a new construct, and documentation does not earn a construct of its own. -- The printer's `Lines = string[]` model indents per entry, so a value spanning several lines needs either a printer change or an escape. -- BPMN lets an element carry several `bpmn:documentation` children, each with its own `textFormat`. - -## Considered Options - -For the shape: - -- One optional `documentation?: string` on the IR node -- A list of `{ text, textFormat }` members, one per `bpmn:documentation` child - -For the printing: - -- Escaping a newline so the value stays a single printed line -- Reworking the printer to give a setting a multi-line entry - -For the placement: - -- Every element the transform touches -- Every element that already carries a name - -For the quoting: - -- One quoting function serving every setting -- One quoting function per contract, splitting `quote()` in two - -## Decision Outcome - -Chosen: one optional `documentation?: string`, escaped onto a single printed line, carried wherever an IR node already carries a `name`, with the quoting function split in two. - -Nothing in the corpus writes two `bpmn:documentation` children on one element, so a repeated member on every carrying kind would buy nothing a single optional string does not already give it; a second child, or one carrying a non-plaintext `textFormat`, keeps today's warning under a narrower message instead. -Escaping the newline keeps the printer's `Lines = string[]` model untouched, and Langium's default `convertString` is the exact inverse of that escape, so import and print stay symmetric without a change to how a line is stored. -Tying documentation to the name means the positions where it is reported instead are already the positions a label is reported: an element with no slot for a name has no slot for documentation either, so there is one rule rather than two. - -One quoting function cannot serve a slot whose value must re-lex as an expression and a slot whose value must not. -A quoted body beginning with `${` is read as a raw expression, because `RAW_TEMPLATE` is declared before `STRING` in the grammar and matches first, and the reader that unwraps a raw expression strips its quotes without unescaping, so an escape inside such a body survives as two literal characters. -The settings that carry an expression, a delegate expression, a timer body, an io value, a loop collection, depend on that reading. -The settings that carry prose, a label, a version tag, a form field label, an error or escalation message, and now documentation, are corrupted by it instead. -A declared error or escalation code belongs on the prose side too, at the one site that quotes one, because ADR-0030 made a code a declared name and a declared name can never be an expression. -`quote()` splits on which contract a slot has: it keeps serving the expression-carrying group exactly as it does today, and `quoteLiteral()` takes the prose group, escaping what `quote()` escapes plus a newline plus a leading `$`. -Carrying documentation is what forces the split, not what creates the fault: the prose settings were already spelled ambiguously, and a value able to hold a newline is the first one that makes the ambiguity reachable. - -### Consequences - -- Good, because the `documentation` warning category survives with a narrowed meaning instead of disappearing, so a consumer classifying warnings by category needs no migration. -- Good, because documentation text is carried verbatim: a modeler's pretty-printed whitespace inside the element body comes back byte for byte instead of being trimmed in silence. -- Bad, because moving any other slot onto `quoteLiteral()` is a behaviour change rather than a refactor. - Several settings still on `quote()`, such as a topic or a decision reference, can legitimately hold an expression in Operaton, so reclassifying one changes what a recompiled document runs and needs a decision of its own. - -### Confirmation - -The frozen `documentation` golden pair and its round-trip suite confirm the fixture compiles byte for byte, imports with no warning, and re-parses to the same IR at every hop. -A parameterized sweep on the import hop and one on the print hop each name every position that carries documentation and every position that reports it instead, so a position wired to neither branch fails the sweep rather than passing in silence. - -## More Information - -Amends ADR-0014, whose warned-construct list read `bpmn:documentation` on any element; the bullet now names the narrower set this decision leaves warned. - -Amended by ADR-0044, under which the raw reader resolves the string escapes as a literal's reader does, so `quote()` escapes the same five characters as `quoteLiteral()` and the split between the two rests on the leading opener alone. - -Related decisions: ADR-0029 (the bracket shape that keeps documentation a setting rather than a construct). -ADR-0030 (the declared-name rule that puts an error or escalation code on the same quoting side as documentation). diff --git a/docs/decisions/0031-inclusive-and-event-based-gateways.md b/docs/decisions/0031-inclusive-and-event-based-gateways.md new file mode 100644 index 0000000..2549412 --- /dev/null +++ b/docs/decisions/0031-inclusive-and-event-based-gateways.md @@ -0,0 +1,114 @@ +--- +status: accepted +date: 2026-08-30 +decision-makers: Marlon Kranz +--- + +# Inclusive and event-based gateways + +## Context and Problem Statement + +Every BPMN element the language has taken on so far had a construct to hang off. +`bpmn:inclusiveGateway` and `bpmn:eventBasedGateway` have nothing to attach to, because under ADR-0011 no author ever writes a gateway. +The desugarer synthesizes every gateway from block structure, and the decompiler elides every one it matches. +That elision is what makes the round trip idempotent, and the grammar states the premise in its own header (`packages/language/src/bpmn-script.langium`). + +So the question is not which keyword spells "inclusive gateway". +It is whether block structure can express the intent well enough that the compiler still picks the element, and which of the flows out of such a fork are worth writing. +A gateway's `name` survives XML to IR to XML, then dies at the print hop, where no gateway has a statement form to carry it (`normalizeContainer`, `tests/helpers/normalize-ir.ts`). + +## Decision Drivers + +- ADR-0006 binds two rules: a keyword names what the user means rather than the BPMN element, and the compiler must not require what it can infer. +- Gateway elision is what ADR-0014's decompiler relies on for idempotence. + A gateway with textual identity gains an authored id and stops round-tripping under ADR-0010's positional scheme. +- Operaton does not police the two kinds equally: an inclusive gateway gets no structural validation, an event-based gateway a list of them. +- The compiler should write no flow the engine can never take, and a valid program must compile. +- The tool's own output should print back as source the validator accepts. +- Trigger words are soft identifiers by design (ADR-0023), so a rule proves nothing until a live parser accepts it. +- A label dropped on the way out needs a channel to be reported through, and ADR-0012's contract covers `xmlToIr` only. + +## Considered Options + +- For the inclusive split, conditioned `parallel` branches headed by `if` and `else`, which add no keyword, no statement, and no id template, at the cost of an element choice invisible in the block's keyword. +- For the inclusive split, branches headed by a bare parenthesized condition, which parses as cleanly, but a parenthesized expression opening a line is a shape this language has nowhere else. +- For the inclusive split, a separate construct with its own reserved keyword, which would make the element visible, but it spends a word and names the BPMN element rather than what the user means. +- For the fallback flow, reserve one only beside an `else` or when every branch is conditioned, which writes no flow the engine can never take, at the cost of a fork whose flows out differ by branch shape. +- For the fallback flow, reserve one on every inclusive fork and teach the printer to drop the `else` it prints for a flow that can never be taken, which keeps the lowering uniform, but writes a dead flow the printer then has to hide, and the `else` it prints for one is source the validator refuses. +- For the fallback flow, a written `else` required whenever every other branch is conditioned, which would make the fallback visible, but it is required syntax for something the compiler can infer. +- For the race, a multi-branch `await`, which reserves no word and generalizes a meaning the keyword already carries. +- For the race, a distinct keyword such as `race`, which could not be confused with anything, but it costs the language a name authors may want. +- For the race's join, a synthesized exclusive join the block falls through, which reuses `if`'s join and cannot duplicate a token, at the cost of a join that sees one. +- For the race's join, self-contained branches that rejoin by `goto`, which would match a hosted `on` handler, but would force a `goto` for the common timeout case. +- For a join that exactly one flow reaches, keep it written, which is what lets the printer structure a terminating branch and a guard clause; eliding it and retargeting the flow to the join's successor would satisfy bpmnlint and lose both. +- For the gateway label, no slot, with the print-side drop reported through a warnings channel out of `irToDsl`, which keeps every gateway elided and puts the label on the same channel as the print hop's other reports, at the cost of a signature change. +- For the gateway label, a slot on `if`, `while`, `parallel`, and the two new forms, which is the only way a label survives a full BPMN to DSL to BPMN round trip, but a labeled gateway is an authored gateway. +- For the gateway label, no slot and a silent drop, which costs nothing, and is the one thing ADR-0012's contract exists to prevent. +- A `gateway` keyword, or a flat node-and-edge escape hatch, which would express every gateway shape, but a gateway that prints re-parses with an authored id. + +## Decision Outcome + +Both gateways stay fully synthesized and fully elided. +Nothing in the surface names a gateway, and ADR-0010's positional ids keep working unchanged. +Every form below parsed with zero ambiguity warnings in a live Langium parser with Chevrotain self-analysis on. + +A branch of a `parallel` block may be headed by `if (condition)`, by `else`, or by nothing at all, and the compiler picks the element from what the branches carry rather than from the keyword. +Operaton takes every non-default flow that has no condition or a true one, and adds the default only when that set comes out empty (`InclusiveGatewayActivityBehavior.execute`), so `lowerParallel` reserves `Flow__default` only where the engine can take it: beside an `else`, or, when every branch carries a condition, running straight to the join, the rule `lowerIf` already applies at the same position, since such a fork with no default deploys, runs, then throws a stuck execution the first time no condition holds. +Beside a branch carrying no condition that branch is in the taken set every time, so the set never comes out empty, a fallback behind it could never run, and an `else` written there is refused for the same reason. +All of this is a lowering rule rather than a validator rule because `BpmnParse.parseInclusiveGateway` validates nothing. + +Every fall-through carries the reserved exit id, the back-edge included. +`lowerWhile` wires its back-edge with `body.exitFlowId`, as `lowerDoWhile` does, so a `while` whose body ends in a loop keeps that loop's declared default on the flow the enclosing head takes and `irToXml` accepts the document. + +A join that exactly one flow reaches stays written. +`ExclusiveGatewayActivityBehavior.doLeave` and the parallel and inclusive behaviours run it as a pass-through, and `BpmnParse.validateExclusiveGateway` accepts its single unconditioned outgoing flow. +The printer recovers a fork whose branch terminates, and a guard clause at the tail of a loop body, from that merge: without it, `parallel { { user A } { end X } } user C` degrades to jumps and `while (x) { if (c) { end R } }` prints as `while (x) { end R }`. +Eliding such a join waits until the printer structures those shapes without it. + +The race is a multi-branch `await`, because `await` already means the token stops here until this resolves and a race is that meaning over a set. +Reusing the word costs nothing, because `await {` cannot parse otherwise: the `IntermediateCatchEvent` shape takes a trigger word after the keyword. +Its triggers are the four `await` already takes, which is also everything Operaton accepts in this position (`BpmnParse.parseIntermediateCatchEvent`), and its branches fall through to a synthesized exclusive join, reusing `if`'s join and its pruning when every branch terminates (`pruneUnreachableJoin`). +Operaton gives every catch event behind an event-based gateway the start behavior `CANCEL_EVENT_SCOPE`, so exactly one branch of a race ever runs. + +An inclusive pair reuses `Gateway__fork` and `Gateway__join` unchanged, since exactly one `parallel` statement sits at any structural coordinate. +The race adds one template, `Gateway__race`, whose segment word joins the reserved-name pattern. + +Neither `if`, `while`, `parallel`, nor either new form gains a label slot, and the reason is structural. +One construct lowers to two gateways, so a single slot carries at most half of what an imported document may hold. +Instead `irToDsl` returns a result carrying source and warnings rather than a bare `string`, as ADR-0012 did to `xmlToIr`, since a channel a caller can skip is a silent drop. + +This extends the structured surface rather than breaching it. +Every gateway is still derived from block structure, none is written, and none is named, so the grammar header's claim holds word for word. + +Both kinds import rather than being refused (`mapContainerChildren`, `packages/transform/src/xml-to-ir.ts`). +An inclusive gateway reads `name`, `default` and its job settings (ADR-0021), and carries under the one refusal every gateway kind shares: an `operaton:inputOutput` on a gateway is refused, quoting `BpmnParse.checkActivityInputOutputSupported`, which fails the deployment. +Beyond that it has no refusal of its own. +An event-based gateway carries, refusing three shapes, each following a refusal in Operaton's own parser: an outgoing flow whose target is not a `bpmn:intermediateCatchEvent` (`BpmnParse.parseEventBasedGateway`), a downstream catch carrying a link definition (`BpmnParse.parseIntermediateCatchEvent`), which the rule that no sequence flow may enter a link catch already refuses for every flow and so for a branch of a race as well, and a downstream catch reached by more than one path (`BpmnParse.parseSequenceFlow`), counted over every path in. +The last is wider than the engine's rule: Operaton lets a path through where it leaves an event-based gateway, so it takes a catch reached only by such paths, and this refuses that too. +Neither that shape nor the one Operaton refuses is authorable, so the extra width costs no script anything. +`parseEventBasedGateway` rejects `operaton:asyncAfter` on the gateway itself as well, so the validator refuses it on a multi-branch `await` head under the same rule, and the import refuses a document carrying it rather than dropping it. + +### Consequences + +- Good, because both gateways stay synthesized and elided, so idempotence, the id scheme, and the control-flow analysis need no new rule and no new IR flag. +- Good, because neither construct spends a reserved word; reserving `race` would take `var race: string` and `user race` out of the language. +- Good, because a compiled inclusive fork beside an unconditioned branch prints back without a warning and re-parses without an error. +- Good, because a `while` whose body ends in a loop compiles. +- Bad, because a conditioned `parallel` reads as one construct but compiles to either of two BPMN elements, so a BPMN-literate reader must read every branch. +- Bad, because `irToDsl` changes its return shape, touching every call site, and the drop that motivated it costs the reader a gateway's name rather than anything the process does. +- Bad, because the label comes back on a BPMN-to-BPMN pass but never on a BPMN-to-DSL one, so a decompiled and recompiled document loses every gateway label. +- Bad, because a conditional branch of a race can win without waiting (`EventBasedGatewayActivityBehavior.execute`), which no part of the surface shows. +- Bad, because an inclusive fork closed on an exclusive merge, the common hand-drawn shape, degrades to `goto`s a reader repairs by hand. +- Bad, because a join that exactly one flow reaches stays in the document, where bpmnlint reports it as superfluous and the auto-layout draws the default edge into it across the terminating branch's end event. + +### Confirmation + +`packages/language/test/` pins a `parallel` block mixing an `if` branch, a plain branch and an `else` branch, `await { ... }` parsing as a race while `await timer("PT1H")` still parses as a single catch, and each validation rule on the two heads. +`packages/transform/test/` pins element selection, the four fallback shapes in one table, the nested `while` with the reserved id on its back-edge through `irToXml`, the race's join and its pruning, the inclusive carry, each refusal by name, elision of both new pairs, the dropped-label warning reaching the caller, and a modeled fallback running straight into the merge coming back as the model without that edge. +The frozen pairs `tests/golden/branch-and-race.{bpmnscript,bpmn}` and `tests/golden/gateway-settings.{bpmnscript,bpmn}` each hold an inclusive fork beside an unconditioned branch with no `default` attribute, asserting IR idempotence after normalization and that the decompiled source recompiles cleanly, and a Docker-gated end-to-end test shows the losing race branch canceled and the inclusive join waiting for exactly the branches taken. + +## More Information + +Operaton behavior was read from `BpmnParse.java`, `InclusiveGatewayActivityBehavior.java`, `ExclusiveGatewayActivityBehavior.java` and `EventBasedGatewayActivityBehavior.java` in the `operaton/operaton` repository. +The documented restrictions are from . +The dataset behind Compagnucci, Corradini, Fornari and Re (BISE 66(1), 2024, DOI 10.1007/s12599-023-00818-7) has an event-based gateway in roughly 12 percent of its 38,863 models and an inclusive gateway in roughly 6 percent. diff --git a/docs/decisions/0032-expressions-raw-templates-and-escapes.md b/docs/decisions/0032-expressions-raw-templates-and-escapes.md new file mode 100644 index 0000000..5ad191a --- /dev/null +++ b/docs/decisions/0032-expressions-raw-templates-and-escapes.md @@ -0,0 +1,53 @@ +--- +status: accepted +date: 2026-09-14 +decision-makers: Marlon Kranz +--- + +# Expressions: raw templates, escapes, and what is lowered as EL + +## Context and Problem Statement + +A quoted string whose body opens with `${` or `#{` is a raw template, the escape hatch for an expression outside the JUEL subset the grammar spells bare. +Its body reaches the engine as expression text, so the reader that unquotes the token and the printer that quotes it back have to be exact inverses, or a compile and a decompile change what the engine evaluates. +Imported documents carry both openers, and a quoted body may hold a quote, a backslash or a line break. +How is a raw template read, printed and nested, and which opener does it keep? + +## Decision Drivers + +- The printer's quoting and the reader's unquoting have to be exact inverses, or every round trip changes the text. +- Operaton reads `${` and `#{` the same way, in `Scanner.isEvalStart` and everywhere above it, so the surface should carry either without rewriting. +- A backslash has no meaning outside a string literal in JUEL, where `Scanner.nextEval` refuses one as an invalid character. +- A message name the engine evaluates is a shape ADR-0012 carries rather than refuses. + +## Considered Options + +- Read a raw template with the string escapes, accept either opener and print it back as written, which makes one escaper the inverse of one reader and leaves the engine's spelling alone. +- Read the token verbatim, unescape at each consumption point, and rewrite `#{` to `${` on the way in, which spreads the inverse over every consumption point and changes text the engine evaluates identically either way. +- Read the token verbatim and stop the printer from escaping, which loses a quote, a backslash or a line break inside a body on the way back out. + +## Decision Outcome + +Chosen option: read a raw template with the string escapes, accept either opener and print it back as written, because it makes one escaper the inverse of one reader and leaves the engine's spelling alone. + +`BpmnScriptValueConverter` converts a `RAW_TEMPLATE` token with `ValueConverter.convertString`, the routine `STRING` goes through, so `RawExpr.raw` holds the unquoted body with `\"`, `\\`, `\n`, `\r` and `\t` resolved. +One escaper in `packages/transform/src/juel.ts` maps those five back, and both `quote()` and `renderRawFallback` print through it. +Moving a slot from `quote()` to `quoteLiteral()` is a behaviour change rather than a refactor, and needs a decision of its own: settings still on `quote()`, such as a topic or a decision reference, can legitimately hold an expression in Operaton, so reclassifying one changes what a recompiled document runs. +The terminal opens on `${` or `#{`; `renderExpression` returns a top-level raw template verbatim, and `renderRawFallback` prints a raw body with the opener it was read with. +A raw template nested under an operator splices its body in parentheses, `!"${x}"` rendering `${!(x)}`, since JUEL has no `${` token inside an expression and `JuelExpressionManager.createValueExpression` rejects `${${x}}` at deployment. +A composite raw template such as `"${a} and ${b}"` has no one body to splice, and the validator refuses it under an operator. +A prose value that happens to open with either opener, a label, a map key, a form value, goes through `quoteLiteral()`, which puts a backslash before it, `"\${..."`, so the value lexes as `STRING` and reads back byte for byte. +The importer carries a message or signal name opening with `${` as written, since `BpmnParse.parseMessages` and `BpmnParse.parseSignals` evaluate every name through `createExpression` and the shape deploys; a message start keeps its refusal, since the engine rejects an expression there. + +### Consequences + +- Good, because printing and re-parsing a raw template gives back the same text, with a quote, a backslash or a line break inside. +- Good, because a Camunda-authored `#{...}` expression imports, compiles and deploys unchanged, in every position the surface has. +- Good, because an expression name on a message end, emit, handler start or signal imports instead of refusing a document the engine deploys. +- Bad, because a backslash inside a raw template is an escape, so `\"` reaches the engine as one character; an author who wants the engine to see a backslash writes two. +- Bad, because a structured `#{...}` body in a condition or cardinality comes back inside `${...}`; the import warns about that one rewrite. + +### Confirmation + +The `renderExpression` table in `packages/language/test/parsing.test.ts` pins the converter's read, both openers and the nested splice, and `packages/transform/test/juel.test.ts` re-parses every printed raw fallback through the grammar and asserts the body it reads back. +`packages/transform/test/ir-to-dsl.test.ts` pins the escaped map key, the one-line io value, the `#{` priority and form default, and `packages/transform/test/xml-to-ir.test.ts` pins the expression names that import and the five body positions that report a rewrite. diff --git a/docs/decisions/0032-field-injection-as-a-parameter-direction.md b/docs/decisions/0032-field-injection-as-a-parameter-direction.md deleted file mode 100644 index 0ffb9bc..0000000 --- a/docs/decisions/0032-field-injection-as-a-parameter-direction.md +++ /dev/null @@ -1,174 +0,0 @@ ---- -status: accepted -date: 2026-09-10 -decision-makers: Marlon Kranz ---- - -# Field Injection as a Third Parameter Direction, Form References on User Tasks Only - -## Context and Problem Statement - -Two Operaton surfaces this language does not yet reach both attach to an element the DSL already models, rather than to a new BPMN construct. -A service task, a send task, a business rule task, and both listener kinds can carry Operaton fields: name and value pairs injected into the Java bean the class or delegate binding instantiates. -A user task can carry a form reference by id and version, an alternative to the fixed form key the surface already writes. -Neither needs a new flow node or a new statement keyword; each needs a place inside a member block or a settings list an element already has. - -The open question for fields is where that place is. -`IoParameter` already spells `input greeting = "hello"` and `output result = "${x}"` inside the brace block a service-task-like statement opens, and a field is the same shape: a name, an `=`, and a value that is either a literal or a raw expression. -Reusing that rule costs nothing if a field behaves like an io parameter everywhere it can legally sit, and Operaton's own parser has to say where that is, because its moddle schema admits a field almost anywhere `extensionElements` can appear while its parser reads one off only a narrow set of bindings. -A field also has two XML slots for the same runtime value, `stringValue` as an attribute and `` as a child, and the DSL surface has to pick one spelling rather than grow a second vocabulary for a distinction no author can act on. - -The open question for form references is narrower: which element gets `formRef`, and what has to go with it. -Operaton's schema allows it on a user task and on a start event; this language's user task already carries `formKey`, so `formRef` is the natural second setting there. -Its own parser is stricter than its schema again: a form reference with no binding, or a form reference beside a form key, both refuse the deployment rather than merely reading oddly. - -## Decision Drivers - -- Every reserved keyword is a name an author permanently loses. - ADR-0023 already rejected a `listener` keyword on that ground, and ADR-0029 lists no new reserved words among its own drivers. -- Operaton's own parser decides what is legal, not what its schema merely allows to be written. - A placement rule read off the schema alone would carry an attribute the engine silently ignores rather than one it acts on. -- An illegal combination should be unrepresentable in the IR's type rather than merely checked for, continuing the convention ADR-0022 set for engine attributes generally. -- The import contract commits this tool to refusing or warning on what it cannot honestly carry, never dropping something that would change what the engine does in silence (ADR-0014). - -## Considered Options - -For field injection: - -- Reuse `IoParameter` as a third direction, `field` -- A dedicated `FieldInjection` grammar rule, with `field` a hard keyword -- A nested `fields { }` sub-block inside the existing member block -- Two DSL spellings, one for `stringValue` and one for `` - -For form references: - -- `formRef` on a user task only, matching where `formKey` already sits -- `formRef` on a user task and a start event, matching Operaton's schema - -## Decision Outcome - -Two decisions, chosen together because each rests on the same kind of evidence: what Operaton's own parser reads off an element, not what its schema merely permits to be written there. - -Field injection reuses `IoParameter` as a third direction. -`field greeting = "hello"` sits beside `input` and `output` inside the same member block a service task, a send task, and a business rule task already open, and, after this decision, inside a listener's own block too. -`IoParameter` already parses `direction=ID name=ID '=' value=IoValue` for any identifier in the direction position, so the grammar accepts `field` with no new rule at all. -The direction word stays a soft word exactly as `input` and `output` already are, free for a variable or a step name everywhere else in a file, and it is the validator, not the parser, that checks which directions a given owner allows. -Zero grammar rule is spent on the direction itself; the one grammar edit this decision needs is the brace block a listener gains, described below. -A fourth direction, `property`, joins the three the same way for a form field's block (ADR-0037). - -Rejected: a dedicated `FieldInjection` rule with `field` a hard keyword. -It would reserve a word for an idea `IoParameter` already has room for, the exact cost ADR-0023 already refused to pay for `listener`, and it would still need the same class-and-delegate placement rule and the same one-spelling value rule regardless of which grammar rule carries it. -Rejected: a nested `fields { }` sub-block inside the existing member block. -It adds a second member-block shape nested inside the first for no gain over letting `field` repeat directly where `input` and `output` already do, and every reader of the outer block would have to look one level deeper for the identical list. - -The brace block is forced, not chosen, once a field needs to repeat. -Spelling it as a setting instead, `service S(field: "greeting=hello")`, hits two limits the settings surface already has. -`checkDuplicateKeys` rejects a repeated setting key, so a second field on the same element could not repeat `field:`, and a setting's value is typed as an expression, which admits no map literal that could hold several named fields at once. -A member block, where `params+=IoParameter*` already repeats freely, is the only surface with room for more than one. - -The same block carries fields on both listener kinds, amending ADR-0023 rather than contradicting it. -Operaton hands a field list to a class-bound and a delegate-expression-bound execution listener and task listener exactly as it does to a class-bound or delegate-expression-bound service task. -Carrying fields on a task but not on a listener would leave one of the two import sites for a dropped field still reporting a drop for a construct this tool otherwise claims to carry. -`on (...) { }` gains a brace block holding `params+=IoParameter*`, the same rule a service task's own block already uses, which gives a listener a member block it did not have before. -ADR-0023 recorded that absence as deliberate, so it is amended rather than left to read as contradicted: a field is none of the three things that ADR ruled out. -It is not a form, not an input or output parameter, and not a nested listener. - -A field's value has one DSL spelling regardless of which of Operaton's two XML slots it lowers to. -A quoted string lowers to the `stringValue` attribute, and a `"${...}"` raw expression lowers to an `` child, the same literal-versus-expression split `renderIoValue` already draws when printing an io parameter's own value. -On import, an `` child, the moddle's third value slot, carries into the same IR text `stringValue` would and exports back as `stringValue`, with a warning naming the rewrite. -The moddle already normalizes an `expression` attribute into a child element on its own, so a normalizing round trip is not new here. -The one shape this rule cannot close is a `stringValue` that reads `${...}`. -Writing it back would turn a literal Operaton injects verbatim into an expression Operaton evaluates instead, so that shape keeps a drop warning naming the reason rather than being carried. - -Rejected: two DSL spellings, one mirroring `stringValue` and one mirroring ``. -The two spellings would name a distinction in Operaton's XML that carries no distinction in what the engine does with either shape until an author writes `${...}`, at which point the one-spelling rule already draws exactly that line. - -Where a field is legal follows the engine, not the schema. -Operaton's `BpmnParse.parseServiceTaskLike` hands `parseFieldDeclarations` to `ClassDelegateActivityBehavior`, to `ServiceTaskDelegateExpressionActivityBehavior`, and to the mail and shell behaviours it instantiates itself (a binding this surface carries since ADR-0042); nothing else takes one. -`ServiceTaskExpressionActivityBehavior` is built from an expression and a result variable with no field list, and `parseExternalServiceTask` is never handed one either. -`parseExecutionListener` and `parseTaskListener` repeat the identical split for both listener kinds, neither of which binds a built-in type. -So on a task a field rides a `class:`, a `delegate:`, or a `type:` binding; an `expression:` binding, a `topic:` binding, and a `decision:` binding all take none. -On a listener, which has no `type:` binding, a field rides `class:` or `delegate:` alone. -The validator reports a field written elsewhere as an error naming the bindings that do take one, and an imported field found under any other binding keeps a drop warning rather than being carried into a binding the engine would never read it from. - -`formRef` lands on a user task only, matching where `formKey` already sits. -Operaton's schema also allows a `formRef` on a start event, but this language does not carry `formKey` on a start event either. -A start owns only a label and an optional form block with no key of its own. -Giving `formRef` a start-event home while `formKey` still has none there would fix half of an existing asymmetry and leave the other half standing, a larger, separate change to that asymmetry rather than a scoping decision that belongs here. -It is a deliberate scope, not an oversight, and the start-event side is a follow-up. - -Rejected: `formRef` on a user task and a start event, matching Operaton's schema. -It would close the gap between what this surface accepts and what Operaton allows, but a start event here carries no `formKey` either, so adding `formRef` alone would fix one half of a pre-existing asymmetry while leaving the other half standing. - -A `formRef` requires a binding, because Operaton's own parser requires one. -`BpmnParse.parseFormDefinition` calls `addError` when `formKey` and `formRef` are both present on one task, and calls `addError` again when `formRef` is present with a binding that is absent or outside `latest`, `deployment`, or `version`. -Both shapes make the file undeployable rather than merely unusual. -The IR carries a form reference as a key and a binding with the binding required, which is what makes the invalid state unrepresentable rather than merely refused later. -The validator errors on both shapes the same way, and the importer refuses both on the way in, the same rule ADR-0028 already applies to a cancel end outside a transaction block: when Operaton itself refuses to deploy a shape, the importer refuses it too, rather than warning and carrying something that would never run. - -### Consequences - -- Good, because no word is reserved for either decision. - `field` stays available as a variable or a step name, and `formRef` is one more setting key rather than a keyword. -- Good, because a field reuses the io-parameter machinery already built for `input` and `output`: the duplicate-key check, the highlighting, and the completion all extend to it for free, the same way ADR-0023 already got the timer clause and the four bindings for free by reusing them for listeners. -- Good, because both illegal states are unrepresentable rather than merely checked. - A field has no binding-key slot to sit in on an expression, a topic, or a decision binding, and a form reference with no binding cannot be constructed in the IR at all. -- Good, because a document Operaton itself refuses to deploy is refused on import rather than accepted into a state the compiler could not have produced. -- Bad, because a listener's brace block now holds two different kinds of member under the same grammar rule, an io parameter and a field, so a reader has to check the direction word rather than the rule name to know which one they are looking at. -- Bad, because the field-placement rule is a fact about Operaton's parser, not its schema: class and delegate on a task or a listener, plus a task's `type` binding since ADR-0042. - It has to be documented rather than discoverable from the moddle alone. - -## Pros and Cons of the Options - -### Reuse `IoParameter` as a third direction - -- Good, because `IoParameter` already parses any identifier in the direction position, so `field` costs no grammar rule and no reserved word. -- Good, because the duplicate-key check, the highlighting, and the completion all extend to the new direction unchanged. -- Bad, because one grammar rule then carries two kinds of member, so a reader tells an injected field from an io parameter by the direction word rather than by the rule. - -### A dedicated `FieldInjection` rule - -- Good, because the rule name alone says what a member is, with no direction word to read. -- Bad, because `field` becomes a word an author permanently loses, the cost ADR-0023 already refused to pay for `listener`. -- Bad, because the placement rule and the value rule are the same whichever rule carries them, so the reservation buys nothing. - -### A nested `fields { }` sub-block - -- Good, because it collects an element's fields under one head. -- Bad, because it nests a second member-block shape inside the first, where `input` and `output` already repeat with none. -- Bad, because every reader of the outer block has to look one level deeper for the same list. - -### Two DSL spellings for the two XML slots - -- Good, because the slot the exporter writes is then visible in the source. -- Bad, because the two slots behave identically until the value reads `${...}`, which the single spelling already tells apart on its own. -- Bad, because choosing between them requires knowing Operaton's XML, which is what the surface exists to avoid. - -### `formRef` on a user task only - -- Good, because it sits beside `formKey`, so both ways of naming a form are written and read in one place. -- Bad, because Operaton's schema also admits a start event, whose form reference this surface then cannot express. - -### `formRef` on a user task and a start event - -- Good, because it accepts every placement the schema allows. -- Bad, because a start event carries no `formKey` here either, so it closes one half of that asymmetry and leaves the other standing. - -## More Information - -Amends ADR-0023, whose listener statement gains a brace block holding a field beside its bindings; the absence that decision recorded as deliberate covered a form, a parameter, and a nested listener, and a field is none of the three. - -Related decisions: ADR-0006 (the IR's vendor-neutral naming, which a field's name and value pair and a form reference's key and binding pair both follow). -ADR-0014 (the honest import contract behind every refusal and warning here). -ADR-0021 (folding a fourth service-task binding into an existing keyword rather than adding one, the same reuse-over-reservation reasoning field injection follows for `IoParameter`). -ADR-0022 (engine attributes as named IR fields, the precedent a field and a form reference both extend). -ADR-0023 (the listener surface this decision amends). -ADR-0028 (the refuse-on-parser-rejection rule this decision reuses for a form reference with no binding). -ADR-0029 (no new reserved words among its own drivers, the same driver behind reusing `IoParameter` here). - -Amended by ADR-0037, which adds `property` as a fourth direction, a member of a form field's block, by the same reuse of `IoParameter`. - -Amended by ADR-0038, under which the `property` direction rides a `topic` binding too, on a `service`, `send`, or `decide` task, the one direction a topic binding takes. -A field never rides `topic`; it rides `class`, `delegate`, and, since ADR-0042, a task's `type` binding instead. - -Amended by ADR-0042, which adds a task's built-in `type` binding as a third carrier of a field, beside `class` and `delegate`. diff --git a/docs/decisions/0033-call-activity-variable-mapping.md b/docs/decisions/0033-call-activity-variable-mapping.md deleted file mode 100644 index b34160f..0000000 --- a/docs/decisions/0033-call-activity-variable-mapping.md +++ /dev/null @@ -1,161 +0,0 @@ ---- -status: accepted -date: 2026-09-11 -decision-makers: Marlon Kranz ---- - -# Carry a call activity's variable mapping under its own two setting keys - -## Context and Problem Statement - -Operaton lets a call activity name a Java class or a bean expression that computes its in and out variable mapping in code, written as `operaton:variableMappingClass` or `operaton:variableMappingDelegateExpression` on the element. -Import refused both, and the message it threw said the delegate replaces the `operaton:in`/`operaton:out` mapping and that importing it would pass no variables into or out of the called process. -The engine's own parser and behaviour class say otherwise. -`BpmnParse.parseCallActivity` reads `operaton:in` and `operaton:out` unconditionally, with no guard on either attribute. -`CallableElementActivityBehavior.execute` builds the variable map from the in mappings and hands the already populated map to the delegate, which mutates it. -`CallableElementActivityBehavior.passOutputVariables` applies the out mappings first and runs the delegate after. -Both mechanisms run on each side with the delegate last, so a delegate overrides a declared mapping rather than replacing it, and a document carrying one loses nothing on the way in. - -What is open is everything else: what the two attributes spell on the authoring surface, whether that spelling sits in the call's parens or its braces, what shape the IR gives the pair, and what happens on a document that sets both at once. - -## Decision Drivers - -- The honest import contract (ADR-0014) reserves a refusal for loss that changes execution, and the engine runs a declared mapping and a delegate alike. -- ADR-0032 states where an injected field is legal as a rule about binding words, `class` and `delegate`, and that rule stays keyed on a word alone only while no other binding spells itself with them. -- ADR-0029 puts a setting that describes the element in the parens and a list with members of its own in the braces. -- An illegal combination should be unrepresentable in the IR's type rather than merely checked for, the convention ADR-0022 set for engine attributes. -- Operaton resolves a document setting both attributes with an if/else-if in `BpmnParse.parseCallActivity`: the class wins and the delegate expression is dropped with no diagnostic. - -## Considered Options - -For the spelling: - -- `mapper` for the class and `mapperDelegate` for the expression -- `class` and `delegate`, the words a service task's own bindings use - -For the placement: - -- A setting in the call's parens -- A member of the call's brace block, beside its `in` and `out` mappings - -For the IR shape: - -- A `CallVariableMapper` of its own, tagged `class` or `delegateExpression` -- The `CodeBinding` a service task and both listener kinds already carry - -For a document setting both attributes: - -- An author-time error, and on import the class with a warning naming the dropped delegate -- Mirroring the engine on both hops, taking the class in silence - -## Decision Outcome - -Chosen: `mapper` and `mapperDelegate` in the parens, carried by a `CallVariableMapper` of their own, mutually exclusive at author time and resolved to the class with a warning on import. - -The spelling is the decision the rest hangs off. -A variable mapping delegate has the Java shape of a `class` or a `delegate` binding and none of its behaviour, because Operaton hands it no field list: `instantiateDelegateClass` constructs the delegate with a null field-declaration list, and `ClassDelegateUtil.applyFieldDeclaration` returns without setting anything when the list is null. -ADR-0032's rule that a field rides a `class` or a `delegate` binding and no other is therefore still a rule about binding words, and seven sites in the source and the documentation state it that way. -Spelling a variable mapping with those two words would make all seven false at once and re-key the rule from which word to which word on which element. -It would cost a user-facing diagnostic too, since `FIELD_HOSTS_MESSAGE` is derived from the `fields` flag on `ATTRIBUTE_BLOCK_RULES`: flipping a call activity to carry fields would rewrite the sentence every element that hosts none prints. -A distinct word is also the honest one. -On a service task `class` names what runs; here the delegate names what computes the data crossing the process boundary, which is a different job that happens to share a Java shape. - -The placement follows from what the thing is. -A variable mapping is one setting on the call rather than a list with members of its own, so it goes where ADR-0029 puts a setting, and `CallActivity.own` gains the two keys after `businessKey`. -That one row is the whole authoring surface: the grammar already accepts any identifier as a setting key, completion derives its offer from `own`, and the printer prints in the same order. - -The IR carries a `CallVariableMapper` tagged `class` or `delegateExpression` rather than reusing `CodeBinding`, on two counts the engine decides. -`CodeBinding`'s `expression` member has no counterpart on a call activity, because Operaton reads exactly two attributes there and declares no `operaton:variableMappingExpression`; reusing the type would admit a third spelling that a hand-written validator rule then has to refuse, re-creating by hand the exclusion a tagged union states for free. -And `CodeBinding`'s `fields` slot would be present and always empty, since that slot exists for the two behaviours Operaton injects into and a variable mapping is neither. - -Two spellings on one call is an author-time error, delegated to `checkAtMostOneBinding` so the sentence is the one every other exclusive pair on the surface prints. -Import cannot refuse the same shape: Operaton deploys and runs the document with the class, so under ADR-0014 there is no execution loss to refuse for. -The import takes the class and reports the dropped delegate as shadowed, the report an implementation attribute outranked by a higher-ranked one already gets. - -There is no rule between a mapper and the call's `in` and `out` mappings, because the engine runs both. -That is the claim the refusal rested on, so the round-trip fixture authors a mapper beside an `in` and an `out` mapping rather than alone, and a reader who adds an exclusivity rule turns it red. - -Either spelling names an implementation of Operaton's `DelegateVariableMapping`, whose two methods are `mapInputVariables(DelegateExecution, VariableMap)` and `mapOutputVariables(DelegateExecution, VariableScope)`. -Nothing on the authoring or the import hop can check that the named class implements it, since `resolveDelegateClass` and `instantiateDelegateClass` resolve the class when the call executes rather than when the document deploys, so a wrong parent class is a runtime failure. - -`calledElementTenantId` stays refused, by the same function and for a reason that holds: it pins which tenant the engine resolves the called process against, so dropping it changes which process runs. -It is the one attribute on a call activity this surface still refuses. - -### Consequences - -- Good, because a document Operaton deploys and runs imports and re-exports, instead of being refused on a reading of the engine that the engine's own source contradicts. -- Good, because the field rule stays keyed on a word alone. - A mapper carries no injected field and takes none, so ADR-0032 needs no element-by-element exception and no site that states it has to change. -- Good, because a mapper and the declared `in` and `out` mappings sit in one call with no rule between them, which is what the engine does with them. -- Bad, because the call surface gains two words for a binding shaped in Java exactly like a `class` or a `delegate` binding and deliberately not spelled like one. - A reader has to be told why rather than read it off the surface. -- Bad, because neither the validator nor the importer can check a mapper. - The class is resolved when the call executes, so a misspelled name, or one whose class implements nothing, passes both hops and fails in the engine. - -### Confirmation - -The mapper block in `tests/call-activity.round-trip.test.ts` authors each spelling beside an `in` and an `out` mapping on the same call and asserts the whole IR node, the serialized Operaton attribute, an empty warning list, an identical re-import, the printed setting in its canonical position, and that the printed source revalidates. -The import table in `packages/transform/test/xml-to-ir.test.ts` pins both attributes under both namespace prefixes and the both-set case, asserting the whole mapper object and the whole warning list rather than membership in either. - -## Pros and Cons of the Options - -### `mapper` and `mapperDelegate` - -- Good, because the field rule stays a rule about the words `class` and `delegate`, true at every site that states it. -- Good, because `ATTRIBUTE_BLOCK_RULES.CallActivity` keeps `fields: false`, so the derived diagnostic naming what may host a field is untouched. -- Bad, because two words on the call surface describe a Java binding without using either of the two words the surface uses for one everywhere else. - -### `class` and `delegate` - -- Good, because the words match the Java shape and the attribute names the engine reads. -- Bad, because a field rides a `class` or a `delegate` binding and no other, and a call activity's variable mapping receives none, so the rule would have to name an element as well as a word. -- Bad, because seven sites state that rule as it stands, and `FIELD_HOSTS_MESSAGE` derives a user-facing sentence from the flag that would have to move. - -### A setting in the call's parens - -- Good, because a variable mapping describes the call itself and has no members, which is the line ADR-0029 draws. -- Good, because the offer order, the printed order, and the completion snippet all come from the one `own` row. -- Bad, because a long call then carries the binding and the mappings in two different brackets. - -### A member of the call's brace block - -- Good, because it would sit beside the `in` and `out` mappings the delegate computes. -- Bad, because the braces hold lists whose members repeat, and a call names one mapping delegate or none. -- Bad, because it would need a grammar rule where a setting key needs none. - -### A `CallVariableMapper` of its own - -- Good, because the two attributes Operaton reads are exactly the two tags, so a third spelling cannot be constructed. -- Good, because no slot on it is unreachable. -- Bad, because a second class-or-expression union sits beside `CodeBinding` in the IR, and a reader has to know why they are not one type. - -### The existing `CodeBinding` - -- Good, because a call activity's delegate is written the way a service task's is. -- Bad, because its `expression` member has no attribute to lower to, so the type would admit a shape a validator rule then refuses by hand. -- Bad, because its `fields` slot would be present and always empty on a binding Operaton injects nothing into. - -### An author-time error, the class with a warning on import - -- Good, because the author is told at the one moment a choice is still open. -- Good, because the import keeps what the engine keeps and names what it drops, which is the two-tier contract. -- Bad, because the two hops answer the same question differently, so the rule has to be read twice. - -### Mirroring the engine on both hops - -- Good, because one rule covers both directions. -- Bad, because it reproduces a silent drop on the hop where a diagnostic costs nothing, which is what the surface exists to avoid. - -## More Information - -Amended by ADR-0042, which adds `type` on a task to the binding words ADR-0032's rule names; the two words and the seven sites above are as they stood at this decision, which is unchanged. - -Amends ADR-0014, whose refused-construct list read "a call activity naming a resolution shape the surface cannot write back"; the bullet now names the four shapes this decision leaves refused. - -Amended by ADR-0054, under which a `calledElementBinding` word outside the four `BpmnParse.parseBinding` matches imports as `latest`, as `BaseCallableElement.isLatestBinding` reads it, and an `operaton:in`/`out` naming two shapes keeps the one `parseCallableElementProvider` reads first and warns on the rest. -Only `variables` outside `all`, an empty `source`, a source with no `target`, and a mapping of no recognized shape still refuse. - -Related decisions: ADR-0029 (the bracket shape that keeps a variable mapping a setting in the parens). -ADR-0032 (the field rule this spelling keeps keyed on a word alone). -ADR-0022 (engine attributes as named IR fields, the precedent `CallVariableMapper` extends). -ADR-0007 (the Operaton moddle extension fork, which has to declare both attributes or moddle drops them on write). diff --git a/docs/decisions/0034-multiple-start-events-on-a-process.md b/docs/decisions/0034-multiple-start-events-on-a-process.md deleted file mode 100644 index 975a0dd..0000000 --- a/docs/decisions/0034-multiple-start-events-on-a-process.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -status: accepted -date: 2026-09-12 -decision-makers: Marlon Kranz ---- - -# Multiple start events on a process - -## Context and Problem Statement - -ADR-0024 required an explicit `start` to be the first statement of its container and read one start event per container off that rule. -Operaton draws the line elsewhere. -`BpmnParse.parseStartEvents` accepts any number of start events on a process definition, and only a scope that is not a process goes through `BpmnParse.parseScopeStartEvent`, which reports an error on the second start of an embedded sub-process, a transaction, or an event sub-process. -So "one start per container" was an invariant of this surface, not of the engine. -It cost real files: a modeler document with a plain start beside a message start, the ordinary shape of a process entered both by hand and by correlation, was refused on import by name and by count. -The two components that would have to carry several starts already did. -The restructuring analysis wires every start event to its container's virtual entry (ADR-0019), and the printer walks every start it finds. -Only the validator, the desugarer's chaining, and the import refusal stood in the way. -Where may a second `start` sit on the page, what does the flow around it mean, and which facts about a start set are worth a diagnostic? - -## Decision Drivers - -- No grammar change: `Statement` already admits a `start` anywhere in a block, so the question is one of validation and lowering, not of syntax. -- The page must say what the graph does. - Steps run top to bottom and sequence flow is never written out, so a statement the flow runs past without entering would be the one silent exception. -- The honest import contract (ADR-0014): a shape the engine deploys and runs must import, and a refusal must name an engine reason. -- The engine's start matrix is silent at deploy time in two places that bite at runtime, and this surface reports what the engine will not. -- Whatever import accepts, the decompiler has to print in a form that re-desugars to the same graph. - -## Considered Options - -For the surface: - -- Flat sibling `start` statements in the process body -- A grouped entry block holding every start - -For a start that follows a statement whose flow is still live: - -- An error naming the ambiguity -- A start the flow runs past, joining its chain to the one before it - -For the two facts the engine leaves silent, no default start and a form on a start that is not the default: - -- A warning -- An error -- Documentation only - -For the scope: - -- A process body only, by engine rule -- Every container, with the engine's refusal surfacing at deployment - -## Decision Outcome - -Chosen: flat sibling `start` statements, a process body only, an error for a start after a live chain, and a warning for each of the two silent engine facts. - -A `start` opens a chain and takes no incoming flow. -It may therefore sit first in the process body, directly after another `start`, or after a statement whose flow always ends or redirects: an `end`, a `throw`, a `goto`, or a compound whose every branch does one of those. -Starts written back to back open one chain together, so `start A`, `start B`, `user T` enters `T` from both. -A start after an `end` opens a chain of its own and reaches a shared step by `goto`, which is the shape the decompiler prints. -A start after a step whose flow is still live is refused with a message naming the fix, because either reading of that page invents a flow the author did not write. -No grammar changed for any of this. - -The rule holds for a process body only. -An embedded `subprocess`, an `attempt` block, and an event-handler body keep exactly one start, at their head, because `BpmnParse.parseScopeStartEvent` rejects the second start of any scope that is not a process definition. -Import lifts the refusal for a process and keeps it for a sub-process and a transaction, naming that engine reason. - -Two warnings report what the engine decides in silence. -`BpmnParse.selectInitial` makes a lone start of any kind the default, and among several starts only a plain or timer start; with two or more starts and none of those two kinds the process has no default, and `ProcessDefinitionImpl.ensureDefaultInitialExists` throws when such a process is started by key. -The validator warns on the process name. -`BpmnParse.parseStartFormHandlers` binds a start form to the default start alone, so a `form` block on any other start is parsed and never offered, and the validator warns on that block. -Both stay warnings: the process deploys and runs, and being entered by message or signal is the point of writing such a process. -Two plain or timer starts on one process is a deployment error `selectInitial` reports itself, so this surface does not repeat it and treats the first as the default. - -The decompiler walks an unnamed plain start ahead of every other. -Such a start is elided on print and re-derived by the compiler at the head of the body, so its chain has to be the first one printed, else the entry point is lost on the way back. - -One corner is accepted rather than fixed, the one ADR-0019 already records for a boundary chain. -A start whose first step is a synthesized gateway prints the branch when every start into that gateway prints: `start A`, `start B`, `if (x) { ... }` round-trips clean. -The corner survives when one of those starts is the elided plain start, the one carrying the minted `StartEvent_` id and no content. -Its chain is walked first, the `if` printed for it consumes the gateway's out-edges, and the other start prints back as a dropped-edge marker. -No source spells that shape, since `goto` names an authored statement and never a synthesized gateway, so it reaches the printer through an imported document alone. -The authoring rule that keeps clear of it: write each start ahead of a named step. -The golden fixture and the `support-ticket` example both obey it. - -### Consequences - -- Good, because a process entered by hand and by message, a common reason to draw two starts, is one file and one diagram instead of two processes. -- Good, because the chain rule is one the reader can check on the page, and the desugarer never has to define the ambiguous shape. - Operaton does not reject a start event with an incoming flow: `BpmnParse.parseSequenceFlow` has no arm for a start-event destination, and `NoneStartEventActivityBehavior` inherits `FlowNodeActivityBehavior.execute`, which leaves the activity at once, so a start the flow runs past would deploy and run as a pass-through step, and the mistake would show only in history. - Refusing the shape is what rules that out. -- Neutral, because `initiator` is read off every start and set on the process definition by `BpmnParse.parseProcessDefinitionStartEvent`, last one wins, and the validator warns on every start but the last that names one. -- Neutral, because a message start whose name is already subscribed by another deployed definition fails in `BpmnDeployer.addMessageStartEventSubscription`, a fact about the deployment rather than the file, and out of this tool's reach. -- Bad, because the printed form of a process with several starts is not always the authored one: starts entering the same step print back to back above it, even one written after an `end` with a `goto` onto that step. - The graph is the same, which the idempotence block asserts, but the reader has to learn the shape. -- Bad, because the corner above means an imported document with an unnamed plain start into a shared synthesized gateway round-trips with a warning rather than cleanly. - -### Confirmation - -The frozen pair `tests/golden/multiple-starts.{bpmnscript,bpmn}` holds four starts across two chains, and its suite asserts at every hop that all four keep their trigger and none is the target of a sequence flow. -`tests/e2e/event-positions.test.ts` deploys `support-ticket`, a process with a plain and a message start, to a real Operaton and starts it once by key and once by message; each instance's history holds exactly its own start and the shared task, which is the assertion a pass-through start would fail. -The validator rows in `packages/language/test/validating.test.ts` pin every position a start may take, the chain error, and both warnings. - -## More Information - -Amends ADR-0024, whose Decision Outcome stated one start per container; that now holds for a subprocess, a transaction, and an event handler only. - -Amended by ADR-0048, which mirrors `BpmnParse.selectInitial` after all: a second plain or timer start is an error, and a second message, signal, or condition start repeating an earlier one's payload is refused beside it. - -Related decisions: ADR-0019 (every start wired to the virtual entry, and the synthesized-gateway corner this decision inherits). -ADR-0014 (the honest import contract under which the process-level refusal is lifted). diff --git a/docs/decisions/0035-link-events-for-import-round-trip-symmetry.md b/docs/decisions/0035-link-events-for-import-round-trip-symmetry.md deleted file mode 100644 index 499319e..0000000 --- a/docs/decisions/0035-link-events-for-import-round-trip-symmetry.md +++ /dev/null @@ -1,175 +0,0 @@ ---- -status: accepted -date: 2026-09-12 -decision-makers: Marlon Kranz ---- - -# Link events for import round-trip symmetry - -## Context and Problem Statement - -ADR-0020 set the `await` trigger scope at message, timer, signal, and conditional, and left link out by name. -Its reason: link is BPMN's own off-page connector, built for splitting one diagram across pages, and `goto` already names a jump inside one textual source, so link would duplicate an existing mechanism for a diagramming problem a single `.bpmnscript` file never has. -That reasoning is correct and it stays. -It is a claim about authoring, about what a person writing this language needs, and nothing here contradicts it. - -It is silent about the other direction. -A modeller's existing document can carry a link pair, and the importer refuses `bpmn:linkEventDefinition` by name on both the intermediate throw and the intermediate catch, so such a document does not import at all. -The one approximation on offer does not fit the import contract. -ADR-0014 lets the importer warn on a drop that changes nothing the engine runs and requires a refusal for everything else; at no point does it let the importer rewrite what is on the wire into a shape the surface prefers. -Reading a link pair as a `goto` would be that rewrite. -A `goto` compiles to one sequence flow, so the re-exported XML holds one edge where the incoming document held two events, and the author's own notation is gone. -The document that comes out is not the document that went in. - -So the question is whether a construct this language does not need for authoring should exist anyway, for the sake of what a diagram already says. -Two reasons answer yes: better support for existing models, and round-trip symmetry. -The second rules out every answer short of a full surface. - -## Decision Drivers - -- Import coverage of existing models. - Modelers built on bpmn-js, the Camunda Modeler among them, offer both ends of a link pair, and a document drawn with one is refused today. -- Round-trip symmetry under ADR-0014. - A construct the importer maps has to re-export as the same XML, and a link pair mapped to a `goto` does not. -- Reuse of what is built. - ADR-0017's payload surfaces already carry a quoted name for message and signal, and ADR-0016's terminality rule already decides from the printed words whether a statement ends its chain; a fifth catch kind should cost one dispatch case per layer, not a second mechanism. -- The engine's own link semantics. - Operaton correlates the two ends by name in one table per parsed file, creates no activity for the throw, and rewires flows at deploy time; those facts decide most of the validator rules before this surface gets a say. -- The cost of a second way to spell a jump. - Two constructs with one meaning is a real price, and this decision has to say what is bought for it. - -## Considered Options - -- Keep refusing a link definition on import, as ADR-0020 decided -- Import a link pair as a `goto`, and never author one -- A full surface, `emit link` and a named `await link` - -## Decision Outcome - -Chosen: the full surface, `emit link ToRetry("Retry")` and `await link AtRetry("Retry")`, because it is the only option under which a document carrying a link pair imports and re-exports as itself. - -The surface reads name after trigger, the way `throw` and `emit` already spell a named event statement. -The grammar comment on those two rules gives the reason: one token of lookahead places the name, since an `ID` after the trigger predicts a name and anything else ends the statement, every statement being keyword-led. -Name first is the shape ADR-0020 tried on `await` and Chevrotain reported as ambiguous, and it would also break every `emit` in the corpus. -The verb is `emit` rather than `throw` because `throw` lowers to `bpmn:endEvent` and a link throw is a `bpmn:intermediateThrowEvent`; a `throw link` is refused with a message pointing at `emit link`. -`emit link` nonetheless ends its chain, as a `goto` does. -`BpmnParse.parseIntermediateThrowEvent` records the throw in `eventLinkSources` and returns before creating an activity, so a flow leaving the throw finds no source and `BpmnParse.parseSequenceFlow` reports it as an invalid source; the token continues at the catch and nowhere else. -This is the one `emit` whose chain ends, and the two printed words `emit link` decide it, never position, so ADR-0016's round-trip argument for terminality read off the text still holds. - -The link name is quoted text, `link("Retry")`, like a message or signal name. -It keys the engine's own table and declares nothing, so it is not a declared code in the sense ADR-0030 gives error and escalation, and it reuses the required-name check, the bareword diagnostic, and the completion snippet the quoted kinds already have. - -`await` gains the optional name slot `emit` has, in the same trigger-first position. -The name is legal on every trigger, as in `await message Paid("Paid")`, and the id is synthesized when it is omitted. -This reverses ADR-0020's "no authored name" for every `await`, not only for a link catch, because a link catch has to be nameable to keep its id across an import and a link-only slot would need a validator rule with no engine reason behind it. -A named `await` is a `goto` target and takes part in the duplicate-name and reserved-name rules like any named step. -A link catch is the one exception: it is entered by `emit link` of the same name and by nothing else, so a `goto` onto one is refused. -No `await` of any kind is a boundary host. - -In the IR both ends carry `{ kind: 'link'; linkName: string }` and no flow runs between them. -The precedent is `BoundaryEvent.attachedToRef`, a node with no incoming flow wired to another by a reference rather than by an edge, not `SubProcess.element`, which is a tag on one node with no graph consequence. -The IR holds no reference field between the two ends because the engine matches them by name alone; an id reference would be a second fact that can drift from the first. -The restructuring analysis wires a link catch to its container's virtual entry the same way it wires a start event and a boundary event, since nothing flows into it: without that edge nothing in its chain has an immediate dominator, and a branch inside a rework chain prints as `goto`s instead of an `if`. -The element `name` attribute is stamped from the link name on both ends. -`BpmnParse.parseIntermediateLinkEventCatchBehavior` warns at deploy when the two differ and recommends they match, and modelers render the element name as the label, so stamping it keeps the deployment clean and the diagram readable. - -The validator enforces the rules the engine decides, each in the tier the engine's behaviour justifies. - -- One `await link` per name in the whole file, at any depth. - `eventLinkTargets` is a field of `BpmnParse` that is never cleared between scopes, and `parseIntermediateLinkEventCatchBehavior` refuses the second catch of a name it already holds. - This is stricter than `goto`, whose targets are container-scoped, and it is the one place link is less expressive than what it duplicates. -- Both ends in one container. - `BpmnParse.parseScope` parses a nested scope's activities and their flows before the parent registers its own catches, so a throw inside a subprocess never finds a catch in the parent, and `ScopeImpl.findActivityAtLevelOfSubprocess` refuses the other direction. -- Nothing leaves the throw. - The engine reports such a flow as an invalid source, as above. -- Nothing enters the catch. - Here the engine is more lenient than this surface: `BpmnParse.parseSequenceFlow` gives a flow into the catch an ordinary transition, and `IntermediateCatchLinkEventActivityBehavior.execute` leaves at once, so the shape deploys and runs as a pass-through. - This surface refuses it anyway, on two grounds. - A link target in a diagram never has an incoming flow, so the shape is not one a modeller's document produces, and the printer has to be able to place an imported catch after a statement whose flow has already ended, which a live fall-through into the catch would make ambiguous. - The statement before an `await link` must therefore end its chain: an `end`, a `throw`, a `goto`, an `emit link`, or a compound whose every branch does one of those. -- No link in a branch of a multi-branch `await`. - `BpmnParse.parseIntermediateCatchEvent` refuses a link catch after an event-based gateway. -- A throw with no catch of its name is an error. - `BpmnParse.parseSequenceFlow` refuses to deploy a flow into a link source whose name has no target, the analogue of an unresolved `goto`. -- A catch with no throw of its name is a warning. - The engine deploys it, and a comment beside the rewiring says so and leaves the warning unwritten; a modeller's document can carry one, and printed output from an import has to re-validate without an error. -- Engine settings and listeners on `emit link` are refused, one error per item. - The throw gets no activity, so `asyncBefore`, `jobPriority`, and every listener written there would never run. - The same items on `await link` are allowed, because `BpmnParse.parseIntermediateCatchEvent` runs `parseAsynchronousContinuationForActivity` and `parseExecutionListenersOnScope` for a link catch like any other. -- A link into a `parallel` or multi-branch `await` branch from outside is refused, mirroring the `goto` rule, because the join would never complete. -- Many throws to one catch stay legal, as they are in the engine. - -What a link pair buys at runtime is nothing. -`IntermediateCatchLinkEventActivityBehavior.execute` is a single `leave`, and `HistoryParseListener.parseIntermediateCatchEvent` skips a link catch by type, so history never records it. -Against a plain sequence flow the pair adds one activity instance that never waits and an optional async point on the catch. -That is why the argument for this surface is notation fidelity and not execution: a link in a `.bpmnscript` file exists so the file can say what the diagram said. - -`goto` stays the way an author jumps, and link is not recommended in hand-written source. -A `goto` target is scoped to its container and needs no name that is unique across the file, it draws no deploy-time table, and it prints as the same one edge it compiles to. -The READMEs say so beside the link rules. - -### Consequences - -- Good, because a document carrying a link pair imports instead of being refused, and it re-exports as the same two events under the same name. -- Good, because the round trip is symmetric: what the importer maps, the compiler emits unchanged, which ADR-0014 requires and a `goto` rewrite cannot give. -- Good, because link costs one dispatch case per layer: a fifth catch kind on the payload surfaces of ADR-0017, one `case 'link'` in each direction of the transform, and one row in the trigger vocabulary. -- Good, because every `await` is now nameable, so an author can `goto` a specific wait, which ADR-0020 listed as its own bad consequence. -- Bad, because two constructs now spell one jump. - A reader who meets `emit link` has to be told it is a `goto` in the diagram's own notation, and the READMEs carry that sentence. -- Bad, because `emit` is no longer always continuing; `emit link` is the one form that ends its chain, read off the trigger word. -- Bad, because link names are unique per file where a `goto` target is unique per container, so two subprocesses cannot each have a link called `Retry`. -- Bad, because a link catch nobody emits is dead code the validator can only warn about, since the engine deploys it and an imported document may carry one. -- Bad, because an async setting or a listener on `emit link` is refused rather than honoured, and nothing on the surface hints at why until the diagnostic says so. -- Bad, because the importer and the printer have to handle a graph with two disconnected components, the work that makes the import half of this decision real and is confirmed under the names below. - -### Confirmation - -`packages/transform/test/ir-to-xml.test.ts` pins the emitted pair in one object: "emits a link pair as two named events sharing one link name, nothing leaving the throw, nothing entering the catch, both laid out". -`packages/language/test/parsing.test.ts` pins the name slot, "`await` takes an optional name between the trigger and the payload, as `emit` does", and `packages/language/test/scoping.test.ts` pins "a goto reaches a named await". -`packages/transform/test/ast-to-ir.test.ts` pins "lowers a link pair as two disconnected nodes carrying one link name: nothing leaves the throw, nothing enters the catch". -`packages/language/test/completion.test.ts` pins that `link` is offered after `emit` and after a bare `await`, and "a race branch header offers every await trigger but link". -`packages/language/test/validating.test.ts` carries the table "Validation - link events", one row per rule above, each asserting the complete diagnostic list, from "a link pair in one container is clean, and the catch opens a new chain after the throw" through "throw link points at emit link". -The import half is confirmed by the frozen pair `tests/golden/link-events.{bpmnscript,bpmn}` with `tests/link-events.round-trip.test.ts`, which pins the five link events under their ids and link names at every hop, no flow leaving a throw or entering a catch, and a printed source that recompiles clean. -The Docker-gated case in `tests/e2e/event-positions.test.ts` deploys `order-rework` compiled and again round-tripped, and runs the same journey on both, the one place the claim that the pair deploys and runs can be shown. - -## Pros and Cons of the Options - -### Keep refusing a link definition on import - -- Good, because the surface stays where ADR-0020 put it, with four catch kinds and no second way to jump. -- Good, because no grammar rule, vocabulary row, or validator rule is added. -- Bad, because every diagram using BPMN's own off-page connector is unimportable, and the modelers this tool's users draw in offer one. -- Bad, because the refusal is not one ADR-0014 asks for: the engine deploys and runs a link pair, so there is no execution loss behind it. - -### Import a link pair as a `goto`, and never author one - -- Good, because the authoring surface does not change at all. -- Good, because the reader of the printed source sees the jump in the form this language already has. -- Bad, because re-export changes the XML. - The `goto` compiles to one sequence flow, so two events and their definitions come out as one edge and the diagram's notation is lost. - Under ADR-0014 that is an approximation, and round-trip symmetry rules it out by construction. -- Bad, because the printer would have to decide which `goto` edges were links, a fact the IR would no longer hold. - -### A full surface, `emit link` and a named `await link` - -- Good, because the import maps one-to-one and the export reproduces it. -- Good, because the payload surface, the required-name check, and the completion snippet are the ones message and signal already use. -- Bad, because the language now has two constructs for one jump, and has to say in its own documentation which one to write. -- Bad, because every `await` gains a name slot to make one kind of `await` nameable. - -## More Information - -Amends ADR-0020, whose four-trigger scope and "no authored name" hold for the reasons given there as claims about authoring; the trigger scope gains `link` and every `await` gains an optional name for the import direction. - -Amends ADR-0025, whose event-based gateway refusal relied on `mapIntermediateCatchEvent` turning a link away wherever it appeared; that refusal now rests on the importer's rule that no flow enters a link catch. - -Amends ADR-0016, whose terminality rule gains the one `emit` that ends its chain, `emit link`; the two printed words decide it, so terminality is still read off the text and never off position. - -Related decisions: ADR-0014 (the honest import contract, under which a rewrite of what is on the wire is not an option). -ADR-0016 (the terminality rule; `emit link` is the one `emit` that ends its chain, but the two printed words decide it, so terminality is still read off the text and never off position). -ADR-0017 (the payload surfaces the link name reuses). -ADR-0019 (boundary events, whose `attachedToRef` is the precedent for a node with no incoming flow wired by a reference). -ADR-0025 (the race, whose branch headers refuse `link` because the engine refuses a link catch after an event-based gateway). -ADR-0030 (declared codes, which a link name is not). - -Engine behaviour was read from `BpmnParse.java`, `ScopeImpl.java`, `IntermediateCatchLinkEventActivityBehavior.java`, and `HistoryParseListener.java` in operaton-engine 2.1.0: the fields `eventLinkTargets` and `eventLinkSources` on `BpmnParse`; `BpmnParse.parseScope`, which calls `parseActivities` before `parseIntermediateCatchEvents`; `BpmnParse.parseIntermediateCatchEvent`, which carries the event-based gateway refusal, the async continuation, and the listeners; `BpmnParse.parseIntermediateLinkEventCatchBehavior`, which carries the duplicate refusal and the name warning; `BpmnParse.parseIntermediateThrowEvent`'s early return for a link; `BpmnParse.parseSequenceFlow`'s link rewiring, its invalid-source error, and its ordinary transition; `ScopeImpl.findActivityAtLevelOfSubprocess`; `IntermediateCatchLinkEventActivityBehavior.execute`; and `HistoryParseListener.parseIntermediateCatchEvent`. diff --git a/docs/decisions/0036-elide-a-synthesized-end-only-at-its-block-tail.md b/docs/decisions/0036-elide-a-synthesized-end-only-at-its-block-tail.md deleted file mode 100644 index a4a7a9f..0000000 --- a/docs/decisions/0036-elide-a-synthesized-end-only-at-its-block-tail.md +++ /dev/null @@ -1,144 +0,0 @@ ---- -status: accepted -date: 2026-09-12 -decision-makers: Marlon Kranz ---- - -# Elide a synthesized end only at its block tail - -## Context and Problem Statement - -The decompiler leaves out a plain end event whose id carries a synthesized prefix and holds nothing printable. -The argument was that the compiler mints one at the same position when the printed source is read back, so nothing is lost. -That argument lived in a code comment on `isElidedOnPrint` and nowhere else, and it is wrong by one word. -The compiler mints a synthesized end in two places, `lowerContainerBody` and `lowerBoundaryHandler`, and both put it at the tail of a block. -It never mints one inside a branch or loop body, whose exit it wires to the join or the loop head instead. -So "the same position" holds only where the elided end was that tail: at its block's own depth, with no flow statement of the same block printed after it. - -The printer has several reasons to print a flow statement after such an end. -A second start's chain, an orphan chain such as a link catch's or one reached only by `goto`, and the final `goto` sweep all append to the same block. -Sequence flow is never written out, so the compiler wires the statement before the missing end into whatever follows it on the page. -The end's predecessor, which ended the process in the model, now continues into another chain. - -The smallest source that shows it: - -```bpmnscript -process p { - error E - var x: boolean - start S - step A - if (x) { - throw error Named(E) - } - step X -} -``` - -The compiler ends `X` in `EndEvent_p`. -Walking the guard, the printer wrote the then-branch as `goto Named`, then `step X`, left `EndEvent_p` out, and appended the orphan chain `throw error Named(E)` right after it. -Read back, `Flow_X_EndEvent_p` had become `Flow_X_Named`, and not one warning said so. -`X` ended the process in the model and threw in the document that came back. -A modeller reaches the same shape without writing any of this: the `EndEvent_1` a modelling tool mints, drawn as the else-path of a guard whose then-branch throws, is the repro's model. -Seven shapes lose a terminal this way. -Five of them hold one start: the repro, a tool-minted end as a guard's else-path or as its then-branch, two branches each to its own end, and a race whose branches each end. -The other two hold several: two starts each with its own end, and one chain holding both an elided start and an elided end. -ADR-0034 made those two loud, since a start after a live chain is now an error; the five with one start reported nothing. -What should the printer do with a plain synthesized end it cannot leave out without rewiring its chain? - -## Decision Drivers - -- The page says what the graph does. - Steps run top to bottom and the flow between them is never written out, so a statement missing from the page is a flow the reader cannot see. -- The honest import contract (ADR-0014): what the round trip cannot carry is reported, never dropped without a word. -- No change to the printed form of an existing document. - Every synthesized plain end in the golden pairs under `tests/golden/` sits at a container or boundary tail. -- One mechanism rather than two. - ADR-0034 already prints a second plain start under its reserved id and reports it, and the same shape serves an end. -- The reserved-name error (ADR-0010) as the channel that names the fix: the validator's message on the printed line tells the reader which element to rename. - -## Considered Options - -- Print the end under its reserved id wherever it is not its block's tail, with the existing `refusedStatement` report -- Inline an authored terminal in a guard clause when the split's route is its only incoming flow, in `branchStaysInRegion` -- Defer the chain holding the elided end to the last position in its block - -## Decision Outcome - -Chosen: print the end under its reserved id wherever it is not its block's tail, because it closes every shape with the mechanism ADR-0034 already established and moves no golden. - -`isElidedOnPrint` stays the model-level predicate and keeps answering "the printer may drop this end". -`warnRefusedStatements`, `forwardToRealTarget` and the importer's `warnElidedNamedDrop` read that answer and keep their meaning. -Whether the printer does drop the end is a fact about print order, known only once every chain of the block is on the page, so that half of the decision moves into the emitter. -Inside a nested body, `emitNode` prints the end at once and reports it. -The compiler wires such a body to its join or loop head and mints no end there, so there is nothing to re-derive one from. -At the top level, `emitNode` records the end with the position it would have printed at and pushes nothing. -Once the block's passes have run, `Emitter.emit` walks those records. -An end nothing followed was the tail and stays out. -Any other is spliced back at its position and reported under the `refusedStatement` category, one report per printed end, in page order. -A boundary handler body is one chain in an array of its own, so its end always sits at that array's end and stays elided, which is the implicit end ADR-0019 describes. -The printed source draws exactly one validator error, on the printed `end` line, and nothing else. -Renaming the id in the model makes the source clean and round-trips the document structurally unchanged. - -The import warning on a labelled plain end changes with it. -The importer cannot know which position the printer will give an end, so its message now describes both outcomes. -Where the script can do without the end, it is left out and its label with it; anywhere else it prints under a name the script refuses. -Either way the fix it names is a rename in the diagram. -The message for a start was exact and does not change. - -### Consequences - -- Good, because all seven shapes close, five of them going from an unreported rewrite to a refused source. -- Good, because no golden's printed form changes, so a document decompiled before this decision decompiles to the same text after it. -- Good, because the shape the reader meets is the one ADR-0034 already taught for a second plain start. - The reader finds a reserved id on the page, a print warning, and a validator error naming the element to rename. -- Bad, because a modeller's document with a default-named plain end that is not last now prints a reserved id and is refused until renamed, where before it printed clean and ran differently. -- Bad, because the import-side label warning can no longer say what happens to the label, only the two things that can. -- Neutral, because a jump into such an end is still dropped and marked (ADR-0009). - `forwardToRealTarget` reads the model-level answer, and at the top level the jump is printed before the end's position is known. - For a plain end in the same container the re-derived tail end is the same terminal, so the process runs the same and the marker is the only cost. -- Neutral, because inlining an authored terminal in a guard clause landed as a change of its own, printing `event-handlers` and `transactions` inline instead of as `goto` plus a trailing statement. - -### Confirmation - -The table `irToDsl: a synthesized plain end that is not its block's tail` in `packages/transform/test/ir-to-dsl.test.ts` holds four rows. -The guard shape is resolved by inlining the terminal, which the first row shows: `a guard's throw with a single incoming flow inlines, so its tail end stays elided rather than deferred`. -The other three rows pin the deferral: `a plain end whose chain a later goto-reached step follows prints before that step, and its label rides along`, `a plain end inside a branch prints at once, so the branch does not fall through`, and `of three chains each ending in its own plain end, the first two print in place and the last stays elided`. -Every row asserts the whole printed source and the whole report list, re-validates the source with the reserved-id error alone, and re-desugars it to the same graph up to id normalization. -Every round-trip suite under `tests/` validates the source printed from its golden with zero error diagnostics, and none of them changed, which is the proof that no golden's printed form moved. -The documentation row `a start and an end whose ids this tool writes for itself carry it and report that no script can spell it back` in `packages/transform/test/xml-to-ir.test.ts` pins the import warning's wording. - -## Pros and Cons of the Options - -### Print the end under its reserved id wherever it is not its block's tail - -- Good, because it covers every shape: the position test does not care why a statement followed the end. -- Good, because the printed source is refused with one error that names the line, and the fix is a rename in the model. -- Good, because the goldens keep their printed form. -- Bad, because a modeller's document that used to print clean is now refused until renamed. -- Bad, because "tail" is decidable only after the block's passes, so the emitter carries a deferred list and a resolve step that a reader has to find. - -### Inline an authored terminal in a guard clause - -Extend `branchStaysInRegion` so a branch entry that terminates before the join, and whose only incoming flow is the split's route, prints inside the branch whether or not its id is synthesized. - -- Good, because it fixes the guard shape at its root: the throw prints inside the `if`, the end after it is the tail again, and nothing is deferred. -- Good, because two goldens, `event-handlers` and `transactions`, would print closer to their authored sources. -- Bad, because it fixes two of the seven shapes and none of the others: a guard is not what puts a second start's chain or a race branch after the end. -- Bad, because it changes printed output for documents people already have. - -### Defer the chain holding the elided end to the last position in its block - -- Bad, because it conflicts with the elided-start-first rule of ADR-0034 whenever one chain holds both an elided start and an elided end. -- Bad, because with several elided ends only one chain can be last. -- Bad, because printing the end in place covers every shape this would, so it is dominated. - -## More Information - -Amends ADR-0014, whose warned-construct list said the statement carrying a synthesized start's or end's label is left out whole; that holds for a start, and for an end only at its block's tail. -Amends ADR-0009, whose guard clause now admits a bare authored terminal as its branch entry when the split's route is its only incoming flow, so the terminal prints inline where it printed as a `goto`; the dropped-jump marker stays as it was. - -Related decisions: ADR-0009 (the dropped-jump rule this decision leaves in place). -ADR-0010 (the reserved-name error that names the fix, and the consequence bullet this decision makes precise). -ADR-0019 (the boundary body whose end stays elided). -ADR-0034 (the elided-start-first rule this decision composes with, and the second-plain-start shape it reuses). diff --git a/docs/decisions/0037-form-field-constraints-values-and-properties.md b/docs/decisions/0037-form-field-constraints-values-and-properties.md deleted file mode 100644 index b169ba7..0000000 --- a/docs/decisions/0037-form-field-constraints-values-and-properties.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -status: accepted -date: 2026-09-13 -decision-makers: Marlon Kranz ---- - -# Form field constraints, values and properties - -## Context and Problem Statement - -A form field drawn in the Modeler carries four things this surface could not spell: a validation list, an enum's values, a date pattern, and a property list. -`DefaultFormHandler.parseValidation` reads each `operaton:constraint` by `name` and `config` and hands it to `FormValidators.createValidator`; `DefaultFormHandler.parseProperties` reads each `operaton:property` by `id` and `value`. -`FormTypes.parseFormPropertyType` reads `datePattern` on a `date` field and the `operaton:value` children on an `enum` field. -A field here was `id: type "label" = default`, so an imported enum field refused the document and an imported constraint, pattern, or property dropped with a warning. -Where do the four go on a field, and what does the import do with a shape the engine deploys but this surface cannot check? - -## Decision Drivers - -- No new reserved word, the driver ADR-0029 lists and ADR-0032 held to for `field`. -- The one bracket shape of ADR-0029: scalar settings in the parens, structured members in the braces. -- A closed set of constraint names the validator can check. -- The import contract of ADR-0014: refuse what the engine refuses, warn on what changes, drop nothing in silence. -- Typed IR fields as ADR-0022 requires, so an illegal constraint name is a type error. - -## Considered Options - -- Constraints as settings in the field's parens, values and properties as members of its braces -- Constraints as member lines in the braces, beside the values -- A nested `validation { }` block inside the field -- `enum` and `property` as soft words, or as keywords -- Constraints in the IR as an ordered list, or as flat named fields -- A fixed `validator:` key, or an open key set - -## Decision Outcome - -Chosen option: constraints in the parens and members in the braces, both words soft, an ordered constraint list, and a closed key set. -It is the ADR-0029 shape applied to a field at no grammar cost. - -A field reads `id: type "label" = default (settings) { members }`. -The type is `string`, `number`, `boolean`, `date`, or `enum`. -The grammar's type rule is `VarType | ID`, so `enum` costs no keyword, and the validator holds the word to the five and names them on a mistyped type. -An `enum` field's variable is a string, since `EnumFormType.convertValue` stores the chosen value's id. - -The parens take seven constraint keys and `pattern`. -`required: true` and `readonly: true` take the literal `true` alone. -`RequiredValidator.validate` and `ReadOnlyValidator.validate` never read a configuration, and `false` has no representation in a constraint list, so `required: false` is an error. -`min`, `max`, `minlength`, and `maxlength` take an integer literal or a quoted integer. -A `number` field is an Operaton `long`, and `AbstractNumericValidator.validate` parses the bound with `Long.parseLong`, the submitted value's own parser, so only an integer survives every submission. -The numeric bounds fit a `number` field and the length bounds a `string` field, since `AbstractNumericValidator.validate` and `AbstractTextValueValidator.validate` throw on a submitted value of any other Java type. -`validator` takes what `class:` takes, a class name or a `${...}` expression, which is what `FormValidators.createValidator` reads off `config`. -`pattern` is not a constraint but the `datePattern` that `FormTypes.parseFormPropertyType` reads on a `date` field alone, so it is legal there and an error on any other type. -Constraints are written in the order the engine validates them, and the IR keeps that order as a list keyed by a closed name union. - -The braces take an enum's values, one `id "label"` line each, and `property key = "value"` lines. -A value line on a field that is not an `enum` is an error, and so is a repeated value id. -An `enum` with no values is a warning, as an empty branch body is: the engine deploys it and then rejects every submitted value. -A literal default naming no value is an error, since `FormFieldHandler.createFormField` converts the default through the type on every render, so the form never opens. -`property` is the fourth `IoParameter` direction beside `input`, `output`, and `field`, the same move ADR-0032 made. -The direction word is soft, and the duplicate-key check and the completion come for free. -A property's value is a quoted string or a `${...}` expression, the shapes `DefaultFormHandler.parseProperties` reads as text. - -The import boundary follows ADR-0014. -Three shapes are refused as `UnsupportedFormFieldConstraintError`. -A constraint name outside the seven, since `FormValidators.createValidator` fails the deployment on any name it has not registered. -A `validator` or a bound with no `config`, since the first fails the deployment and the second every submission. -A name repeated on one field, since the parens hold each key once and dropping one would change what runs. -Dropped with a warning naming the method that never reads it: `datePattern` off a `date` field, `operaton:value` children off an `enum` field, and `config` on `required` or `readonly`. -Three shapes the engine deploys are carried as written, with a warning that the printed script draws an error at the field. -A bound on a type its validator refuses, a bound whose `config` is not an integer, and a literal enum default naming no value. -Rewritten with a warning: a repeated value id, kept at its first position with its last label, as `FormTypes.parseFormPropertyType` keeps it. - -A type outside the five stays refused, and a constraint name outside the seven joins it. -`customFormTypes` and `customFormFieldValidators` are engine configuration this surface cannot see, so a name registered there is indistinguishable from a typo. -`validator` with a class name reaches a custom validator anyway. - -### Consequences - -- Good, because no word is reserved: `enum`, `property`, and the eight setting keys stay ordinary identifiers everywhere else. -- Good, because a field the engine would deploy and then fail on every submission or render is a diagnostic naming the fix. -- Good, because a Modeler-drawn form carrying any of the four imports, prints, and recompiles to the same IR. -- Bad, because a field's braces hold two member shapes under two grammar rules, told apart at the third token rather than by a head word. -- Bad, because a constraint the engine deploys on the wrong type is carried on import with a warning, so the printed script does not compile until the author moves it. - -### Confirmation - -The `forms` golden pair under `tests/golden` compiles to the exact `operaton:formField` shape, imports back with no warning, and prints to source that re-desugars to the same IR. -Its suite asserts the complete `FormField[]` of both forms at every hop. -The validator table in `packages/language/test/validating.test.ts` pins each diagnostic, and the import tables in `packages/transform/test/xml-to-ir.test.ts` pin each refusal, drop, carry, and rewrite. - -## Pros and Cons of the Options - -### Constraints in the parens, members in the braces - -- Good, because it is the ADR-0029 shape, so a reader derives a field's spelling from any other element. -- Bad, because the braces hold two member shapes under one block. - -### Constraints as member lines - -- Good, because a field then has one member list and no parens. -- Bad, because `required` on its own line reads as a part the field is built from rather than a rule it is checked under. - -### A nested `validation { }` block - -- Good, because it mirrors the XML. -- Bad, because it nests a second block shape inside a field, the cost ADR-0032 refused to pay for a `fields { }` block. - -### `enum` and `property` as soft words rather than keywords - -- Good, because `var enum: string`, a step named `property`, and every existing file stay legal. -- Bad, because an enum value cannot be named by a reserved word, the limit a field id and a parameter name already have. - -### Constraints as an ordered list rather than flat named fields - -- Good, because the engine's validation order survives the round trip with one map per direction. -- Bad for flat fields, because the XML order is lost and every name needs a plumbing site per direction. - -### A fixed `validator:` key rather than an open key set - -- Good, because an unknown name is a diagnostic here instead of a failed deployment there. -- Bad, because a validator registered under its own engine name is written `validator: ""` instead. -- Bad for the open set, because every unregistered name fails the deployment, so the openness buys nothing but a late error. - -## More Information - -Amends ADR-0014 (the refusal list gains `UnsupportedFormFieldConstraintError`, the warned list the form field drops, carries, and rewrite), ADR-0029 (a form field takes the parens-and-braces shape), and ADR-0032 (three parameter directions become four). - -Related decisions: ADR-0007 (the moddle fork, which gains the `Properties`, `Property`, `Validation`, and `Constraint` types). -ADR-0022 (engine attributes as named IR fields, the typing rule the constraint name union follows). - -Amended by ADR-0054, under which a literal default the field's type cannot convert (a `number` that is not an integer, a `boolean` outside `true`/`false`, an ISO date on a field naming no pattern) joins the enum default among the shapes carried as written with a warning. -The repeated-name refusal says that `DefaultFormHandler.parseValidation` deploys both. - -Amended by ADR-0038, under which a `property` line is shared with an external task. -There it reaches the wire as `operaton:property name=`, on a form field as `operaton:property id=`, since the engine reads the two by different attributes. diff --git a/docs/decisions/0038-external-task-extras-ride-the-topic-binding.md b/docs/decisions/0038-external-task-extras-ride-the-topic-binding.md deleted file mode 100644 index bff08b7..0000000 --- a/docs/decisions/0038-external-task-extras-ride-the-topic-binding.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -status: accepted -date: 2026-09-13 -decision-makers: Marlon Kranz ---- - -# External task extras ride the topic binding - -## Context and Problem Statement - -A `service`, `send`, or `decide` step bound with `topic:` hands its work to an external worker, and the engine reads three more things beside the topic there and nowhere else. -`BpmnParse.parseExternalServiceTask` reads `operaton:taskPriority` through `parsePriority`, an `operaton:properties` block through `BpmnParseUtil.parseOperatonExtensionProperties`, and each `operaton:errorEventDefinition` under `extensionElements` through `parseOperatonErrorEventDefinitions`. -The priority orders the task in a worker's fetch, the properties travel with it, and a definition raises a BPMN error when its expression holds on a reported failure. -This surface spelled none of the three, so an import dropped the priority and the properties with a warning and reported the definition as an extension element the moddle descriptor did not declare. -Where do the three go on a step, and what does the import do with each shape the engine accepts? - -## Decision Drivers - -- No new reserved word, the driver ADR-0029 lists and ADR-0032 and ADR-0037 held to. -- The one bracket shape of ADR-0029: scalar settings in the parens, structured members in the braces. -- A mapping names an error code, and ADR-0030 made every code a declared name the editor resolves. -- The import contract of ADR-0014: refuse what the engine refuses, warn on what changes, drop nothing in silence. -- Typed IR fields as ADR-0022 requires, so an extra under a binding the engine never reads it for is a type error. -- The spellings were fixed before the design: `taskPriority:`, `property key = "value"`, and `error when `. - -## Considered Options - -- `when` as a soft word told apart by lookahead, or as a keyword -- A mapping as a member line, as a settings key, or as a nested block -- The extras on the external binding, or on the service task node beside it -- The extras on a thrown message binding a topic too, or on the three task kinds alone - -## Decision Outcome - -Chosen: both words soft, a mapping as a member line, the extras on the external binding, and the three task kinds alone. - -`taskPriority` is a settings key on `service`, `send`, and `decide`, legal beside `topic` alone. -Its value is an integer, a quoted integer, a bare variable name, or a `"${...}"` expression, since `parsePriority` parses a constant as an integer and fails the deployment on anything else. -`jobPriority` goes through the same method and the same check. - -`property key = "value"` is the `IoParameter` direction ADR-0037 added for a form field, admitted in a task's braces the same way. -It reaches the wire as `operaton:property name=`, where a form field's is keyed by `id`, because `BpmnParseUtil.parseOperatonExtensionProperties` and `DefaultFormHandler.parseProperties` read the same tag by different attributes. - -`error when ` is a fourth member shape of the shared block. -It parses as `ID ID ID Expr`, and the only other member opening with two identifiers is a parameter, `ID ID '='`, so the parser tells them apart at the third token. -Neither `error` nor `when` is reserved. -`error` is the trigger word every code site writes, and a mistyped `when` is a validator message saying what to write. -The code slot is a cross-reference in the scope a `throw error(X)` resolves in, so an undeclared code draws the linker's message and jump-to-definition works. -The condition is evaluated on the task's execution by `ExternalTaskEntity.evaluateThrowBpmnError`, where `VariableScopeElResolver` resolves `externalTask` to the task entity beside every process variable. -The validator admits that name inside a mapping's condition and nowhere else, so in an `if` it stays an undeclared variable. -The engine runs the mappings on a failure and again on a completion, in document order, and the first true one raises its code with the reported message. - -In the IR the three sit on the external variant of `ServiceTaskBinding`, as `taskPriority`, `properties`, and `errorMappings`. -The type then cannot carry them under a class, expression, delegate, or decision binding, which keeps every direction's guard a type check (ADR-0022). -A mapping counts as a use of its code, so a `bpmn:error` root is derived for it as for a throw (ADR-0016). -A task prints its injected fields, then its properties, then its mappings, then its parameters and listeners. - -The import boundary follows ADR-0014. -Two shapes refuse as `UnsupportedErrorMappingError`. -A definition with `errorRef` and no `expression`, which `parseOperatonErrorEventDefinitions` fails the deployment on. -One whose `errorRef` names no root, or a root without a code, the refusal a throw's dangling reference already draws. -The moddle deletes an unresolved reference, so a definition written without `errorRef`, which the engine skips, arrives identical to a dangling one, and both refuse. -Warned, one per item: the three on a class, expression, delegate, or decision binding, naming `parseExternalServiceTask` as the only reader, since the step runs without them either way. -`errorCodeVariable` and `errorMessageVariable` on a mapping are stored and never read on the throw side, since `BpmnExceptionHandler.propagateBpmnError` writes them off the catching definition, so they warn as on a thrown error. -A property missing its name or its value is skipped with a warning. -A repeated name is kept once with its last value, as the engine's `HashMap` keeps it, at its first position, and the warning names the rewrite. -A second `operaton:properties` block is reported, since the engine reads one. - -A message throw bound with a topic reaches `parseExternalServiceTask` too. -The surface carries the three on the task kinds alone, so on a thrown message the generic sweeps report each as not imported. -`send` and `decide` take them because `parseSendTask` and `parseBusinessRuleTask` without a `decisionRef` dispatch through the same `parseServiceTaskLike`. - -### Consequences - -- Good, because no word is reserved: `when` and `property` stay ordinary identifiers, and `error` already headed every code site. -- Good, because a mapping's code has a declaration to jump to and a rename that reaches it. -- Good, because a worker's priority, its properties, and its failure mappings round-trip without a warning. -- Bad, because the shared block holds a fourth member shape, told from a parameter at the third token rather than by a head word. -- Bad, because `externalTask` is a name the validator admits in one position and warns on in every other. -- Bad, because a definition missing its `errorRef`, which the engine would skip, refuses on import, since the moddle leaves nothing to tell it from a dangling one. - -### Confirmation - -The `external-task` golden pair under `tests/golden` compiles to the shape `parseExternalServiceTask` reads, imports back with no warning, and prints to source that re-desugars to the same IR. -Its suite asserts the complete binding of all three tasks at every hop and the frozen `extensionElements` text. -The validator table in `packages/language/test/validating.test.ts` pins each diagnostic, and the import tables in `packages/transform/test/xml-to-ir.test.ts` pin each refusal and warning. -`tests/e2e/forms-and-external-tasks.test.ts` fetches the charge from a real engine and fails it with a matching and a non-matching message. - -## Pros and Cons of the Options - -### `when` as a soft word - -- Good, because ADR-0017's rule holds: a word is reserved only where lookahead cannot do the job, and here it can. -- Bad, because the validator checks the word, and the editor highlights it through semantic tokens alone. - -### `when` as a keyword - -- Good, because a mapping is told from a parameter by its second token. -- Bad, because it reserves a word for a distinction the third token already makes. - -### A mapping as a member line - -- Good, because several mappings repeat the way parameters do. -- Bad, because the block's member shapes are four. - -### A mapping as a settings key or a nested block - -- Bad, because a settings key holds one value where a task carries several mappings, and a nested block is the cost ADR-0032 refused for `fields { }`. - -### The extras on the binding, or on the node beside it - -- Good for the binding, because the type forbids them wherever the engine never reads them. -- Bad for the node, because a rule then has to say they are empty unless the binding is external. - -### The extras on a thrown message too - -- Good, because the engine reads them there. -- Bad, because the engine reads the priority and the mappings off the message definition and the properties off the event element, a split shape of its own for a fourth site. - -## More Information - -Amends ADR-0014 (the refusal list gains `UnsupportedErrorMappingError`, the warned list the extras on a non-external binding) and ADR-0021 (the `topic` binding carries the three extras). -Amends ADR-0030 (a mapping is a third site naming a declared code) and ADR-0032 (the `property` direction rides a `topic` binding too). -Amends ADR-0037 (`property` is shared with an external task, keyed differently on the wire) and ADR-0029 (an error mapping joins the members the braces hold). - -Amended by ADR-0042, under which a built-in mail or shell `type` binding joins the bindings the extras are refused on in a script and warned about on import, since `parseExternalServiceTask` alone reads them. - -Amended by ADR-0054, under which a definition with no `errorRef` is skipped with a warning, as `parseOperatonErrorEventDefinitions` skips it, and one whose `errorRef` names no root imports as the code spelled by the text, as every engine reader takes it. -A thrown message's extras warn naming `parseExternalServiceTask` or `parseServiceTaskLike` as their reader. - -Related decisions: ADR-0007 (the moddle fork, which gains `taskPriority` on `ServiceTaskLike` and a concrete `ErrorEventDefinition` type beside the trait carrying the catch-side variables). -ADR-0016 (derived root elements, which a mapping's code now feeds). diff --git a/docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md b/docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md deleted file mode 100644 index daec385..0000000 --- a/docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -status: accepted -date: 2026-09-13 -decision-makers: Marlon Kranz ---- - -# Import BPMN resource assignment, and report the quantity attributes - -## Context and Problem Statement - -A user task drawn in the Modeler can say who does it in BPMN's own words: one `bpmn:humanPerformer` and any number of `bpmn:potentialOwner` elements, each carrying a formal expression. -`BpmnParse.parseTaskDefinition` reads both into the same assignee and candidate lists that `operaton:assignee`, `operaton:candidateUsers`, and `operaton:candidateGroups` fill, so the deployed task is the same whichever spelling the document used. -This surface writes the Operaton spelling alone, and the import dropped every resource role with a warning. -ADR-0014 admitted that drop as the one warned item that changes what runs, since it changes who may claim the task. - -Separately, BPMN declares `startQuantity` and `completionQuantity` on every activity with a default of `1`, and ADR-0014 named `startQuantity` as its example of an attribute neither read nor reported. -`BpmnParse` contains no line reading either, so a document setting one away from `1` runs as if it read `1`, and the import said nothing. - -## Considered Options - -- Refuse the BPMN spelling -- Carry both spellings in the IR and the script -- Import the two roles onto the Operaton attributes, with a warning naming the rewrite - -## Decision Outcome - -Chosen: import onto the Operaton attributes with a warning, and report a quantity away from `1`. - -The merge follows the engine's order. -`parseTaskDefinition` runs `parseHumanPerformer` and `parsePotentialOwner` before `parseUserTaskCustomExtensions`, so role-derived entries come first and the attribute's entries after, joined with a comma. -The one `bpmn:humanPerformer` gives the assignee. -Each `bpmn:potentialOwner` is split as `parseCommaSeparatedList` splits, where a comma inside a `${...}` or `#{...}` body does not split. -An entry `user(x)` is a candidate user, and `group(x)` or a bare entry is a candidate group. -The exported document then carries the Operaton attributes alone, and the engine builds the same identity links from them. -One warning per role element names the rewrite, so a reader knows the script says `assignee: "demo"` where the document said `bpmn:humanPerformer`. - -Two shapes refuse as `UnsupportedAssignmentError`, because the engine refuses to deploy them. -A `bpmn:humanPerformer` beside `operaton:assignee`, which `parseUserTaskCustomExtensions` rejects as a duplicate assignee, and more than one `bpmn:humanPerformer`, which `parseHumanPerformer` rejects. -The rest drops with a warning naming the method that reads nothing off it. -A role with no `bpmn:formalExpression` child, a plain `bpmn:performer` or bare `bpmn:resourceRole`, and a role's `resourceRef` or parameter binding are each read by no line of `BpmnParse`. -A `bpmn:expression` child typed `tFormalExpression` through `xsi:type` drops the same way, since the engine fetches the child by its tag name and finds none. -On every activity other than a user task the roles stay a generic drop, since the engine reads none of them there. - -A `startQuantity` or `completionQuantity` other than `1` warns as an `unmappedConstruct`, naming the attribute and that `BpmnParse` never reads it. -The moddle default is `1`, so a written `="1"` is silent, as ADR-0014 already holds for a BPMN attribute set to the value it reads back as when nothing is written. - -### Consequences - -- Good, because a Modeler-drawn user task deploys with the same assignee and candidates whichever spelling the document used. -- Good, because the one warned drop that changed who may claim a task is gone from the import contract. -- Bad, because the round trip rewrites the document: a `bpmn:potentialOwner` comes back as `operaton:candidateGroups`. -- Bad, because a `bpmn:expression` typed `tFormalExpression` is a legal BPMN spelling the engine reads nothing of, so it drops under a warning that reads as a lost assignment. - -### Confirmation - -The import tables in `packages/transform/test/xml-to-ir.test.ts` pin the merge, the split, the two refusals, each drop, and the quantity warning. -`tests/e2e/forms-and-external-tasks.test.ts` deploys the re-export of a task assigned in BPMN's own words to a real engine and reads the identity links back. - -## Pros and Cons of the Options - -### Refuse the BPMN spelling - -- Good, because the import never rewrites a document. -- Bad, because it rejects a file the engine deploys and runs, which ADR-0014 does not license. - -### Carry both spellings - -- Good, because the round trip is byte-stable on the roles. -- Bad, because the language then has two spellings for one assignment, and the IR two fields the engine merges into one. - -### Import onto the Operaton attributes with a warning - -- Good, because the deployed task is the same, and the warning names the rewrite as the `bpmn:manualTask` warning does. -- Bad, because the document written back differs from the one read. - -## More Information - -Amends ADR-0014 (the refusal list gains `UnsupportedAssignmentError`; the warned list narrows the resource-assignment drop to the roles the engine never reads and gains the quantity attributes; `startQuantity` leaves the unreported list). - -Amended by ADR-0054, under which an `operaton:potentialStarter` on the process is read the way this decision reads a `bpmn:potentialOwner`: its formal expression is split as `BpmnParse.parseStartAuthorization` splits it, onto `candidateStarterUsers` and `candidateStarterGroups` after the attributes' own, with one warning per element naming the rewrite. - -Related decisions: ADR-0022 (engine attributes as named IR fields, where `assignee`, `candidateUsers`, and `candidateGroups` live). diff --git a/docs/decisions/0040-engine-settings-on-synthesized-gateways.md b/docs/decisions/0040-engine-settings-on-synthesized-gateways.md deleted file mode 100644 index 75c5730..0000000 --- a/docs/decisions/0040-engine-settings-on-synthesized-gateways.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -status: accepted -date: 2026-09-13 -decision-makers: Marlon Kranz ---- - -# Engine settings on synthesized gateways - -## Context and Problem Statement - -A gateway is the one flow node in this surface with no keyword and no name of its own. -`if`, `while`, `do...while`, `parallel`, and a multi-branch `await` each synthesize one or two gateways from block structure, and the restructurer elides every one it matches back to a statement (ADR-0009, ADR-0010). -Operaton still reads five execution settings off every gateway it deploys. -`BpmnParse.parseExclusiveGateway`, `parseInclusiveGateway`, `parseParallelGateway`, and `parseEventBasedGateway` each call `parseAsynchronousContinuationForActivity` for `asyncBefore`, `asyncAfter`, and `exclusive`, and `createActivityOnScope` for `jobPriority` through `parsePriority`. -`DefaultFailedJobParseListener.parseActivity` reads the retry cycle the same way it does on an activity. -ADR-0022 gave every other flow node these five settings as named IR fields and left the four gateway kinds out, on the ground that a gateway with no textual identity has nowhere to author one. -A file a real deployment produced can set any of the five on any of its gateways, and this surface imported every one of them as a dropped extension attribute. -Where should a modeller put a setting for a construct they never write, and what should the import do with one it finds? - -## Decision Drivers - -- ADR-0013's rule that the compiler infers what it can rather than asking the author to spell a construct out. -- ADR-0009's elision: every gateway still has to be synthesized and matched back, so a setting cannot cost a gateway its textual anonymity. -- ADR-0014's import contract: what changes execution is refused, what does not is carried or warned, and nothing drops in silence. -- ADR-0029's one bracket shape, scalar settings in a parens, reused rather than a second shape invented for this. -- A join gateway sits beside its split under one statement, so the two need told-apart spellings inside one parens rather than a second parens. - -## Considered Options - -- A settings parens on the statement head, the join's settings inside it under a `join`-prefixed spelling -- A second parens for the join, separate from the head's -- A `gateway` keyword introducing an authored gateway with its own settings - -## Decision Outcome - -Chosen option: a settings parens on the statement head, carrying the split's five settings under their plain spelling and the join's under `join` plus the spelling, `joinAsyncBefore`, `joinAsyncAfter`, `joinExclusive`, `joinJobPriority`, `joinRetryCycle`. -One parens rather than two, because a statement already reads as one construct, and a second parens would ask an author to place a setting on a gateway they cannot see or name. - -The parens sits where every other statement's settings sit: after the condition on `if` and `while`, and after the keyword on `parallel` and a multi-branch `await`, immediately before the block that follows. -On `do...while` it follows the condition as well, and there it closes the statement, since no block follows. -`if`, `parallel`, and a multi-branch `await` synthesize both a split and a join, so their head takes all ten keys, five plain and five `join`-prefixed. -`while` and `do...while` are one gateway with a back-edge, so their head takes the five plain keys alone. -A `join`-prefixed key there is an error naming the plain spelling to write instead, since there is no second gateway for it to belong to. -An `if` chain lowers to one exclusive-gateway split no matter how many `else if` branches it carries. -`lowerIf` mints a single split with one conditioned flow per branch, so the head parens govern the whole chain. -An `else if` head takes none of its own. - -`parseEventBasedGateway` refuses `asyncAfter` on the gateway it deploys, so a multi-branch `await` head refuses the same setting. -The message names the branch triggers as where to put it instead, since the catch events behind the gateway are activities and take it. -The refusal repeats on import: a wait with several branches carrying `operaton:asyncAfter` throws `UnsupportedEventFeatureError` rather than importing a document that never deployed. -`joinAsyncAfter` is unaffected, since the join a race falls through to is an ordinary exclusive gateway. - -A `join`-prefixed setting on a statement whose branches all terminate is a warning rather than an error. -`statementTerminates` already decides that shape for the validator's unreachable-statement check, and the compiler's `pruneUnreachableJoin` drops the join on the same condition. -The same predicate says a join setting there has nothing to act on: the join is pruned, so the setting is honest but inert. - -On the way out, `irToXml` writes the four `operaton:` attributes and the `operaton:FailedJobRetryTimeCycle` child on a gateway exactly as it does on an activity. -Both go through the same `jobSettingAttrs` and `retryCycleElement` helpers, so a gateway's serialized shape is byte-identical to an activity's. -`xmlToIr` reads the same five off all four gateway tags instead of reporting each as a dropped extension attribute. -On the way to source, `irToDsl` prints the split's settings on the statement head it recovers and the join's as `join`-prefixed keys. -That happens only when the join is the one-route pass-through the restructurer elides back to the statement, exactly the shape the compiler produces and re-parses to. -A join with a second route out keeps its settings for its own head. -A gateway degraded to a jump, or otherwise left with no statement to carry them, draws a new print warning, `droppedSetting`, naming the gateway whose settings the script leaves out. -Execution listeners stay off every gateway kind: `JobSettings` carries the five settings alone, and `EngineAttributes` is the wider mixin that adds listeners for everything that keeps a textual identity to attach one to. - -### Consequences - -- Good, because a file a real deployment produced imports its gateway settings instead of losing them, closing the gap ADR-0022 left open. -- Good, because the settings sit where an author already looks, the statement head, rather than inventing a second parens or a keyword only for this. -- Good, because the two refusals, `asyncAfter` on a wait's head and on import, agree with each other and with what Operaton itself refuses to deploy. -- Bad, because a gateway that never reaches a statement still loses its settings on print; `droppedSetting` reports the loss rather than preventing it. - That is an unstructured jump target, a second-route join, or a join entered from one branch alone, which prints as a guard clause. -- Bad, because ten keys now sit in one parens on three statement kinds, so a reader has to know which five are the join's before knowing which gateway a value tunes. - -### Confirmation - -The `gateway-settings` golden pair under `tests/golden` carries all five plain and all five join-prefixed keys, both `parallel` element kinds included, and its suite asserts the settings survive DSL to XML to DSL and back. -The validator table in `packages/language/test/validating.test.ts` pins the loop and `await` refusals and the pruned-join warning, and the import and print tables in `packages/transform/test/xml-to-ir.test.ts` and `ir-to-dsl.test.ts` pin the carry, the `asyncAfter` refusal, and `droppedSetting`. - -## Pros and Cons of the Options - -### A settings parens on the statement head - -- Good, because it reuses the one bracket shape ADR-0029 already gives every element, at no grammar cost beyond a `SettingsParens?` slot on each of the five statement rules. -- Good, because the join's setting sits next to the split's, matching how close the two gateways sit in the model. -- Bad, because ten keys in one parens ask a reader to tell the join spellings from the split's by prefix alone. - -### A second parens for the join - -- Good, because the split and the join would each read as their own scalar list, with no prefix to track. -- Bad, because a statement's head would then carry two adjacent parens, a shape nothing else in the grammar does, and `while`'s single gateway would leave the second one empty for no reason. - -### A `gateway` keyword - -- Good, because an authored gateway could carry a setting the ordinary way any other named element does. -- Bad, because it reserves a word for an element ADR-0008 and ADR-0025 deliberately keep unauthored, and an authored gateway gains an id and stops eliding, breaking the restructurer's round trip. - -## More Information - -The five settings are documented beside `JobSettings` in `packages/transform/src/ir/types.ts`. -The join spellings are derived at `JOIN_KEY_BY_ENGINE_KEY` and the statements that take them are listed at `GATEWAY_STATEMENT_RULES`, both in `packages/language/src/vocabulary.ts`. - -Amends ADR-0022 by dropping its rule that no engine attribute can be authored on a gateway, the rule this decision replaces. -Amends ADR-0025 by dropping its note that a multi-branch `await`'s `asyncAfter` needs no refusal, since an author can now write one. -Amends ADR-0014, whose refusal list gains the `asyncAfter` bullet for a wait with several branches. - -Related decisions: ADR-0008 and ADR-0010, the synthesis and id scheme every gateway here still follows. -ADR-0029, the bracket shape reused. diff --git a/docs/decisions/0041-per-run-job-settings-on-a-repetition.md b/docs/decisions/0041-per-run-job-settings-on-a-repetition.md deleted file mode 100644 index 91d769f..0000000 --- a/docs/decisions/0041-per-run-job-settings-on-a-repetition.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -status: accepted -date: 2026-09-13 -decision-makers: Marlon Kranz ---- - -# Per-run job settings on a repetition - -## Context and Problem Statement - -ADR-0027 gave a repeated statement the same five engine settings any other statement carries: `asyncBefore`, `asyncAfter`, `exclusive`, `jobPriority`, and `retryCycle`. -Written there, the five govern one job around the whole repetition, because Operaton reads them off the activity element itself. -`BpmnParse.parseAsynchronousContinuationForActivity` also hands a multi-instance activity's `bpmn:multiInstanceLoopCharacteristics` element to `parseAsynchronousContinuation`, so the same three async settings written there mean one job per run instead. -`DefaultFailedJobParseListener.parseActivity` reads a retry cycle off that element the same way. -`createActivityOnScope` reads a job priority off the activity element alone, and nothing in the engine reads one off the loop element. -A document a real deployment produced could carry any of the four on its loop element. -This surface refused every one of them on import, since ADR-0027 gave the clause nowhere to write what the document already ran with. -Where does a per-run job setting sit on this surface, and what should happen to a per-run job priority the engine reads nowhere? - -## Decision Drivers - -- The language README's Repetition section already named the gap, as it stood: "one job per run is a setting on the multi-instance element itself, which this surface has nowhere to write." - Closing it is this decision's whole purpose. -- ADR-0029's one bracket shape holds every scalar setting in a parens on the statement head; a second construct for four more settings needs its own justification. -- ADR-0040 already solved the same shape of problem for a join gateway's settings, deriving a second spelling from the plain one with `prefixedSettingKey`. -- ADR-0014's import contract: a document a real deployment produced should import back rather than refuse, whenever this surface can honestly carry what it wrote. - -## Considered Options - -- Four keys in the statement's own parens, prefixed `run` -- A second parens attached to the `for` clause itself -- The four settings spelled inside the clause's own grammar - -## Decision Outcome - -Chosen option: four keys in the statement's own parens, prefixed `run`. - -`runAsyncBefore`, `runAsyncAfter`, `runExclusive`, and `runRetryCycle` sit in the same parens a repeated statement's own five settings already occupy. -Each is spelled by `runSettingKey(key) = prefixedSettingKey('run', key)`, the exact function ADR-0040's join spellings already use with a different prefix. -No `runJobPriority` exists, because `createActivityOnScope` reads a job priority off the activity element alone. -Nothing in the engine ever looks at the loop element for one, so a value written there would be honored by nothing. -The validator refuses that specific spelling by name rather than folding it into the generic unknown-key message. -A generic "not valid on a service task" would send an author hunting for a spelling that does not exist. -None of the nine repeatable statement kinds also synthesizes a gateway, and none of the three statements that synthesize a join repeats. -A `run`-prefixed key and a `join`-prefixed key therefore never both sit in one parens. -A `run*` key on a statement with no `for` clause is an error naming the fix: one job per run says nothing coherent when there is no run. -The message also names the plain spelling to write instead, for one job around the step. - -The four fields land flat on `LoopCharacteristics`, typed `Omit`, so the loop object is `JobSettings`-shaped everywhere but that one field. -ADR-0027's `readLoop` in `ast-to-ir.ts` reads them with `readJobSettings(settingsOf(stmt.items), runSettingKey)`, the same reader every other job-setting owner already uses, just keyed by the prefixed function instead of the identity one. -`jobSettingAttrs(loop)` and `retryCycleElement(moddle, loop)` in `ir-to-xml.ts` write the loop element's four attributes and its `operaton:FailedJobRetryTimeCycle` child exactly as they write an activity's. -Both take a plain `JobSettings`-shaped argument, and neither reads the DSL spelling at all. -On the XML side the loop element carries the plain, unprefixed attribute names, `operaton:asyncBefore` and not a `run`-prefixed one. -The loop element is already its own element, distinct from the activity's, so nothing there needs disambiguating the way one shared parens does. -Only the DSL surface needs the `run` prefix, since the split's settings and the loop's share one parens on the same statement. -The moddle declaration makes the plain names legal and typed on that element in the first place: `AsyncCapable.extends` in `operaton-moddle.json` gains `bpmn:MultiInstanceLoopCharacteristics` beside `bpmn:FlowNode`. -Upstream camunda-bpmn-moddle already declares the same properties there through `Collectable`'s own superclass. -Before this, the loop element's `asyncBefore` and friends parsed into `$attrs` as untyped strings, invisible to every typed reader ADR-0040 built. - -On import, `readRunSettings` in `xml-to-ir.ts` reads the same four plain attribute names and the retry-cycle child off the loop element, the same shape `readJobSettings` reads off any other owner. -`sweepRepetition` keeps its existing warning naming `operaton:jobPriority` on the loop element, since the engine still never reads one there. -On the way to source, `jobSettingItems(el.loop, runSettingKey)` in `irToDsl`'s `engineSettings` prints the four beside the statement's own settings, guarded by `repeats(el.loop)`. -A loop with nothing to repeat over carries none by construction, and prints none. - -### Consequences - -- Good, because a document a real deployment produced, carrying async or retry settings on its multi-instance element, now imports rather than refusing, closing the gap the language README named. -- Good, because no new IR shape was needed: `LoopCharacteristics` already existed, and every read, write, import, and print helper ADR-0040 built for `JobSettings` applies to it unchanged, keyed by a different function. -- Bad, because a repeated statement's parens can now hold nine keys, five plain and four `run`-prefixed. - A reader has to track which five govern the whole repetition and which four govern each run, the same trade ADR-0040 already accepted for a gateway's ten. -- Neutral, because `jobPriority` in that same parens keeps exactly one meaning, the whole repetition, since `createActivityOnScope` reads it off the step regardless of what repeats around it. - There is no ambiguity, only a spelling an author might expect and not find. - -### Confirmation - -The `repetition` golden pair under `tests/golden` carries the four `run` keys on at least one repeated statement, and its round-trip suite asserts the settings survive DSL to XML to DSL unchanged. -The validator table in `packages/language/test/validating.test.ts` pins the without-a-clause refusal, the `runJobPriority` refusal, and the nine repeatable kinds that admit the four. -The transform suites pin the loop element's written attributes, the import back into `LoopCharacteristics`, and the printed keys. -The end-to-end suite deploys a repeated step with `runAsyncBefore: true` and asserts the job executor creates one job per run rather than one job around the whole loop. - -## Pros and Cons of the Options - -### Four keys in the statement's own parens, prefixed `run` - -- Good, because it reuses the one bracket shape ADR-0029 already gives every element, at no grammar cost. -- Good, because it reuses the exact derivation ADR-0040's join spellings already use, so the validator, the printer, and the importer read one function for both prefixes. -- Bad, because it grows the parens on a repeated statement to nine keys at once, five plain and four prefixed. - -### A second parens attached to the `for` clause - -- Good, because a run's settings would sit visually next to the clause that creates the runs, rather than mixed in with the whole step's own settings. -- Bad, because it is a second bracket shape ADR-0029 exists specifically to avoid, one grammar rule and one printer branch for four settings that already have a home elsewhere. - -### The four settings spelled inside the clause's own grammar - -- Good, because visual adjacency to the clause is the same as the option above. -- Bad, because the `for` clause is a phrase grammar, not a keyed settings list. - Folding four settings into it needs new syntax for each one, where the settings-key list already reuses machinery built for every other element. - -## More Information - -The four fields are declared beside `LoopCharacteristics` in `packages/transform/src/ir/types.ts`. -`runSettingKey` and `RUN_ENGINE_KEYS` are declared beside `JOIN_KEY_BY_ENGINE_KEY` in `packages/language/src/vocabulary.ts`. -The moddle declaration is in `packages/transform/src/operaton-moddle.json`. - -Amends ADR-0027, closing the gap the language README's Repetition section named. -Amends ADR-0014, whose `UnsupportedLoopCharacteristicsError` bullet no longer refuses an async or retry setting on the repetition element itself. - -Amended by ADR-0048, which adds the validator's job-setting pairing warning and states plainly that a repeated step's own `jobPriority` prices the per-run job, never the whole-loop one, correcting the "one job around the whole repetition" reading above. - -Related decisions: ADR-0010 (the deterministic ids a repeated activity keeps). -ADR-0022 (engine attributes as named IR fields, the convention this decision extends onto a second element). -ADR-0029 (the one bracket shape reused here). -ADR-0040 (the same per-carrier prefixing convention, reused with a different prefix). diff --git a/docs/decisions/0042-mail-and-shell-as-a-type-binding.md b/docs/decisions/0042-mail-and-shell-as-a-type-binding.md deleted file mode 100644 index 9a973af..0000000 --- a/docs/decisions/0042-mail-and-shell-as-a-type-binding.md +++ /dev/null @@ -1,124 +0,0 @@ ---- -status: accepted -date: 2026-09-13 -decision-makers: Marlon Kranz ---- - -# Mail and shell tasks as a `type` binding - -## Context and Problem Statement - -Operaton runs two built-in behaviours off a service-task-like element that carry no code of their own: a mail task and a shell task. -Each is dispatched by `operaton:type` instead of a class, an expression, or a delegate. -`BpmnParse.parseServiceTaskLike` reads that attribute before any code attribute: `mail` builds `parseEmailServiceTask`, `shell` builds `parseShellServiceTask`, `external` builds `parseExternalServiceTask`, and any other value fails the deployment. -This surface refused every other `operaton:type` on import, with no way to write either behaviour. -Each behaviour also runs its own field checks before the engine deploys it. -A mail task needs `to` and one of `text`/`html`; a shell task needs only `command`. -Both refuse a field name their behaviour class does not declare, and a shell task alone also requires a fixed-value shape and three flags that must read `true` or `false`. -Where does this surface let an author write a mail or a shell task, and which of these engine checks should the validator mirror before a document ever reaches the engine? - -## Decision Drivers - -- ADR-0021's precedent: an external task folded into `service` as a fourth binding rather than a fifth statement keyword, because the IR already treats every execution form as one tagged union. -- ADR-0013's minimal keyword surface: a dedicated keyword per behaviour costs an English word an author permanently loses, for a distinction the engine already reads off one attribute. -- ADR-0032's rule that a field's legal placement follows the engine's own parser, not its schema, and both new behaviours reach the identical `instantiateDelegate` call a class binding already reaches. -- ADR-0014's honest import contract: a shape the engine refuses to deploy should be refused on import too, rather than carried into a process that cannot start. - -## Considered Options - -- A fifth binding key, `type`, carrying the engine's own discriminator value -- Two dedicated keywords, `mail` and `shell` -- Dedicated keys, `to:` for a mail task and `command:` for a shell task, with no `type` key at all - -## Decision Outcome - -Chosen option: a fifth binding key, `type`. - -`type: "mail"` or `type: "shell"` sits beside `class`, `expression`, `delegate`, and `topic` in a `service`, `send`, or `decide` task's parens. -It is one more member of `SERVICE_TASK_BINDING_KEYS` and of the exactly-one-binding check the other four already share. -The IR gains one variant on `ServiceTaskBinding`, `{ kind: 'builtin'; type: BuiltinTaskType; fields?: FieldInjection[] }`. -It is tagged by the engine's own `type` value rather than by two separate IR kinds, so every exhaustive switch grows one case instead of two. - -It is legal on `service`, `send`, and `decide`, the three tags a code binding already reaches, since a `decide` task with no `decision` key falls back to the same `parseServiceTaskLike` path a service task takes. -It is not legal on `throw` or `emit`. -The same engine method also builds a thrown message's implementation when bound with a topic. -A thrown message opens no member block here, so the fields a mail or shell task's required-field check demands could never be written on one. -`THROW_BINDING_KEYS` excludes `type` for that reason. - -`field` lines ride a `type:` binding exactly as they ride `class:` or `delegate:`. -`parseEmailServiceTask` and `parseShellServiceTask` both build their behaviour through `instantiateDelegate`, the identical call a `class` binding's `ClassDelegateActivityBehavior` reaches, so `applyFieldDeclaration` runs the same check regardless of which behaviour declared the names. -`FIELD_BINDING_KEYS` grows from `class`/`delegate` to `class`/`delegate`/`type`, one list every reader already reads. - -The validator mirrors the three checks the engine's parse runs before it builds a mail or shell behaviour. -It asks only once `type` is the binding actually written; beside another binding, that conflict is what needs fixing first. -`BUILTIN_REQUIRED_FIELDS` names each required group, keyed by type; `BUILTIN_FIELD_NAMES` names every field the behaviour class declares, in its own declaration order, and refuses any other with the same fact `applyFieldDeclaration` would throw on deployment. -On a shell task alone, the validator also mirrors the value shapes `validateFieldDeclarationsForShell` casts before it looks at any of them. -Every field must be a quoted literal rather than a `"${...}"` expression, and the three flag fields must read `true` or `false`. -All three tables live once in `vocabulary.ts`, read by both the validator and the importer, so a shape refused on one side and accepted on the other cannot happen by construction. - -`resultVariable` beside `type:` is accepted and ignored, the same as beside `topic:` today, since `parseResultVariable` is read inside `parseServiceTaskLike` but neither the mail nor the shell branch consults it. -Shell output has one spelling here, `field outputVariable = "..."`, the same field `ShellActivityBehavior.execute` writes the process's stdout to. -Mapping a second name onto it would spell one runtime fact two ways for no reader's benefit. - -`validateFieldDeclarationsForShell` checks a shell flag case-insensitively, so `wait: "True"` deploys clean. -`ShellActivityBehavior.readFields` later compares it with `"true".equals(...)`, case-sensitively, and reads it as `false`. -The validator refuses that spelling in a script; the importer carries it as written and warns of the false read. - -Import reads `mail` and `shell` back off `operaton:type` on the same three tags. -It refuses the same shapes the engine refuses: a missing required-field group, an undeclared field name, and, on a shell task, an expression-valued field or a flag outside `true`/`false`. -A `type` outside `external`, `mail`, and `shell` keeps refusing exactly as before, the same `addError` branch `parseServiceTaskLike` itself takes. -A `type` on a thrown message refuses too, for the same reason. -Whatever a `type` binding shadows, a code attribute Operaton would never read past it, is dropped with the same shadowing warning an `external` binding's shadowed attributes already draw. - -### Consequences - -- Good, because a document a real deployment produced, carrying a mail or shell task, now imports and recompiles rather than refusing, the same gap ADR-0021 already closed for an external topic. -- Good, because no keyword is reserved: `mail` and `shell` stay ordinary identifiers, free for a variable or a step name. -- Good, because the required-field, field-name, and shell-value-shape facts each live once, read by the validator and the importer alike, so the two cannot drift apart. -- Bad, because the binding surface now spans five keys instead of four, and the exactly-one-binding message has to name a fifth. -- Bad, because the shell flag's case-sensitivity gap, a written `"True"` deploying and reading as `false`, is a fact this surface documents rather than one it closes. - -### Confirmation - -The `mail-and-shell` golden pair under `tests/golden` carries a mail `service` task and a shell `send` task, and its suite asserts both round-trip byte for byte. -The validator table in `packages/language/test/validating.test.ts` pins the required-field, undeclared-field, and shell-value-shape refusals, and the fifth row of the exactly-one-binding table. -The transform suites pin the import back, the shadowing warning, and the printed source. -The end-to-end suite deploys a mail task and asserts the engine refuses one with no body, citing the same message `validateFieldDeclarationsForEmail` throws. - -## Pros and Cons of the Options - -### A fifth binding key, `type` - -- Good, because it matches ADR-0021's own precedent: one more member of a binding list the IR, the validator, and the printer already treat as one tagged union. -- Good, because `field` rides this binding for free, the same machinery `class` and `delegate` already extend. -- Bad, because the binding's own value, `"mail"` or `"shell"`, names the engine's attribute rather than a word this surface invented. - -### Two dedicated keywords, `mail` and `shell` - -- Good, because a reader would see both behaviours named directly, rather than as a value under a shared `type` key. -- Bad, because it reserves two English words no earlier binding needed, for a distinction the IR already collapses into one binding kind. - -### Dedicated keys, `to:` and `command:`, with no `type` key - -- Good, because a mail task's recipient would sit directly in the parens rather than inside a member block. -- Bad, because a mail task takes up to eight fields and a shell task twelve, more than a settings key per field can hold without duplicating the member-block machinery `field` already provides. - -## More Information - -The IR variant is declared beside `ServiceTaskBinding` in `packages/transform/src/ir/types.ts`. -`TYPE_BINDING_KEY`, `TYPE_BINDING_VALUES`, `BUILTIN_FIELD_NAMES`, `BUILTIN_REQUIRED_FIELDS`, and `SHELL_FLAG_FIELDS` are declared beside `SERVICE_TASK_BINDING_KEYS` in `packages/language/src/vocabulary.ts`. - -Amends ADR-0021, whose "exactly one of the four" binding count grows to five. -Amends ADR-0032, whose field-binding rule grows from `class`/`delegate` to `class`/`delegate`/`type`. -Amends ADR-0014, which narrows the `type` refusal to a value outside `external`, `mail`, and `shell`, and whose warned list gains a mail or shell task's shadowed attributes. -Amends ADR-0033, which states ADR-0032's field rule as a rule about the two binding words `class` and `delegate` at seven sites; the rule stays keyed on words alone, and `type` on a task is the third. -Amends ADR-0038, whose three extras stay with the `topic` binding: on a mail or shell `type` binding they refuse in a script and warn on import, since `parseExternalServiceTask` alone reads them. - -Amended by ADR-0048, under which `resultVariable` beside `type:` draws a warning that nothing writes the variable, rather than being accepted and ignored. - -Related decisions: ADR-0006 (the IR between the AST and BPMN XML, which carries Operaton's semantics under vendor-free names, whose tagged union this decision extends). -ADR-0013 (the minimal keyword surface a fifth key, rather than two more keywords, follows). -ADR-0014 (the honest import contract behind every refusal above). -ADR-0021 (folding an external topic into `service` as a fourth binding, the precedent this decision repeats for a fifth). -ADR-0026 (task kinds on the authoring surface, the three tags `parseServiceTaskLike` dispatches from). -ADR-0032 (field injection, whose placement rule this decision extends to a third binding). diff --git a/docs/decisions/0043-result-variable-beside-an-expression-binding.md b/docs/decisions/0043-result-variable-beside-an-expression-binding.md deleted file mode 100644 index d3e311b..0000000 --- a/docs/decisions/0043-result-variable-beside-an-expression-binding.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -status: accepted -date: 2026-09-13 -decision-makers: Marlon Kranz ---- - -# A result variable rides an expression binding, never a class or delegate one - -## Context and Problem Statement - -`resultVariable` names the process variable a service task's return value is stored in. -This surface accepted it on a `service`, `send`, or `decide` task beside any of the five bindings, and the importer carried `operaton:resultVariable` beside any binding into the IR. -Operaton does not. -`BpmnParse.parseServiceTaskLike` reads the attribute once, through `parseResultVariable`, which also accepts the older `resultVariableName` spelling. -The `class` branch and the `delegateExpression` branch each fail the deployment when it is set, with the message `'resultVariableName' not supported for elements using ''`, where the element is `serviceTask`, `sendTask`, or `businessRuleTask`. -Only the `expression` branch hands it to `ServiceTaskExpressionActivityBehavior`. -The `type` branches for `mail`, `shell`, and `external` never read it, so beside `topic:` and `type:` it stays accepted and ignored, as ADR-0042 already records. -A `decide` task with a `decision` key takes a separate path, `parseDmnBusinessRuleTask`, which reads the attribute into `DmnBusinessRuleTaskActivityBehavior`, so beside `decision:` it is read. -The golden `engine-attributes` fixture carried the exact shape the engine refuses, a class binding beside a result variable, and every suite was green. -What should the validator and the importer do with a result variable beside a class or delegate binding? - -## Decision Drivers - -- ADR-0014's contract: a shape the engine refuses to deploy is refused on import, never carried into a process that cannot start. -- The validator's standing rule, applied in ADR-0037 and ADR-0042, that a deployment refusal the engine's parser raises is reported in the editor first, with the fix named. -- One fact, one home: which binding a result variable rides is a fact about `parseServiceTaskLike`, and both sides should read it the same way. - -## Considered Options - -- Refuse it in the validator and on import -- Warn and drop it on import, and keep accepting it in a script -- Keep accepting it on both sides - -## Decision Outcome - -Chosen option: refuse it in the validator and on import, because the engine fails the deployment and ADR-0014 refuses what fails deployment. - -The validator reports an error on the `resultVariable` key of a `service`, `send`, or `decide` task whose one binding is `class` or `delegate`. -The message quotes the engine's own refusal text under the element name the engine uses, and names the fix: bind with `expression` to store the return value, or drop the setting. -It runs beside the `type` checks, only once exactly one binding is written; a task with two bindings has that conflict to fix first. -Beside `expression:` and `decision:` the setting stays clean, and beside `topic:` and `type:` it stays accepted and ignored. - -The importer throws `UnsupportedServiceTaskFormError` for a service, send, or business rule task without a `decisionRef` that carries `operaton:resultVariable`, or the older `operaton:resultVariableName`, beside `operaton:class` or `operaton:delegateExpression`. -The refusal quotes the same engine text. -Beside `operaton:expression` the attribute imports and prints as before. - -The `engine-attributes` golden pair rebinds its `AssessBodywork` task with an expression, keeps its `resultVariable`, and is refrozen; its `(node id, attribute, value)` contract is unchanged. - -### Consequences - -- Good, because a script that would fail deployment draws an error at the setting, with the engine's own reason and the binding that does store a return value. -- Good, because a document the engine refuses no longer imports, prints, and recompiles into a document the engine still refuses. -- Bad, because a script carrying the combination draws an error rather than a dropped setting, though it never deployed either way. -- Bad, because the engine's refusal text is quoted by the validator and by the importer separately, so a change in the engine's wording is two edits. - -### Confirmation - -The binding table in `packages/language/test/validating.test.ts` pins the refusal on `service`, `send`, and `decide` under both bindings, and the clean row for `expression`. -The refusal table in `packages/transform/test/xml-to-ir.test.ts` pins the import refusal for both attribute spellings. -The `engine-attributes` round-trip suite pins the refrozen artifact byte for byte and imports it without a warning. - -## Pros and Cons of the Options - -### Refuse it in the validator and on import - -- Good, because it is the only option under which every document this surface accepts deploys. -- Bad, because an author who wrote the combination gets an error rather than a quiet fix. - -### Warn and drop it on import, and keep accepting it in a script - -- Good, because an imported document keeps its binding and loses only the attribute the engine would have refused. -- Bad, because the printed script would then differ from the document in a way the author has to notice in a warning, and the same script written by hand would still fail to deploy. - -### Keep accepting it on both sides - -- Good, because nothing changes. -- Bad, because the surface would keep producing documents the engine refuses, against ADR-0014, and the golden fixture pinning that shape would keep proving nothing. - -## More Information - -The validator's refused-binding table sits beside the `type` binding tables in `packages/language/src/bpmn-script-validator.ts`; the importer's check sits in `readServiceTaskBinding` in `packages/transform/src/xml-to-ir.ts`. - -Amends ADR-0014, whose `UnsupportedServiceTaskFormError` bullet gains this case. - -Amended by ADR-0048, under which `resultVariable` beside `topic:` or `type:` draws a warning that nothing writes the variable, rather than staying accepted and ignored. - -Amended by ADR-0054, under which the importer reads `operaton:resultVariableName` as `resultVariable` with a warning naming the respelling, and carries the attribute beside an external or built-in `type` binding with a warning that the binding never writes it. -On a thrown message it refuses the attribute beside `operaton:expression` too, since `parseServiceTaskLike` stores the expression's value under it and the script's throw has no slot for it. - -Related decisions: ADR-0014 (the honest import contract behind the refusal). -ADR-0021 (the binding list this rule reads). -ADR-0042 (the `type` binding, whose parse-mirroring checks this one sits beside). diff --git a/docs/decisions/0044-raw-templates-read-and-printed-with-string-escapes.md b/docs/decisions/0044-raw-templates-read-and-printed-with-string-escapes.md deleted file mode 100644 index 4bb0079..0000000 --- a/docs/decisions/0044-raw-templates-read-and-printed-with-string-escapes.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -status: accepted -date: 2026-09-14 -decision-makers: Marlon Kranz ---- - -# Read and print a raw template with the string escapes, either opener kept - -## Context and Problem Statement - -A quoted string whose body opens with `${` or `#{` is a raw template, the escape hatch for an expression outside the JUEL subset the grammar spells bare. -Langium's `DefaultValueConverter` converts only the terminal named `STRING`, so the `RAW_TEMPLATE` token reached the compiler with its quotes and escapes intact, and the reader that consumed it stripped the quotes and nothing else. -The printer's `quote()` escaped a quote and a backslash on the way out. -The two were not inverses: `"${fn(\"a\")}"` compiled to `${fn(\"a\")}`, which JUEL's `Scanner.nextEval` refuses as an invalid character, since a backslash has no meaning outside a string literal there, and every pass through the printer added one more backslash. -`renderRawFallback`, which prints an out-of-subset condition or cardinality on import, escaped nothing, so a body with a quote printed source the grammar could not lex. -Only `${` opened a raw template, so a `#{...}` body from a Camunda-authored document lexed as a literal and compiled to `${"#{x}"}`, a string the engine never evaluates, and the printer rewrote a leading `#{` to `${` wherever it could. -A raw template nested under an operator rendered as `${${x}}`, which `JuelExpressionManager.createValueExpression` rejects at deployment. -The importer refused a message or signal name opening with `${`, on the grounds that a quoted name reads back as an expression, although `BpmnParse.parseMessages` and `BpmnParse.parseSignals` evaluate every name through `createExpression` and the shape deploys. -How should a raw template be read, printed and nested, and which opener should it keep? - -## Decision Drivers - -- The printer's quoting and the reader's unquoting have to be exact inverses, or every round trip changes the text. -- Operaton reads `${` and `#{` the same way, in `Scanner.isEvalStart` and everywhere above it, so the surface should carry either without rewriting. -- A message name the engine evaluates is a shape ADR-0014 carries rather than refuses. - -## Considered Options - -- Read a raw template with the string escapes, accept either opener and print it back as written -- Keep the verbatim read, unescape at each consumption point, and rewrite `#{` to `${` on the way in -- Keep the verbatim read and stop the printer from escaping - -## Decision Outcome - -Chosen option: read a raw template with the string escapes, accept either opener and print it back as written, because it makes one escaper the inverse of one reader and leaves the engine's spelling alone. - -`BpmnScriptValueConverter` converts a `RAW_TEMPLATE` token with `ValueConverter.convertString`, the routine `STRING` goes through, so `RawExpr.raw` holds the unquoted body with `\"`, `\\`, `\n`, `\r` and `\t` resolved. -One escaper in `packages/transform/src/juel.ts` maps those five back, and both `quote()` and `renderRawFallback` print through it. -The terminal opens on `${` or `#{`; `renderExpression` returns a top-level raw template verbatim, and `renderRawFallback` prints a raw body with the opener it was read with. -Nothing rewrites `#` to `$` except at a `bpmn:loopCardinality`, a `bpmn:completionCondition`, a `bpmn:conditionExpression`, a `bpmn:condition`, or an `operaton:errorEventDefinition` expression: a `#{...}` body the importer can spell as bare DSL prints bare there, the compiler writes it inside `${...}`, and the import reports that. -A raw template nested under an operator splices its body in parentheses, `!"${x}"` rendering `${!(x)}`, since JUEL has no `${` token inside an expression. -A composite raw template such as `"${a} and ${b}"` has no one body to splice, and the validator refuses it under an operator. -A prose value that happens to open with either opener, a label, a map key, a form value, prints with a backslash before it, `"\${..."`, which lexes as `STRING` and reads back byte for byte. -The importer carries a message or signal name opening with `${` as written; a message start keeps its refusal, since the engine rejects an expression there. - -### Consequences - -- Good, because printing and re-parsing a raw template gives back the same text, with a quote, a backslash or a line break inside. -- Good, because a Camunda-authored `#{...}` expression imports, compiles and deploys unchanged, in every position the surface has. -- Good, because an expression name on a message end, emit, handler start or signal imports instead of refusing a document the engine deploys. -- Bad, because a backslash inside a raw template is an escape, so `\"` reaches the engine as one character; an author who wants the engine to see a backslash writes two. -- Bad, because a structured `#{...}` body in a condition or cardinality comes back inside `${...}`; the import warns about that one rewrite. - -### Confirmation - -The `renderExpression` table in `packages/language/test/parsing.test.ts` pins the converter's read, both openers and the nested splice. -`packages/transform/test/juel.test.ts` re-parses every printed raw fallback through the grammar and asserts the body it reads back. -`packages/transform/test/ir-to-dsl.test.ts` pins the escaped map key, the one-line io value, the `#{` priority and form default, and `packages/transform/test/xml-to-ir.test.ts` pins the expression names that import and the five body positions that report a rewrite. - -## More Information - -Amends ADR-0024, whose lexical refusal of a name opening with `${` is lifted; a message start keeps the refusal ADR-0024 records for the engine's reason. -Amends ADR-0031, whose account of the raw reader stripping quotes without unescaping no longer holds; the split between `quote()` and `quoteLiteral()` it decided stands, with `quoteLiteral()` escaping either opener. - -Related decisions: ADR-0014 (the import contract that carries what deploys). diff --git a/docs/decisions/0045-reserve-an-inclusive-fallback-only-where-the-engine-can-take-it.md b/docs/decisions/0045-reserve-an-inclusive-fallback-only-where-the-engine-can-take-it.md deleted file mode 100644 index d97e5f8..0000000 --- a/docs/decisions/0045-reserve-an-inclusive-fallback-only-where-the-engine-can-take-it.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -status: accepted -date: 2026-09-14 -decision-makers: Marlon Kranz ---- - -# Reserve an inclusive fallback only where the engine can take it - -## Context and Problem Statement - -`lowerParallel` reserved a default flow on every inclusive fork and, with no `else` written, ran it straight to the join. -`InclusiveGatewayActivityBehavior.execute` takes every non-default flow that has no condition or a true one, and adds the default only when that set comes out empty. -A branch with no head is in the set every time, so beside one the fallback can never be taken. -The printer leaves a fallback that runs straight into the merge out, so the compiled document came back without it, and once the split degraded to jumps the fallback printed as an `else`, which the validator refuses beside an unheaded branch. -The same lowering had a second gap. -`lowerWhile` wired its back-edge without the exit id the body reserved, so a `while` whose body ended in a loop left the inner gateway declaring a default flow no flow carried, and `irToXml` refused the document. -Which forks should name a fallback, and how does a reserved exit reach the back-edge? - -## Decision Drivers - -- The compiler should write no flow the engine can never take. -- The tool's own output should print back as source the validator accepts. -- A valid program must compile. - -## Considered Options - -- Reserve the fallback only beside an `else` or when every branch is conditioned, and carry the reserved exit id on every fall-through, the back-edge included -- Keep reserving the fallback on every inclusive fork and teach the printer to drop the `else` it prints for a dead one -- Elide a join that exactly one flow reaches and that carries no setting, retargeting the flow to the join's successor - -## Decision Outcome - -Chosen option: reserve the fallback only beside an `else` or when every branch is conditioned, and carry the reserved exit id on every fall-through, because it removes the dead flow where it is written and the crash with one argument. - -`lowerParallel` reserves `Flow__default` when the fork is inclusive and either an `else` branch is written or every branch carries a condition. -With an `else` the default points at that branch. -With every branch conditioned it runs straight to the join, since such a fork with no default deploys and then throws a stuck execution the first time no condition holds. -Beside an unheaded branch no default is written and the fork carries no `default` attribute. -The validator's refusal of an `else` beside an unheaded branch stands, for the same reason: the branch it would fall back from always runs. -`lowerWhile` adds its back-edge with `body.exitFlowId`, as `lowerDoWhile` already did, so a body ending in a loop keeps that loop's declared default on the flow the enclosing head takes. - -A join that exactly one flow reaches stays written. -`ExclusiveGatewayActivityBehavior.doLeave` and the parallel and inclusive behaviours run it as a pass-through, and `BpmnParse.validateExclusiveGateway` accepts its single unconditioned outgoing flow. -The printer recovers a fork whose branch terminates, and a guard clause at the tail of a loop body, from that merge: without it, `parallel { { user A } { end X } } user C` degrades to jumps and `while (x) { if (c) { end R } }` prints as `while (x) { end R }`. -The third option waits until the printer structures those shapes without the merge. - -### Consequences - -- Good, because a compiled inclusive fork beside an unheaded branch prints back without a warning and re-parses without an error. -- Good, because a `while` whose body ends in a loop compiles. -- Bad, because a join that exactly one flow reaches stays in the document, where bpmnlint reports it as superfluous and the auto-layout draws the default edge into it across the terminating branch's end event. - -### Confirmation - -`packages/transform/test/ast-to-ir.test.ts` pins the four fallback shapes in one table over `parallel { if (a > 1) { ... } ... }`, and the nested `while` with the reserved id on its back-edge through `irToXml`. -`packages/transform/test/ir-to-dsl.test.ts` pins that a modeled fallback running straight into the merge comes back as the model without that edge. -The frozen pairs `tests/golden/branch-and-race.{bpmnscript,bpmn}` and `tests/golden/gateway-settings.{bpmnscript,bpmn}` each hold an inclusive fork beside an unheaded branch with no `default` attribute. - -## More Information - -Amends ADR-0025, whose rule that the default flow is always emitted no longer holds; the `else` refusal it records stands. -Operaton behaviour was read from `InclusiveGatewayActivityBehavior.execute`, `ExclusiveGatewayActivityBehavior.doLeave` and `BpmnParse.validateExclusiveGateway`. - -Related decisions: ADR-0009 (dominator-based restructuring, which is what needs the merge), ADR-0024 (a terminating branch does not rejoin). diff --git a/docs/decisions/0046-listener-shapes-the-engine-deploys.md b/docs/decisions/0046-listener-shapes-the-engine-deploys.md deleted file mode 100644 index 0a9fc73..0000000 --- a/docs/decisions/0046-listener-shapes-the-engine-deploys.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -status: accepted -date: 2026-09-14 -decision-makers: Marlon Kranz ---- - -# Listener shapes the engine deploys - -## Context and Problem Statement - -Deploying the `listeners` and `input-output` golden artifacts to Operaton failed on three shapes the tool wrote and the surface called legal. -`BpmnParse.parseTaskListeners` accepts the event words `TaskListener.EVENTNAME_CREATE`, `EVENTNAME_ASSIGNMENT`, `EVENTNAME_COMPLETE`, `EVENTNAME_UPDATE`, `EVENTNAME_DELETE` and `EVENTNAME_TIMEOUT`, and the assignment word is `assignment`, where the language spelled `assign` and wrote it through to `event="assign"`. -`BpmnParse.parseTimeoutTaskListener` reads the listener's `id` and adds an error when it is null, since the id is baked into the timer job's handler configuration and keys `TaskDefinition.addTimeoutTaskListener`. -The tool wrote no id, and the importer warned one away. -`BpmnParse.checkActivityInputOutputSupported` adds an error for an `operaton:inputOutput` on a `bpmn:subProcess` whose `triggeredByEvent` is `true`, where a host-less `on` handler's `input` and `output` lines lowered exactly there. -Each shape also blocked the import side: a real file carrying `event="assignment"` was refused, and a deployable file with a timeout listener rebuilt to one the engine refuses. -Which spelling, which id and which member set does the surface commit to, so that what it writes deploys and what deploys imports? - -## Decision Drivers - -- A program the validator accepts must deploy, and a document that deploys must import and rebuild to one that still deploys. -- One fact about the language lives once: the event words, the parameter hosts and the id scheme are read off one table each. -- No new key on the surface for something the engine derives, and no second spelling to document. - -## Considered Options - -- Spell the event as the engine does, mint the timeout listener's id on export, and refuse parameters on a host-less handler -- Keep `assign` on the surface and map it to `assignment` on the wire in both directions -- Give the timeout listener an `id` key and keep the parameter grant, moving the mapping onto the trigger start event - -## Decision Outcome - -Chosen option: spell the event as the engine does, mint the id on export, and refuse the parameters, because each rule then follows from one engine method and adds nothing to the surface. - -The task listener event is `assignment`, the word in `TASK_LISTENER_EVENTS`, and completion, the validator's list and the importer's refusal all read that table. -Every `timeout` task listener is written with the id `_timeout_`, `n` counting the task's timeout listeners from one in document order. -An id the document already holds is stepped past the way the synthesized root element ids are. -The importer consumes a task listener's `id` without a warning: on a timeout listener the export re-mints it, and on any other event `BpmnParse.parseTaskListener` never reads it. -An `on` handler takes no `input` or `output` line in either form. -The hosted form lowers to a boundary event and the host-less form to an event sub-process, and the engine refuses a mapping on either (`BpmnParse.parseBoundaryEvents`, `BpmnParse.checkActivityInputOutputSupported`). -The attribute block rule for a handler therefore grants no parameters, and the validator's existing parameter-host message names the kinds that take one. - -### Consequences - -- Good, because the two golden artifacts, and every program the validator accepts, deploy. -- Good, because a Modeler-authored file with an assignment listener or an id-carrying timeout listener imports without a refusal or a warning. -- Bad, because a timeout listener's authored id is replaced by the minted one on a round trip, so a document that names its timeout listeners rebuilds under the tool's names. -- Bad, because a value a host-less handler's body needs has to be mapped on a step inside the body rather than on the handler. - -### Confirmation - -`packages/language/test/validating.test.ts` pins `on assignment` accepted and `on assign` refused with the list, and a parameter on either handler form refused with the parameter-host message. -`packages/transform/test/ir-to-xml.test.ts` pins two timeout listeners on one task written as `_timeout_1` and `_timeout_2`, and the second stepping past a task already holding its id. -`packages/transform/test/xml-to-ir.test.ts` pins `event="assignment"` imported, `event="assign"` refused, and a listener id consumed without a warning. -The frozen pairs `tests/golden/listeners.{bpmnscript,bpmn}` and `tests/golden/input-output.{bpmnscript,bpmn}` carry the three shapes and import without a warning. - -## Pros and Cons of the Options - -### Spell the event as the engine does, mint the id, refuse the parameters - -- Good, because the surface has one spelling per fact and the wire carries it as written. -- Good, because the id needs no key: the engine needs one, the author never reads it, and the task id makes it readable. -- Bad, because `assignment` is the longer word, and the validator's list is what teaches it. - -### Keep `assign` and map it to `assignment` on the wire - -- Good, because the shorter word stays. -- Bad, because one event word would then have two spellings, one in the language and one in every diagram and engine log, and both would need documenting. -- Bad, because the mapping would be the one place `TASK_LISTENER_EVENTS` is not the whole truth about an event word. - -### An `id` key on the timeout listener, and the mapping on the trigger start event - -- Good, because an authored id survives a round trip. -- Bad, because `BpmnParse.parseStartEvents` calls `ensureNoIoMappingDefined` on every start event, so the nested start cannot take the mapping either. -- Bad, because the key would be the only listener setting the engine derives rather than reads, and every timeout listener would have to spell it. - -## More Information - -Amends ADR-0023, whose event list spells `assign` and whose timeout listener carries no id. -Operaton behaviour was read from `BpmnParse.parseTaskListeners`, `BpmnParse.parseTaskListener`, `BpmnParse.parseTimeoutTaskListener`, `BpmnParse.checkActivityInputOutputSupported`, `BpmnParse.ensureNoIoMappingDefined` and `TaskListener`. - -Related decisions: ADR-0010 (deterministic structural ids), ADR-0014 (the import contract), ADR-0023 (listeners on the attribute block). diff --git a/docs/decisions/0047-timer-job-settings-on-the-element-the-engine-reads.md b/docs/decisions/0047-timer-job-settings-on-the-element-the-engine-reads.md deleted file mode 100644 index fcd7742..0000000 --- a/docs/decisions/0047-timer-job-settings-on-the-element-the-engine-reads.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -status: accepted -date: 2026-09-14 -decision-makers: Marlon Kranz ---- - -# Timer job settings on the element the engine reads - -## Context and Problem Statement - -`BpmnParse.parseTimer` builds the job a timer creates from two places: `operaton:exclusive` on the `bpmn:timerEventDefinition` element, and the priority stored on the activity that carries the definition. -`DefaultFailedJobParseListener.parseStartEvent`, `parseBoundaryEvent` and `parseIntermediateCatchEvent` read the retry cycle off the same event element when its type is a timer. -`operaton:exclusive` on the event tag is read by `BpmnParse.parseAsynchronousContinuation` alone, which configures the async continuation job, and with no async flag there is no such job. -The tool wrote `exclusive: false` on the event tag and never on the definition, so every timer job it deployed locked exclusively whatever the script said. -On import the definition's attribute was dropped under a warning claiming the tool keeps that setting. -A host-less `on timer(...)` handler wrote `jobPriority`, `retryCycle` and `exclusive` on the event sub-process it lowers to. -Nothing that creates a timer job looks there, since the timer activity is the start event nested inside it. -Which element should each timer job setting be written on, and how does the surface keep spelling the setting once? - -## Decision Drivers - -- A setting the script writes must reach the job it names. -- The surface keeps one `exclusive` key per statement rather than one per job the element may create. -- A document the tool wrote must import back to the same script, and a foreign document must import without losing what the engine reads. - -## Considered Options - -- Write each setting on the element the engine reads it from, and lift a synthesized start's settings back into the handler head on print -- Warn on a host-less timer handler's job settings and point the author at an explicit `start` inside the body -- Give the surface a second key for the timer job's lock, beside the one for the continuation job - -## Decision Outcome - -Chosen option: write each setting on the element the engine reads it from, because it makes the documented spelling run as written with no new key and no warning. - -A node carrying a timer writes its `exclusive` on the `bpmn:timerEventDefinition` as well as on the event tag: the definition's copy locks the timer job, the tag's copy the continuation job an async flag creates. -The moddle descriptor declares the attribute on the definition, so it parses as a typed boolean and the default `true` is omitted on write. -On import the definition's value is taken and the tag's stands in where the definition carries none, so a document the tool wrote before this decision imports unchanged. -Where both are written and differ, the import keeps the definition's and warns once, naming which job each governs, since the surface has one key for both. - -A host-less `on timer(...)` lowers `jobPriority`, `retryCycle` and `exclusive` onto the trigger start event it synthesizes, and `asyncBefore` and `asyncAfter` onto the event sub-process, whose continuation job they create. -The printer lifts the three back into the `on timer` head as long as the start prints no statement of its own. -When it does print, they stay on the `start` line, so an authored start inside the body keeps its settings where it wrote them. -On import, a timer-started event sub-process's own `exclusive`, `jobPriority` or `retryCycle` is dropped with a warning, since that copy reaches only its continuation job and the head's spelling is the start's. -Every other host-less handler keeps writing its settings on the event sub-process, as before. - -### Consequences - -- Good, because `exclusive: false` on a timer start, a hosted timer, an `await timer` and a host-less timer handler now configures the job the timer creates. -- Good, because a host-less `on timer` with `jobPriority` or `retryCycle` deploys a job that carries them. -- Bad, because a timer carrier with an async flag writes `operaton:exclusive` twice, and a document that sets the two apart imports with one of them changed and a warning. - -### Confirmation - -`packages/transform/test/operaton-moddle.test.ts` pins the typed attribute on the definition and its omission at the default. -`packages/transform/test/ir-to-xml.test.ts` pins the definition and the tag both carrying the lock on a boundary timer and an await, and neither when none is written. -`packages/transform/test/xml-to-ir.test.ts` pins the definition's value on a boundary and an event sub-process start, the tag's as the fallback, the one warning when the two differ, and the warning under which a timer-started event sub-process's own timer job key is dropped. -`packages/transform/test/ast-to-ir.test.ts` and `packages/transform/test/ir-to-dsl.test.ts` pin the three settings on the synthesized start, the async flags on the sub-process, the lifted head, and the authored start keeping its own line. -The frozen pair `tests/golden/engine-attributes.{bpmnscript,bpmn}` carries the lock on the hosted timer's definition. - -## More Information - -Amends ADR-0022, whose rule that a flow node's settings are written on that node now excepts the timer job's lock, which is written on the definition too. -Amends ADR-0023, whose rule that a host-less handler's attributes go on the sub-process now excepts a timer handler's three timer job settings. -Operaton behaviour was read from `BpmnParse.parseTimer`, `BpmnParse.parseAsynchronousContinuation`, `BpmnParse.createActivityOnScope` and `DefaultFailedJobParseListener.parseStartEvent`. - -Related decisions: ADR-0017 (the timer clause), ADR-0019 (the hosted form), ADR-0040 (engine settings on synthesized gateways). diff --git a/docs/decisions/0049-import-modeler-shaped-documents.md b/docs/decisions/0049-import-modeler-shaped-documents.md deleted file mode 100644 index d8b2f73..0000000 --- a/docs/decisions/0049-import-modeler-shaped-documents.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -status: accepted -date: 2026-09-14 -decision-makers: Marlon Kranz ---- - -# Import Modeler-shaped documents as the engine deploys them - -## Context and Problem Statement - -Camunda Modeler writes a `bpmn:collaboration` with one participant for any diagram that has a pool or a lane, an `isExecutable="false"` process for every empty pool it draws, and `xmlns:camunda` on every export. -The Operaton invoice example and four of the MIWG reference files have exactly this shape, and this tool refused all of them as "multiple linked processes". -Operaton does not read the document that way. -`BpmnParse.parseRootElement` runs `parseProcessDefinitions`, which deploys every process whose `isExecutable` attribute parses as true, and then `parseCollaboration`, which records each participant's `processRef` so that `parseBPMNShape` can attach the pool's bounds to the process. -No method in `BpmnParse` reads a `bpmn:messageFlow`. -A collaboration is diagram data to the engine, and the one question that decides what runs is how many processes deploy. -An absent `isExecutable` deploys nothing: `parseProcessDefinitions` defaults it to `!deployment.isNew()`, which is false for every deployment of a new resource, while this tool imported such a process as executable and its README said the engine reads it as executable too. -The same audit found document shapes the engine refuses before `BpmnParse` sees an element. -`BpmnParse` sets `BPMN20.xsd` as the schema and `Parse.execute` runs a validating SAX parse, so a duplicate `xs:ID` fails. -`Parser.setXxeProcessing` sets `disallow-doctype-decl` unless XXE processing is enabled, so a `` fails. -This tool saw neither: moddle-xml drops an element whose id repeats or falls outside its ASCII pattern with a warning, which the importer reported as extra engine configuration or as a dangling reference on an innocent flow. -saxen skips a DOCTYPE and keeps an undeclared entity as literal text. -`BpmnParse.parseImports` fails the deployment on every `bpmn:import` but a WSDL one, and `parseSignals` fails it on two signal roots of one name. -`isSequential` and `triggeredByEvent` are typed `xsd:boolean`, so `Parse.execute`'s validating parse admits only `true`, `false`, `1` or `0` and fails the deployment on any other word, where moddle reads `yes` as false. -What should the importer do with a document whose shape the engine reads differently from this tool? - -## Decision Drivers - -- ADR-0014's contract: what the engine refuses to deploy is refused on import, what it deploys and this surface cannot spell is dropped with a warning, and nothing changes silently. -- The Modeler's default output has to import, since it is most real input. -- One fact about the engine lives in one place, in the message or the comment beside the check, naming the method that reads it. - -## Considered Options - -- Import the one executable process, warn per pool and message flow, refuse the document shapes the engine refuses -- Keep refusing every collaboration and every multi-process document -- Write the pool back on export - -## Decision Outcome - -Chosen option: import the one executable process, warn per pool and message flow, refuse the document shapes the engine refuses, because it is the only option under which the importer accepts what the engine deploys and nothing else. - -`selectProcess` imports the one process marked `isExecutable="true"` and pushes one warning per other process, naming it and that `parseProcessDefinitions` does not deploy it either. -Two or more executable processes refuse with `UnsupportedCollaborationError`, which names that one shape alone. -Several processes of which none is executable refuse with `UnsupportedDocumentError`, since the engine deploys none of them. -A lone process without the attribute imports with a warning that says the engine skips it in a new deployment and that this tool writes it back as executable. -`warnCollaborationDrops` pushes one warning per participant, saying whether it names the imported process, a skipped one, or none, and one per message flow with its ends; both say the process runs identically and the document written back has no pool. -Lanes keep their existing warnings. -An empty process refuses with `UnsupportedEventFeatureError`, naming that `BpmnParse.parseStartEvents` fails the deployment on a process or subprocess with no start event. - -The camunda namespace is read as the operaton one by replacing the URI in the document text before `moddle.fromXML`, with one warning per document. -`BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` falls back to the camunda URI only when the operaton lookup finds nothing, so the engine reads a `camunda:taskListener` exactly as an `operaton:taskListener`. -moddle-xml binds a prefix to a package by URI, not by spelling, so after the swap every `camunda:` element arrives typed `operaton:*` and every `camunda:` attribute reads through the one `operaton:` reader. -A second moddle package would key every consumption table twice, and a prefix rewrite could hit a prefix spelled inside attribute text; the URI cannot appear there. - -`refuseDocumentShapes` runs on the raw text before parsing, on a copy with comments and CDATA sections cut out, since a script body may hold `&name;` or ``, an entity XML does not predefine, and an `isSequential` or `triggeredByEvent` value outside `true` and `false`, naming `Parse.execute`'s validating parse against `BPMN20.xsd` as the reason. -After parsing, a moddle warning for a duplicate or an illegal id refuses with the id named and the rename as the remedy, before any reference could dangle on it. -A `bpmn:import` refuses naming its type, and two signal roots of one name refuse naming both. -The DOCTYPE, entity, boolean, id, and import refusals are one `UnsupportedDocumentError`, so the CLI exits 1 for them like for every other refusal. - -### Consequences - -- Good, because the Modeler's default collaboration file, the Operaton invoice example, and the MIWG files with one executable process import. -- Good, because a file the engine refuses at schema validation or at the DOCTYPE no longer imports, prints, and rebuilds into a different document that deploys. -- Good, because a process the engine would not deploy is named in a warning rather than turned into one that runs. -- Bad, because the document written back has no pool, so a file that opened in the Modeler as a pooled diagram opens as a plain process after the round trip. -- Bad, because an id the schema admits and the engine deploys, such as one with an umlaut, is refused until renamed, since moddle-xml cannot read it. - -### Confirmation - -The table `xmlToIr: the document level` in `packages/transform/test/xml-to-ir.test.ts` pins every import row with its whole warning list and every refusal with its class and detail. -The camunda rows sit in the `camunda: prefix alias` describe of the same file. - -## Pros and Cons of the Options - -### Import the one executable process, warn per pool and message flow, refuse the document shapes the engine refuses - -- Good, because the import accepts exactly what the engine deploys. -- Bad, because the pool is lost on the round trip, and the warning has to say so. - -### Keep refusing every collaboration and every multi-process document - -- Good, because nothing is dropped. -- Bad, because the refusal blocks most real input on a shape the engine ignores, and its message called a lone pool "multiple linked processes". - -### Write the pool back on export - -- Good, because the round trip would keep the diagram's frame. -- Bad, because it needs a `bpmn:collaboration`, a participant shape whose bounds enclose every laid-out element, and a plane over the collaboration, none of which `bpmn-auto-layout` produces, for a frame the engine never reads. - -## More Information - -The document-level checks sit in `refuseDocumentShapes`, `refuseDroppedIds`, `refuseImports`, `selectProcess`, and `warnCollaborationDrops` in `packages/transform/src/xml-to-ir.ts`; the namespace swap sits at the top of `xmlToIr`. - -Amends ADR-0014, whose `UnsupportedCollaborationError` bullet narrows to two or more executable processes and whose `isExecutable="false"` bullet gains the absent attribute. - -Related decisions: ADR-0003 (diagram interchange is regenerated, which is why the pool has no export side). -ADR-0014 (the honest import contract behind every refusal and warning here). diff --git a/docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md b/docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md deleted file mode 100644 index 91835dc..0000000 --- a/docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -status: accepted -date: 2026-09-14 -decision-makers: Marlon Kranz ---- - -# Reserve the minted start and end ids exactly, and respell an id the script cannot spell - -## Context and Problem Statement - -The compiler mints an implicit start and end for every container it lowers: `StartEvent_` and `EndEvent_`, plus `EndEvent_` for a boundary escape, resolved against a process-wide taken set (ADR-0010). -The printer leaves those out again, since the compiler re-derives them, and the validator refuses a statement written under one, since the compiler would mint it a second time. -Both sides read the prefixes `StartEvent_` and `EndEvent_` for that, not the ids the compiler mints. -The Modeler names its default start `StartEvent_1`, and 25 of 44 real exported diagrams sampled from public example repositories carry that id. -Under the prefix rule such a start is either left out of the script with its label and its `initiator`, or printed and refused when the script is read back. -Renaming it is not a fix the user can apply without cost: the engine keys history (`HistoricActivityInstance.getActivityId`), migration plans (`MigrationPlanBuilder.mapActivities`) and a modification (`InstantiationBuilder.startBeforeActivity`) on the activity id. - -A second defect sits one step over. -A BPMN id is an `xs:ID`, and the script's name slot is the `ID` terminal: letters, digits and `_`, a `-` between them, and no keyword. -An id such as `Task.1`, `user`, `Review-` or `WFP-6-` (the MIWG reference models) is a legal id the script cannot spell, and the printer wrote it verbatim into source that does not parse, with no warning. -Only error and escalation codes went through a minting step. -A form field id or an `operaton:inputParameter` name outside the terminal is the same defect one level down: `input my.param = ...` does not parse either. - -What should the validator reserve, what should the printer elide, and what should happen to an id the script cannot spell? - -## Decision Drivers - -- The goldens: every start and end the compiler mints is of the exact form `StartEvent_`, `EndEvent_` or `EndEvent_`, so a rule that names those forms keeps every golden byte-identical. -- ADR-0014's contract: a document that deploys imports, and every change to what runs is reported. -- An activity id is the key the engine's history and migration match on, so a changed id is a changed process, and the report has to say so. -- One fact, one home: the shape of a name is the `ID` terminal, and the transform spells it once for both directions. - -## Considered Options - -- Reserve the exact minted ids and respell an unspellable id on print, with one warning per rename -- Keep the prefix rule and rename a Modeler default on import -- Refuse every id the script cannot spell - -## Decision Outcome - -Chosen option: reserve the exact minted ids and respell an unspellable id on print, because the exact forms are the only ids the compiler mints and the respelling keeps every document importable while reporting the one change it makes. - -The validator reserves, for each statement, the exact `StartEvent_` and `EndEvent_` of the container `c` whose body holds it: the process, or the nearest `subprocess` or `attempt` block. -The message says the compiler generates that id for the container's implicit start or end. -The prefix patterns for gateways, flows, throws, catches, boundaries and event sub-processes stay as they were. -`StartEvent_1` is an ordinary name. -A handler body's own implicit start and end, and a boundary escape's end, are minted off an id no author writes, so the validator reserves those three forms by prefix instead: `StartEvent_EventSubProcess_`, `EndEvent_EventSubProcess_` and `EndEvent_Boundary_`. - -The printer recognizes a minted start or end by the same exact forms, plus `EndEvent_` for a boundary in the same container and the `Throw_` prefix an unnamed throw lowers to. -Only those are left out; a start or end under any other id prints with its label, its documentation and its initiator. - -The printer builds one map per print, from the process id and every element id that prints, to the name each is written under. -An id the `ID` terminal accepts maps to itself. -Any other maps to the id with every non-word character replaced by `_`, a leading `_` where the result would open on a digit or read as a keyword, resolved against every id in the document and every name minted before it. -Every site that writes an id reads the map: the process head, every statement head, a `goto`, a boundary's host, and a named throw or catch. -A gateway, a boundary and an event sub-process never write their id and keep it. -Each rename draws one `renamedId` warning naming the printed name and saying that the rebuilt document carries it as the activity id. - -A form field id or an `operaton:inputParameter`/`operaton:outputParameter` name outside the terminal refuses the import. -The engine sets the variable under that exact name (`FormFieldHandler.handleSubmit`, `InputParameter.execute`, `OutputParameter.execute`), so a minted name would change which variable is set rather than how it is written. - -### Consequences - -- Good, because a Modeler default diagram imports, prints and rebuilds with its start's id, label and initiator intact. -- Good, because a MIWG reference model prints as source that parses and validates, with one report per id it could not keep. -- Good, because every golden is byte-identical: the exact forms are the ones the compiler already wrote. -- Bad, because a respelled id is a changed activity id in the rebuilt document, so a process rebuilt from such a script is not the process the history and migration APIs know; the warning says so, and the remedy is a rename in the model. -- Bad, because a statement written under another container's minted id, such as `user EndEvent_S` beside `subprocess S`, is legal and makes the compiler mint `EndEvent_S_2` for `S`, which the printer then writes as an ordinary `end EndEvent_S_2`; the round trip stays stable from the second print on, and nothing runs differently. - -### Confirmation - -The reserved-name table in `packages/language/test/validating.test.ts` pins the exact rule per container and the freed `StartEvent_1`. -The respelling table in `packages/transform/test/ir-to-dsl.test.ts` prints every id site under a minted name, asserts the whole warning list, and re-parses and validates the source; the Modeler-default row asserts the printed start, end and initiator with no warning. -`packages/transform/test/synthesize-ids.test.ts` pins the exact predicates and the minting scheme. -The refusal matrix in `packages/transform/test/xml-to-ir.test.ts` pins the two import refusals. -The golden pair suites pass unchanged. - -## Pros and Cons of the Options - -### Reserve the exact minted ids and respell an unspellable id on print - -- Good, because it matches what the compiler mints, and no golden changes. -- Good, because every legal document prints as source that compiles. -- Bad, because an author can write a name the compiler would otherwise have minted for a sibling container, and the compiler takes the next free suffix rather than refusing. - -### Keep the prefix rule and rename a Modeler default on import - -- Good, because the printer and validator stay as they were. -- Bad, because the rename changes the activity id of the one element every Modeler diagram has, and a rule that renames a default is a rule that renames most imports. - -### Refuse every id the script cannot spell - -- Good, because nothing prints under a name the model does not carry. -- Bad, because the MIWG reference models and any diagram with a dotted or hyphen-terminated id would not import at all, although the engine deploys them. - -## More Information - -The exact predicates and the minting scheme sit in `packages/transform/src/synthesize-ids.ts`; the name map is built by `printedNames` in `packages/transform/src/ir-to-dsl.ts`; the per-container check is `checkReservedNames` in `packages/language/src/bpmn-script-validator.ts`; the two import refusals go through `refuseUnspellableVariable` in `packages/transform/src/xml-to-ir.ts`. - -Amends ADR-0010, whose reserved-pattern list narrows to the prefixes above and the exact minted forms. -Amends ADR-0014, whose label bullet names the exact minted ids rather than a prefix, whose warned list gains the `renamedId` respelling, and whose refused list gains a form field id or an input/output parameter name the script cannot spell. - -Related decisions: ADR-0014 (the honest import contract behind the refusals and the warning). -ADR-0036 (where an elided end prints under its id, which this decision leaves as it is). diff --git a/docs/decisions/0052-loop-bodies-with-conditioned-exits.md b/docs/decisions/0052-loop-bodies-with-conditioned-exits.md deleted file mode 100644 index 4a4ad69..0000000 --- a/docs/decisions/0052-loop-bodies-with-conditioned-exits.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -status: accepted -date: 2026-09-14 -decision-makers: Marlon Kranz ---- - -# A split inside a loop body continues at the loop head when a route stays inside - -## Context and Problem Statement - -The catalog in ADR-0009 folds an exclusive split into an `if` chain when it has a clean join, a post-dominator the split dominates, and into a guard clause when exactly one route is unconditioned, that route being the continuation. -A split inside a loop body has neither when every route carries a condition. -A route that leaves the loop puts the split's immediate post-dominator outside it, at the graph's exit, or at a node behind the loop when the leaving route and the loop exit share an end, so there is no clean join, and with no unconditioned route there is no guard clause. -Every route then printed as a jump. -The approve-review loop of the Operaton invoice example is this shape: `approveInvoice` splits under `${approved}` to the bank transfer, which leaves the loop, and under `${!approved}` to `reviewInvoice`, which runs into the "review successful" gateway that routes back under `${clarified}`. -The printer recognized the `do ... while (clarified)` head, printed both routes as `goto`, hoisted `reviewInvoice` out of the loop after the process's end, and had no name for its edge into the folded loop gateway, so the edge was dropped with a marker. -`ExclusiveGatewayActivityBehavior.doLeave` takes the first route whose condition holds, so the model is a live loop and the rebuilt process ended after the review. -The same shape with a default flow in place of `${!approved}` printed correctly through the guard clause, which was the asymmetry to remove. -Where should a split whose every route is conditioned continue when it sits inside a loop body or a branch? - -## Decision Drivers - -- The totality guarantee in ADR-0009: every route keeps a form, and a jump is the fallback for what the catalog cannot fold, never the first answer for a shape it can. -- A modeler draws a condition on every route and names no default, so the shape is the common one, and the invoice example the thesis demonstrates on is an instance of it. -- The guard clause already continues a split at a node the split does not dominate; the rule should extend that rather than add a second continuation mechanism. - -## Considered Options - -- Continue at the enclosing construct's stop node when a route stays inside it -- Keep every such split as jumps and report it -- Recover the loop body as a region of its own before matching the split - -## Decision Outcome - -Chosen option: continue at the enclosing construct's stop node when a route stays inside it, because the construct being printed already knows where its region ends, and the dominator queries the clean join uses answer whether a route reaches it. - -`emitRoutes` asks for the join in three steps: the clean join, then the guard clause, then the enclosing continuation. -The clean join is refused when the enclosing construct's stop node does not post-dominate it. -Inside a `do` body the body dominates every node behind the loop, so a join behind the loop passes the dominance checks whenever the leaving route and the loop exit share an end, and without the refusal the staying route would be walked past the printed loop head and the back edge dropped. -The enclosing continuation is the stop node the enclosing construct handed down. -It answers only while that node is defined, no route is unconditioned, and at least one route's target is dominated by the split and post-dominated by the stop node. -The chain then walks that route inline, since `branchStaysInRegion` already accepts a target the join post-dominates, and prints the routes that leave the region as jumps. -The walk stops at the stop node, where the enclosing construct carries on. -With an unconditioned route the guard clause still answers first, and outside any enclosing construct the jumps stay, so a split at process level whose every route is conditioned prints as before. - -The chain closes with no `else`, so the recompiled split gains a fallback into the join that the model never named; the print reports it, as it does for the same chain at process level. -The loop gateway's own exit condition, `${!clarified}` in the example, prints as the fall-through after `} while (clarified)` and is reported as dropped. - -The `review-loop` golden pair freezes the invoice example's shape. -Its split carries a default flow to the join, which the compiled else-less chain gives it and which a hand-drawn original lacks, so the modeler's shape, with a condition on every route and no default anywhere, is pinned in the goto-fallback suite. - -### Consequences - -- Good, because the invoice example prints its review call inside the loop and the rebuilt process keeps the loop. -- Good, because a cascade of such splits inside one body prints as nested chains, each continuing at the same stop node. -- Good, because the rule reads the model through the dominator queries the clean join already uses and adds no state to the walk. -- Neutral, because a route running straight into the stop node prints as an empty branch, which the validator warns about; the condition on it survives, where a jump would have lost the branch. -- Bad, because a branch that ends inside a loop body, a `do { end } while`, and a jump into a race branch still print as jumps or markers, and stay reported as they are. - -### Confirmation - -The table in `packages/transform/test/ir-to-dsl.test.ts` under "a split inside a loop body whose every route is conditioned" pins the printed source for one and for two staying routes, and for a leaving route that shares its end with the loop exit, and the whole warning list. -`tests/goto-fallback.round-trip.test.ts` pins the modeler's shape through the XML hop: no marker, and the real-node reachability of the recompiled print equal to the import's plus the two pairs the invented fallback adds. -The `review-loop` round-trip suite pins the frozen artifact byte for byte, its import without a warning, and the chain inside the `do` body. - -## Pros and Cons of the Options - -### Continue at the enclosing construct's stop node when a route stays inside it - -- Good, because the stop node is already threaded through every emitter, so the rule is one arm in `emitRoutes` and one predicate beside `guardClauseContinuation`. -- Good, because the same rule serves a split inside a `while` body, a `do` body, and a branch. -- Bad, because a target the stop node post-dominates but the split does not dominate is walked inline by the chain all the same, the way the guard clause walks it, so a target reached from elsewhere leaves its other edges as jumps. - -### Keep every such split as jumps and report it - -- Good, because nothing changes and the print reports the drop. -- Bad, because the shape is the common one, and the process the thesis demonstrates on came back running differently. - -### Recover the loop body as a region of its own before matching the split - -- Good, because a region tree would answer the question for every construct at once. -- Bad, because it is the RPST decomposition ADR-0009 left for later, and the scope does not yet justify it. - -## More Information - -The predicate is `enclosingContinuation` in `packages/transform/src/ir-to-dsl.ts`; the containment check is in `cleanJoin` beside it. -`emitRoutes` asks a further step between the clean join and the guard clause, `convergence`, which reads where the split's live routes come back together off the model's routes by forward reachability bounded by the stop node, and so answers a split whose branch can end and whose post-dominator therefore lies at the exit. - -Amends ADR-0009, whose catalog gains the enclosing continuation beside the clean join and the guard clause. -Amends ADR-0014, whose print hop now walks the route that stays inside a loop body inline and prints the leaving routes alone as jumps, for a split inside a loop body whose every route is conditioned. - -Related decisions: ADR-0009 (the catalog this rule extends). -ADR-0036 (the inline terminal in a guard clause, whose region test the chain reuses). diff --git a/docs/decisions/0053-routes-off-a-step-and-jumps-with-conditions.md b/docs/decisions/0053-routes-off-a-step-and-jumps-with-conditions.md deleted file mode 100644 index 32e0fab..0000000 --- a/docs/decisions/0053-routes-off-a-step-and-jumps-with-conditions.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -status: accepted -date: 2026-09-15 -decision-makers: Marlon Kranz ---- - -# A step's routes print as the fork the engine runs, and a degraded split keeps its conditions on its jumps - -## Context and Problem Statement - -A BPMN step may leave on several sequence flows, and the catalog in ADR-0009 printed them as an `if` chain, a choice that takes one of them. -The engine takes more than one. -`BpmnActivityBehavior.performOutgoingBehavior` leaves a step by every flow whose condition holds or that carries none, and by the flow the step names as `default` only when none was taken, so two plain flows are a parallel fork and a conditioned flow beside a plain one is an inclusive fork, the rule `InclusiveGatewayActivityBehavior.execute` applies to a gateway of that kind. -The printer had the exact forms, `parallel { { B } { C } }` and `parallel { if (ok) { B } { C } }`, and wrote `if (true) { B } else { C }` with a report instead. -The import now carries a step's `default` (`defaultFlowId` on every activity kind), which the report used to say it could not see. - -Three neighbouring shapes had no report or a poor one. -A fork or race the catalog cannot fold printed every route as an unweighed jump, `if (true) { goto A } else if (true) { goto B }`, so the conditions the routes carried were discarded and the script held no marker for the reader to find. -A parallel, inclusive or event-based gateway with no outgoing flow printed nothing, although the inclusive one throws `stuckExecutionException` in the model and the event-based one waits forever. -A gateway whose route returns to itself matched the loop patterns with the exit route as the body, so `while (go) { user A }` came out of a model that loops on the gateway until `go` holds and runs `A` once. -A jump written into a branch of an `await` or `parallel` block from outside it is refused by the validator and nothing in the print said so. -A guard clause whose branch opens on a second split dropped the edge into that split with a marker, because the branch walk admitted only terminals as guard-clause entries and a jump cannot name a gateway. - -## Decision Drivers - -- The totality guarantee in ADR-0009: every route keeps a form, and a jump is the fallback for what the catalog cannot fold, never the first answer for a shape it can. -- A report describes what runs differently and nothing else; a print that runs the same as the model draws none. -- What the marker and the warnings channel say has to be enough to start hand-repair, so a lost split names itself in the source. - -## Considered Options - -- Print a step's routes through the fork block with the step as the split, and keep the conditions on the jumps of a degraded split -- Keep the `if` chain for a step's routes and the unweighed jumps, and sharpen the reports -- Refuse a step with several routes on import - -## Decision Outcome - -Chosen option: print a step's routes through the fork block with the step as the split, and keep the conditions on the jumps of a degraded split, because both are exact forms the printer already had, and the engine's rule for a step is the inclusive fork's rule. - -`followLinear` reads the step's routes off the model. -Two or more hand the step to the fork printer as its split: inclusive when any route carries a condition or the step names a `default`, parallel otherwise, with the `default` as the block's `else`. -The one shape a choice routes the same way, exactly one weighed route beside the `default`, keeps the `if`/`else` the choice prints, which reads better and lowers to the same routing. -The fork printer is keyed on a split's id and kind rather than on a gateway, so a step's block takes the merge's `join` settings and no head settings of its own. -A step whose fallback carries a condition is reported the way an inclusive fork's is: the engine skips the fallback while weighing and takes it when nothing held, so the condition is weighed nowhere and the run is the same without it. -A step whose every route is weighed and which names no fallback draws the invented-fallback report a split of that shape draws. - -A fork or race the catalog cannot fold prints one marker line naming the split and its kind, `split G degraded to jumps; was inclusive`, and then the `if` chain of jumps with each route's condition on its own jump and the split's fallback as the `else`. -The report says the conditions are on the jumps and that the split's kind, whether it opened one branch, every branch whose condition held, or the first to resolve, is written nowhere. -An exclusive split with no clean join prints its jumps as it did, with no marker: an `if` chain of jumps is a choice, so nothing of it is lost. - -A gateway with no route out prints nothing and is reported by kind: a parallel gateway ends the run as the script's block end does (`ParallelGatewayActivityBehavior.execute` leaves by no route and `PvmExecutionImpl.leaveActivityViaTransitions` ends the execution); an inclusive gateway stops the model with an error (`InclusiveGatewayActivityBehavior.execute` throws `stuckExecutionException`); an event-based gateway holds the model forever (`EventBasedGatewayActivityBehavior.execute` is a wait state with nothing to wait for); an exclusive gateway keeps the model from deploying (`BpmnParse.validateExclusiveGateway`), which the import refuses ahead of the printer, so the print report covers hand-built IR alone. - -The loop patterns ignore a back edge whose source is its target. -The gateway then prints as a choice whose route back into itself has no name, so it takes the dropped-edge marker and its report, and the exit route keeps its condition. - -The branch walk records the `await` or `parallel` branch each statement is printed in, as the path of branch ids from the outermost block down, and every jump records the path it was printed under. -Once the walk is done, a jump whose target sits in a branch the jump's path does not begin with draws a report under `refusedStatement`: the validator refuses a `goto` into a branch from outside it, since a branch's steps run only when the whole block is reached. -The jump is written all the same; the model is where the route comes from. - -A guard clause admits a gateway the split dominates as its branch entry. -A jump could not name it anyway, and the branch walk sorts the gateway's routes with the guard's continuation as their stop node, so a split whose every route is weighed inside a guarded branch continues at that node the way ADR-0052 describes, and a loop that opens a guarded branch prints as the loop it is. - -An event sub-process a flow edge leads into is malformed IR and the printer throws, as it does on a duplicate id; the import refuses the flow ahead of it. - -### Consequences - -- Good, because a step with two plain routes or with a weighed route beside a plain one round-trips to the routing the engine runs, with no report. -- Good, because the conditions of a degraded split survive on its jumps, and the marker line puts the reader at the split rather than leaving the jumps to explain themselves. -- Good, because the fork printer, the invented-fallback report and the fallback-condition report each have one home, and a gateway and a step share it. -- Neutral, because a step whose routes reconverge at a merge of another kind, or at a step, degrades to jumps under the marker: the block form synchronizes at a merge of its own kind, which the model does not have there. -- Bad, because a branch that ends inside a loop body, a `do { end } while`, and a loop gateway the compiler leaves without an incoming flow still print as jumps, and a post-test loop whose body ends inside a branch prints the jump into that branch the validator refuses; these are reported and pinned as they are. - -### Confirmation - -The table in `packages/transform/test/ir-to-dsl.test.ts` under "a step whose own routes split" pins the printed source, the whole warning list, the re-parse and the real-node reachability for two plain routes, two plain routes into an implicit merge, a weighed route beside a plain one, one weighed route beside the fallback, a weighed route beside a plain one and the fallback, and two weighed routes with no fallback. -Beside it, one row pins the marker line and the weighed jumps of an inclusive fork whose merge a boundary chain also enters, one row per gateway kind pins the empty-split report, two rows pin the self-looping gateway, two the jump across a branch border, and one the guard clause opening on a split. -`tests/goto-fallback.round-trip.test.ts` runs the composite shapes a fuzz run drew through compile, export, import and print, and pins each print's warning categories and whether it validates. - -## Pros and Cons of the Options - -### Print a step's routes through the fork block with the step as the split, and keep the conditions on the jumps of a degraded split - -- Good, because the fork block already prints both kinds and both fallback reports, so the step gains them by naming itself as the split. -- Good, because a jump chain that weighs its routes is the same chain the choice prints, so the degraded print gains no new form. -- Bad, because a step whose routes merge at a merge of another kind now prints jumps under a marker where it printed a readable `if` chain, which was a choice the model is not. - -### Keep the `if` chain for a step's routes and the unweighed jumps, and sharpen the reports - -- Good, because nothing in the print changes. -- Bad, because the print of a common Modeler shape, a task with two plain flows, runs one route where the model runs both, and the exact form was one line away. - -### Refuse a step with several routes on import - -- Good, because the printer never sees the shape. -- Bad, because the engine deploys and runs it, and the import contract in ADR-0014 refuses only what the engine refuses or what changes a stored value. - -## More Information - -The step's routing is `followLinear` in `packages/transform/src/ir-to-dsl.ts`; the fork block is `emitFork`, the jumps `emitJumps`, the empty-split report `emptySplitWarning`, the branch bookkeeping `emitBranch` and the check at the end of `Emitter.emit`, and the guard-clause entry `branchStaysInRegion`. - -Amends ADR-0009, whose catalog entry for a statement with more than one route out becomes the fork block, and whose degraded split keeps its conditions on the jumps under a marker line. -Amends ADR-0014, whose step-`default` bullet this rule closes by printing the step's routes as the fork block with the `default` as its `else`, and whose print-hop reports gain a degraded split's marker line, an empty gateway, a self-loop, and a jump across a branch border. - -Related decisions: ADR-0009 (the catalog this rule extends). -ADR-0014 (the import contract, whose `default`-on-a-step clause this rule closes). -ADR-0045 (the fallback the compiler reserves on a fork, which the printed block of a step's routes reads back through). -ADR-0052 (the enclosing continuation, which a split inside a guarded branch now reaches). diff --git a/docs/decisions/0054-import-tiers-follow-the-engine-parse.md b/docs/decisions/0054-import-tiers-follow-the-engine-parse.md deleted file mode 100644 index b4a1de6..0000000 --- a/docs/decisions/0054-import-tiers-follow-the-engine-parse.md +++ /dev/null @@ -1,146 +0,0 @@ ---- -status: accepted -date: 2026-09-15 -decision-makers: Marlon Kranz ---- - -# Import tiers follow the engine's parse - -## Context and Problem Statement - -ADR-0014 draws two tiers: what changes execution refuses, what does not warns. -It left the placement of each construct to the reader of the IR, and an audit of the importer against `BpmnParse` found the two disagreeing in both directions. -The importer refused what the engine deploys: a second listener on one event, a listener naming two bindings, a text beside a nested parameter value, an unknown binding word on a call activity, an `operaton:in` naming two source shapes, a throw whose `errorRef` names no root, an intermediate throw with no definition. -It carried what the engine refuses: an exclusive gateway with two plain routes, an `operaton:inputOutput` on a start event, a second `operaton:formData` on one task, a `jobPriority` of `1.5`, a sub-process with no start, two plain starts on the process. -And it dropped what the engine reads with a note that this tool "keeps" the very category being dropped: an execution listener on a gateway, an `operaton:in` on a thrown signal, a `formKey` on the process's start, a step's `default`. -Where should each tier sit, and what must a warning say when the dropped setting is one the engine runs? - -## Decision Drivers - -- ADR-0014's own claim: no silent semantic loss, and no refusal of a file the engine deploys. -- The engine's parser is the one reader whose verdict decides whether a document runs, so it is the one reference for where a tier sits. -- A warning that names what the engine does with the dropped setting is the only form under which a reader can tell a cosmetic drop from one that changes a run. -- The Camunda Modeler's default output, and the Operaton invoice example, have to import. - -## Considered Options - -- Draw each tier where `BpmnParse` draws it, and name the engine method in every refusal and every drop of a setting the engine reads -- Keep the tiers as the surface drew them and correct the wording alone -- Refuse every setting the engine reads that this surface cannot spell - -## Decision Outcome - -Chosen option: draw each tier where `BpmnParse` draws it, because it is the only option under which the importer accepts exactly what the engine deploys and says what the engine does with what it drops. - -The rule has three clauses. -A shape `BpmnParse` fails the deployment on refuses, and the message names the method and quotes its sentence, since there is nothing that runs to write back. -A shape the engine deploys and this surface can spell is carried, with a warning where the spelling changes. -A shape the engine deploys and this surface cannot spell drops with a warning that names the method that reads it and says the document written back runs without it. -It refuses only where the drop changes a value the engine stores, which is a thrown message's result variable beside an expression binding. - -The rows below are the tier moves, one per shape, with the engine method that decides it. - -| Shape | Engine method | Was | Now | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| `operaton:async="true"` on a job-setting owner | `BpmnParse.isAsyncBefore` | dropped as unread, with a warning | carried as `asyncBefore`, warning names the respelling | -| `jobPriority` or `taskPriority` neither an integer nor an expression | `BpmnParse.parsePriority` | carried as written | refused | -| `operaton:resultVariableName` | `BpmnParse.parseResultVariable` | dropped with a warning | carried as `resultVariable`, warning names the respelling | -| `resultVariable` beside an `external` or built-in `type` binding on a task | `BpmnParse.parseServiceTaskLike` | carried in silence | carried, warning says the binding never writes it | -| `operaton:decisionRefTenantId` | `BpmnParse.parseTenantId` | dropped with a warning | refused, as `calledElementTenantId` is | -| `calledElementBinding` or `decisionRefBinding` outside the four words | `BpmnParse.parseBinding`, `BaseCallableElement.isLatestBinding` | refused | carried as `latest`, warning names the rule | -| `operaton:in`/`out` naming two shapes (`source` beside `sourceExpression`, `variables="all"` beside either, a `businessKey` beside them, a second `businessKey`) | `BpmnParse.parseCallableElementProvider`, `parseInputParameter`, `setBusinessKeyValueProvider` | refused | the engine's pick carried, one warning per loser | -| `operaton:potentialStarter` on the process | `BpmnParse.parseStartAuthorization` | dropped as residual extension content | read onto the candidate starter lists, warning names the rewrite | -| A literal form default the field's type cannot convert | `LongFormType.convertValue`, `BooleanFormType.convertValue`, `DateFormType` | carried in silence | carried, warning says the printed script draws an error there | -| An empty `name`, `label`, `defaultValue`, or `operaton:assignee` | read as the empty text | read as absent | carried as `""`, so the second compile writes the same attribute | -| A script body holding three consecutive backticks | none; no fence can enclose it | printed into a script that does not parse | refused | -| A thrown message's `resultVariable` beside `class` or `delegateExpression` | `BpmnParse.parseServiceTaskLike` | dropped with a warning | refused | -| A thrown message's `resultVariable` beside `expression` | `BpmnParse.parseServiceTaskLike` into `ServiceTaskExpressionActivityBehavior` | dropped with a warning | refused; the engine stores the value and the throw has no slot | -| A thrown message's `resultVariable` beside `external` or no binding | `BpmnParse.parseServiceTaskLike` | dropped under a note that this tool keeps it | dropped, warning says no binding writes it | -| A thrown message's `taskPriority`, `operaton:properties`, `operaton:errorEventDefinition`, or injected field | `BpmnParse.parseExternalServiceTask`, `parseServiceTaskLike` | dropped under the same note | dropped, warning names the reader | -| A dangling `errorRef` on a catch | `BpmnParse.parseBoundaryErrorEventDefinition`, `parseErrorStartEventDefinition` | imported as a catch-all | imported as the code spelled by the text, with a warning | -| A dangling `errorRef` on an error end | `BpmnParse.parseEndEvents` | refused | imported as the code spelled by the text, with a warning | -| A dangling `escalationRef` on a catch | `BpmnParse.createEscalationEventDefinitionForEscalationHandler` | imported as a catch-all | refused | -| An absent or dangling `escalationRef` on a throw | `BpmnParse.findEscalationForEscalationEventDefinition` | imported as a catch-all | refused | -| An `operaton:errorEventDefinition` with no `errorRef` | `BpmnParse.parseOperatonErrorEventDefinitions` | refused | skipped with a warning, as the engine skips it | -| An `operaton:errorEventDefinition` with a dangling `errorRef` and an `expression` | `BpmnParse.parseOperatonErrorEventDefinitions` | refused | imported as the code spelled by the text, with a warning | -| `operaton:initiator` or `operaton:formData` on a start that is not the process's own | `BpmnParse.parseScopeStartEvent`, `parseStartFormHandlers` | carried | dropped, warning names the reader that never runs there | -| An intermediate throw with no event definition | `BpmnParse.parseIntermediateThrowEvent` into `IntermediateThrowNoneEventActivityBehavior` | refused | imported as a plain step, warning names the rewrite | -| `operaton:inputOutput` on a start event, a gateway, or an event handler | `BpmnParse.ensureNoIoMappingDefined`, `checkActivityInputOutputSupported` | dropped with a warning, or carried | refused | -| `operaton:outputParameter` on an end event | `BpmnParse.checkActivityOutputParameterSupported` | dropped with a warning | refused | -| A second `operaton:inputOutput`, `formData`, `properties`, or `failedJobRetryTimeCycle` on one element | `Element.elementNS` | first kept, rest warned | refused | -| A second listener on one event | `CoreModelElement.addListenerToMap`, `TaskDefinition.addTaskListener`, `addTimeoutTaskListener` | refused | carried in document order | -| A listener naming two or more bindings | `BpmnParse.parseExecutionListener`, `parseTaskListener` | refused | first in the engine's order kept, one warning per loser | -| An execution listener with `expression=""`, a task listener with `class=""`, `expression=""`, or `delegateExpression=""` | `ExpressionExecutionListener`, `ExpressionTaskListener`, `ClassDelegateTaskListener`, `DelegateExpressionTaskListener` | refused as naming no binding | carried, warning says the printed script draws an error there | -| An execution listener with `class=""` or `delegateExpression=""` | `BpmnParse.parseExecutionListener` | refused as naming no binding | refused quoting "cannot be empty" | -| A timer under a task listener that is not a `timeout` | `BpmnParse.parseTaskListener` | refused | dropped with a warning | -| Body text beside one nested value in a parameter | `BpmnParseUtil.parseNestedParamValueProvider` | refused | nested value kept, text warned | -| An exclusive gateway with no route, one conditioned route, a conditioned default, or a plain non-default route | `BpmnParse.validateExclusiveGateway` | imported and printed as `if (true)` | refused as `UnsupportedGatewayShapeError` | -| A condition typed through `xsi:type` as anything but `tFormalExpression` | `BpmnParse.parseConditionExpression` | imported | refused | -| A condition or completion condition body with no `${` or `#{` opener, or an empty one | `UelExpressionCondition.evaluate`, `MultiInstanceActivityBehavior.completionConditionSatisfied` | carried in silence | carried inside `${...}`, or as no condition when empty, with a warning | -| A sequence flow's `name` differing from the label the rebuilt document derives | none; the engine routes by condition | dropped in silence | dropped with a `label` warning | -| A `bpmn:subProcess` with no start event | `BpmnParse.parseStartEvents` | imported with a start added | refused | -| A `bpmn:process` with no start event | `BpmnParse.parseStartEvents` | imported with a warning | refused | -| A `bpmn:transaction` with no start event | `SubProcessActivityBehavior.execute` | imported with a start added, in silence | imported with a start added, with a warning | -| Two or more plain or timer starts on the process | `BpmnParse.selectInitial` | imported | refused | -| A sequence flow into or out of an event handler | `BpmnParse.parseSequenceFlow` | caught only through the element's own `incoming`/`outgoing` children | refused off the flow's ends | -| A `default` on a step naming one of its routes | `BpmnActivityBehavior.handleNoTransitions` | dropped with a warning | carried; a `default` naming a foreign flow still drops with a warning | -| A collection named in both spellings with different shapes | `MultiInstanceActivityBehavior.resolveNrOfInstances` | BPMN spelling kept | the expression kept, warning names the rule | -| A `language` on a `bpmn:completionCondition` | `BpmnParse.parseMultiInstanceLoopCharacteristics` | dropped in silence | dropped with a warning | - -Three rules stand behind the rows. - -Listeners repeat per event. -`CoreModelElement.addListenerToMap` and `TaskDefinition.addTaskListener` both append rather than replace, so several `start` listeners run in document order. -`BpmnParse.parseTaskListeners` routes a `timeout` listener to `TaskDefinition.addTimeoutTaskListener`, which keys it by its id, so two `timeout` listeners with distinct ids and timers are the ordinary reminder-then-escalate shape. -The validator's one-listener-per-event error goes, the importer keeps document order as the IR order, and a listener naming two bindings keeps the first in the order `parseExecutionListener` and `parseTaskListener` read them: `class`, `expression`, `delegateExpression`, then the `operaton:script` child. - -A thrown message is a service task to the engine. -`parseIntermediateThrowEvent` and `parseEndEvents` hand a message definition to `parseServiceTaskLike`, so off the definition the engine reads every binding word, the injected fields, the result variable, the priority, and the error mappings. -This surface's throw carries the binding alone, so each of the rest drops with a warning naming the reader. -The result variable is the exception: beside a `class` or `delegateExpression` the engine refuses it, and beside an `expression` the engine stores it where the throw cannot spell it, so both refuse. - -A drop of what the engine reads names its reader. -`warnUnimportedSetting` says that this tool reads no such setting on the owner's tag and that the document written back carries none, and where the engine does read it there, the sentence continues with the method and says the document written back runs without it. -The note that listed what this tool keeps is gone, since it named the very setting being dropped whenever that setting fell in one of its categories. - -### Consequences - -- Good, because a document the engine deploys imports, and one it refuses is refused with the engine's own sentence rather than imported into a script that cannot deploy. -- Good, because every warning on a setting the engine reads says which method reads it, so the reader knows whether the drop changes a run. -- Good, because the Modeler's default export and the invoice example, both refused before, import. -- Bad, because the engine's sentences are quoted in the importer and drift with the engine's wording. -- Bad, because a shape the engine deploys and then fails at run time, such as an empty listener `class` on a task, is carried into a script that draws an error at the step, which the reader has to repair by hand. - -### Confirmation - -The refusal and warning tables in `packages/transform/test/xml-to-ir.test.ts` pin every row above with the whole warning list or the refusal class and detail, and the audit reproductions each row came from are the fixtures. -`tests/review-loop.round-trip.test.ts` and the invoice fixture in `tests/` pin the Modeler-shaped documents end to end. - -## Pros and Cons of the Options - -### Draw each tier where `BpmnParse` draws it - -- Good, because the engine is the one reader whose verdict decides what runs, so the tiers stop being a second opinion. -- Bad, because every row needs the engine method read before its sentence is written. - -### Keep the tiers as the surface drew them and correct the wording alone - -- Good, because no import result changes and no test moves. -- Bad, because the importer would keep refusing files the engine deploys and carrying files it refuses, which ADR-0014 does not license. - -### Refuse every setting the engine reads that this surface cannot spell - -- Good, because nothing the engine runs is ever dropped. -- Bad, because a listener on a gateway or a `formKey` on a process start would refuse every Modeler export that carries one, and the drop costs the reader a warning, not a run. - -## More Information - -The checks sit in `packages/transform/src/xml-to-ir.ts`: `checkExclusiveGateways`, `checkStartEventCount`, `checkHandlerFlows`, `attachDefaultFlows`, `refuseIoMapping`, `onlyExtensionElement`, `resolveListenerBinding`, `readThrownMessageBinding`, and the `ENGINE_READS_ELSEWHERE` table behind `warnUnimportedSetting`. -The consumer-facing summary is the import contract section of `packages/transform/README.md`. - -Amends ADR-0014, whose `UnsupportedExtensionFormError`, `UnsupportedErrorMappingError`, and step-`default` bullets and whose gateway-listener line point here. -Amends ADR-0023 (listeners repeat per event), ADR-0033 (the call-mapping shapes that warn rather than refuse), ADR-0037 (form defaults the type cannot convert are carried), ADR-0038 (a thrown message's extras and the error-mapping shapes), ADR-0039 (an `operaton:potentialStarter` reads like a `bpmn:potentialOwner`), and ADR-0043 (the result-variable rules on a task and on a thrown message). - -Related decisions: ADR-0049 (the document-level refusals and the collaboration, decided on the same reading of the engine). -ADR-0048 (the validator's mirror of the same parse-time rules on the compile side). -ADR-0053 (how a carried step `default` prints). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 434facd..88f587e 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -1,64 +1,41 @@ # Architecture decision records Each file in this directory records one design decision behind BPMNscript: the problem, the options weighed, the choice, and what it costs. -Every record follows the MADR shape in [adr-template.md](adr-template.md). -A later decision that changes an earlier one says so with an `Amends ADR-XXXX` line in its "More Information" section, and the earlier one carries the matching `Amended by` line, so the two link both ways. -A `Supersedes ADR-XXXX` line pairs with a `Superseded by` line the same way. +Every record follows the MADR shape in [adr-template.md](adr-template.md), and states the decision as it stands today. +Where a record names what the engine does, that behaviour was read from Operaton 2.1.0. -| Number | Title | Status | -| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -------- | -| [0000](0000-use-markdown-architectural-decision-records.md) | Use Markdown Architectural Decision Records | accepted | -| [0001](0001-vscode-as-primary-ide-target.md) | Use VS Code as Primary IDE Target | accepted | -| [0002](0002-use-langium-as-language-workbench.md) | Use Langium as Language Workbench | accepted | -| [0003](0003-auto-layout-for-diagram-interchange.md) | Use Auto-layout for Diagram Interchange Data | accepted | -| [0004](0004-use-apache-2-license.md) | Use Apache 2.0 License | accepted | -| [0005](0005-use-mono-repo-structure.md) | Use Mono-repo Structure | accepted | -| [0006](0006-intermediate-representation-between-ast-and-bpmn.md) | Use an Intermediate Representation between the AST and BPMN XML | accepted | -| [0007](0007-operaton-moddle-extension-fork.md) | Fork the Camunda Moddle Extension as a Local Operaton Extension | accepted | -| [0008](0008-structured-grammar.md) | Use a Structured, Code-Like Grammar | accepted | -| [0009](0009-dominator-based-restructuring.md) | Use Dominator/Post-Dominator Analysis for IR-to-DSL Restructuring | accepted | -| [0010](0010-deterministic-structural-ids.md) | Use Deterministic Structural Ids for Synthesized BPMN Elements | accepted | -| [0011](0011-in-editor-conversion-sidebar.md) | In-Editor Conversion: Webview Sidebar, Two-Layer Architecture, and CJS Bundling | accepted | -| [0012](0012-embed-java-delegate-code-and-generate-operaton-application.md) | Embed Java Delegate Code and Generate the Operaton Application | rejected | -| [0013](0013-target-users-without-bpmn-knowledge.md) | Target Users Without BPMN Knowledge and Minimize Boilerplate | accepted | -| [0014](0014-honest-bpmn-import-contract.md) | Honest BPMN Import: Refuse Unsupported Constructs, Warn on Non-Semantic Drops | accepted | -| [0015](0015-subprocess-di-expansion-hint.md) | Author a Diagram-Interchange Expansion Hint for Sub-processes | accepted | -| [0016](0016-derived-event-root-elements.md) | Derive Event Root Elements From Usage: Syntactic Throw Terminality, Soft Trigger Words | accepted | -| [0017](0017-event-trigger-payload-surfaces.md) | Event Trigger Payloads: One Paren Slot, Validated Timer Particles, Deferred Conditional Narrowing, Name-Keyed Roots | accepted | -| [0018](0018-compensation-through-event-subprocesses.md) | Compensation Only as a Subprocess Undo Block: One Handler Form, No Targeted Throws | accepted | -| [0019](0019-boundary-events-attached-to-an-activity.md) | Boundary Events Attached to an Activity: Host Syntax, Trigger Scope, and a Self-Contained Body | accepted | -| [0020](0020-intermediate-catch-events.md) | Intermediate Catch Events: the `await` Keyword, a Synthesized Id, and a Four-Trigger Scope | accepted | -| [0021](0021-external-tasks-as-a-service-task-binding.md) | External Tasks as a Service Task Binding, Not a Separate Keyword | accepted | -| [0022](0022-engine-attributes-as-named-ir-fields.md) | Carry Operaton Engine Attributes as Named IR Fields | accepted | -| [0023](0023-listeners-on-the-attribute-block.md) | Listeners Reuse `on` Inside the Attribute Block, Not a Second Keyword | accepted | -| [0024](0024-event-triggers-on-start-end-and-throw.md) | Event Triggers at the Process Boundary: Four Start Kinds, `terminate` on `end`, and the Message End on `throw` | accepted | -| [0025](0025-authoring-inclusive-and-event-based-gateways.md) | Inclusive and Event-Based Gateways: Conditioned `parallel` Branches, a Multi-Branch `await`, and No Gateway Label | accepted | -| [0026](0026-task-kinds-on-the-authoring-surface.md) | Task Kinds on the Authoring Surface: `step`, `send`, `receive`, and `decide` | accepted | -| [0027](0027-repetition-on-the-authoring-surface.md) | Repetition on the Authoring Surface: the `for` Clause | accepted | -| [0028](0028-attempt-blocks-and-the-cancel-trigger.md) | Work That Can Be Given Up: the `attempt` Block and the `cancel` Trigger | accepted | -| [0029](0029-one-bracket-shape-for-every-element.md) | One bracket shape for every element | accepted | -| [0030](0030-error-and-escalation-codes-as-declared-names.md) | Error and escalation codes as declared, referenced names | accepted | -| [0031](0031-carry-bpmn-documentation.md) | Carry bpmn:documentation wherever a name is carried | accepted | -| [0032](0032-field-injection-as-a-parameter-direction.md) | Field Injection as a Third Parameter Direction, Form References on User Tasks Only | accepted | -| [0033](0033-call-activity-variable-mapping.md) | Carry a call activity's variable mapping under its own two setting keys | accepted | -| [0034](0034-multiple-start-events-on-a-process.md) | Multiple start events on a process | accepted | -| [0035](0035-link-events-for-import-round-trip-symmetry.md) | Link events for import round-trip symmetry | accepted | -| [0036](0036-elide-a-synthesized-end-only-at-its-block-tail.md) | Elide a synthesized end only at its block tail | accepted | -| [0037](0037-form-field-constraints-values-and-properties.md) | Form field constraints, values and properties | accepted | -| [0038](0038-external-task-extras-ride-the-topic-binding.md) | External task extras ride the topic binding | accepted | -| [0039](0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md) | Import BPMN resource assignment, and report the quantity attributes | accepted | -| [0040](0040-engine-settings-on-synthesized-gateways.md) | Engine settings on synthesized gateways | accepted | -| [0041](0041-per-run-job-settings-on-a-repetition.md) | Per-run job settings on a repetition | accepted | -| [0042](0042-mail-and-shell-as-a-type-binding.md) | Mail and shell tasks as a `type` binding | accepted | -| [0043](0043-result-variable-beside-an-expression-binding.md) | A result variable rides an expression binding, never a class or delegate one | accepted | -| [0044](0044-raw-templates-read-and-printed-with-string-escapes.md) | Read and print a raw template with the string escapes, either opener kept | accepted | -| [0045](0045-reserve-an-inclusive-fallback-only-where-the-engine-can-take-it.md) | Reserve an inclusive fallback only where the engine can take it | accepted | -| [0046](0046-listener-shapes-the-engine-deploys.md) | Listener shapes the engine deploys | accepted | -| [0047](0047-timer-job-settings-on-the-element-the-engine-reads.md) | Timer job settings on the element the engine reads | accepted | -| [0048](0048-mirror-the-engines-parse-time-rules-in-the-validator.md) | Mirror the engine's parse-time rules in the validator | accepted | -| [0049](0049-import-modeler-shaped-documents.md) | Import Modeler-shaped documents as the engine deploys them | accepted | -| [0050](0050-reserve-the-minted-ids-and-respell-unspellable-ones.md) | Reserve the minted start and end ids exactly, and respell an id the script cannot spell | accepted | -| [0051](0051-synthesize-variable-declarations-on-print.md) | The print declares every variable it reads bare, typed any | accepted | -| [0052](0052-loop-bodies-with-conditioned-exits.md) | A split inside a loop body continues at the loop head when a route stays inside | accepted | -| [0053](0053-routes-off-a-step-and-jumps-with-conditions.md) | A step's routes print as the fork the engine runs, and a degraded split keeps its conditions on its jumps | accepted | -| [0054](0054-import-tiers-follow-the-engine-parse.md) | Import tiers follow the engine's parse | accepted | +| Number | Title | Status | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------- | -------- | +| [0000](0000-use-markdown-architectural-decision-records.md) | Use Markdown Architectural Decision Records | accepted | +| [0001](0001-use-apache-2-license.md) | Use Apache 2.0 License | accepted | +| [0002](0002-use-mono-repo-structure.md) | Use Mono-repo Structure | accepted | +| [0003](0003-use-langium-as-language-workbench.md) | Use Langium as Language Workbench | accepted | +| [0004](0004-use-vscode-as-primary-ide-target.md) | Use VS Code as Primary IDE Target | accepted | +| [0005](0005-in-editor-conversion-webview-sidebar.md) | In-Editor Conversion: Webview Sidebar and Two Layers | accepted | +| [0006](0006-target-users-without-bpmn-knowledge.md) | Target users without BPMN knowledge, and minimize boilerplate | accepted | +| [0007](0007-intermediate-representation-ast-bpmn.md) | An intermediate representation between the AST and BPMN XML | accepted | +| [0008](0008-fork-camunda-moddle-extension.md) | Fork the Camunda moddle extension as a local Operaton one | accepted | +| [0009](0009-auto-layout-and-expansion-hint.md) | Diagram interchange: auto-layout and the sub-process expansion hint | accepted | +| [0010](0010-deterministic-synthesized-ids.md) | Deterministic ids for synthesized elements, reserved and respelled | accepted | +| [0011](0011-structured-grammar-one-bracket-shape.md) | A structured, code-like grammar with one bracket shape | accepted | +| [0012](0012-honest-bpmn-import.md) | Honest BPMN import: refusals, warnings, and the parse tiers | accepted | +| [0013](0013-validator-mirrors-parse-time-rules.md) | The validator mirrors the engine's parse-time rules | accepted | +| [0014](0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md) | Restructure the IR into a DSL with dominator analysis | accepted | +| [0015](0015-the-print-declares-every-variable-it-reads.md) | The print declares every variable it reads | accepted | +| [0016](0016-task-kinds-on-the-authoring-surface.md) | Task kinds on the authoring surface | accepted | +| [0017](0017-service-task-bindings.md) | Service task bindings, including external, mail and shell | accepted | +| [0018](0018-forms-and-field-injection.md) | Forms and field injection | accepted | +| [0019](0019-a-call-activitys-variable-mapping.md) | A call activity's variable mapping | accepted | +| [0020](0020-listeners-reuse-on-inside-the-attribute-block.md) | Listeners reuse `on` inside the attribute block | accepted | +| [0021](0021-operaton-engine-attributes-as-named-ir-fields.md) | Operaton engine attributes as named IR fields | accepted | +| [0022](0022-repetition-the-for-clause-and-its-per-run-job-settings.md) | Repetition: the `for` clause and its per-run job settings | accepted | +| [0023](0023-event-roots-from-usage-and-declared-codes.md) | Event root elements derived from usage, and declared codes | accepted | +| [0024](0024-event-trigger-payloads-paren-slot.md) | Event trigger payloads in one paren slot | accepted | +| [0025](0025-events-at-the-process-boundary.md) | Events at the process boundary: starts, ends and throws | accepted | +| [0026](0026-intermediate-catch-events.md) | Intermediate catch events | accepted | +| [0027](0027-boundary-events-attached-to-an-activity.md) | Boundary events attached to an activity | accepted | +| [0028](0028-compensation-as-subprocess-undo-block.md) | Compensation as a subprocess undo block | accepted | +| [0029](0029-work-that-can-be-given-up.md) | Work that can be given up: `attempt` and `cancel` | accepted | +| [0030](0030-link-events-for-import-round-trip-symmetry.md) | Link events for import round-trip symmetry | accepted | +| [0031](0031-inclusive-and-event-based-gateways.md) | Inclusive and event-based gateways | accepted | +| [0032](0032-expressions-raw-templates-and-escapes.md) | Expressions: raw templates, escapes, and what is lowered as EL | accepted | diff --git a/docs/glossary.md b/docs/glossary.md index 4c32099..d75f13c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -24,7 +24,7 @@ Terms that show up across the READMEs, the ADRs, and the code. BPMNscript is a DSL for writing BPMN processes as text. - **DI**: diagram interchange. The `` part of a BPMN file that stores diagram coordinates, meaning where each box sits. - BPMNscript regenerates it automatically on export; see [ADR-0003](decisions/0003-auto-layout-for-diagram-interchange.md). + BPMNscript regenerates it automatically on export; see [ADR-0009](decisions/0009-auto-layout-and-expansion-hint.md). - **Multi-instance**: the BPMN marker that makes one activity run several times, once per element of a collection or a fixed number of times. BPMNscript writes it from a `for` clause on the step; Operaton keeps the run counters on the repetition and a `loopCounter` on each run. - **MIWG**: the Model Interchange Working Group. @@ -34,7 +34,7 @@ Terms that show up across the READMEs, the ADRs, and the code. - **Langium**: the TypeScript toolkit used to build the language. From one grammar file it generates the parser, the AST types, and a language server. - See [ADR-0002](decisions/0002-use-langium-as-language-workbench.md). + See [ADR-0003](decisions/0003-use-langium-as-language-workbench.md). - **Grammar**: the file (`bpmn-script.langium`) that defines the DSL's syntax, meaning which keywords and shapes are valid. Langium turns it into a parser. - **Parser**: the code that reads `.bpmnscript` text and builds the AST. @@ -43,8 +43,8 @@ Terms that show up across the READMEs, the ADRs, and the code. The parser's structured, in-memory representation of a source file, with nodes for each `process`, `user`, `service`, `if`, `while`, `parallel`, and so on. - **IR**: intermediate representation. A small set of plain objects (`packages/transform/src/ir/types.ts`) that every transform reads from or writes to, in both directions. - See [ADR-0006](decisions/0006-intermediate-representation-between-ast-and-bpmn.md). - [ADR-0022](decisions/0022-engine-attributes-as-named-ir-fields.md) and [ADR-0023](decisions/0023-listeners-on-the-attribute-block.md) extend that shape to the engine's execution settings, input/output parameters, and lifecycle listeners. + See [ADR-0007](decisions/0007-intermediate-representation-ast-bpmn.md). + [ADR-0021](decisions/0021-operaton-engine-attributes-as-named-ir-fields.md) and [ADR-0020](decisions/0020-listeners-reuse-on-inside-the-attribute-block.md) extend that shape to the engine's execution settings, input/output parameters, and lifecycle listeners. - **Validator**: checks that a parsed process is structurally sound and reports errors in the editor. The full list of checks is in [packages/language/README.md](../packages/language/README.md#diagnostics). - **LSP**: Language Server Protocol. @@ -52,7 +52,7 @@ Terms that show up across the READMEs, the ADRs, and the code. Langium implements it for us; the VS Code extension is the host. - **moddle / bpmn-moddle**: the library that reads and writes BPMN XML as objects. A _moddle extension_ (here, `operaton-moddle.json`) teaches it about the extra `operaton:` attributes, and about the nested types they can carry, such as an input/output value or a listener binding. - Declaring those nested shapes is what lets an import warning attribute a dropped element to the specific step that carried it, instead of reporting an anonymous loss ([ADR-0022](decisions/0022-engine-attributes-as-named-ir-fields.md)). + Declaring those nested shapes is what lets an import warning attribute a dropped element to the specific step that carried it, instead of reporting an anonymous loss ([ADR-0021](decisions/0021-operaton-engine-attributes-as-named-ir-fields.md)). - **Round trip**: compiling a `.bpmnscript` file to BPMN and decompiling the result back to `.bpmnscript`, or the reverse. Used to check what each hop keeps and what it warns about or drops. @@ -61,4 +61,4 @@ Terms that show up across the READMEs, the ADRs, and the code. - **Golden file**: a known-good reference output checked into the repo so a test can diff against it instead of recomputing the expected result. See [tests/golden/README.md](../tests/golden/README.md). - **Monorepo / workspace**: this repo holds several npm packages (`packages/*`) in one place; npm "workspaces" link them so they can depend on each other without publishing. - See [ADR-0005](decisions/0005-use-mono-repo-structure.md). + See [ADR-0002](decisions/0002-use-mono-repo-structure.md). diff --git a/examples/spring-boot/compile-processes.sh b/examples/spring-boot/compile-processes.sh index 6abd8c3..8f426d8 100755 --- a/examples/spring-boot/compile-processes.sh +++ b/examples/spring-boot/compile-processes.sh @@ -29,7 +29,7 @@ shopt -s nullglob count=0 for src in "$SRC_DIR"/*.bpmnscript; do base="$(basename "$src" .bpmnscript)" - node "$CLI" build "$src" -o "$OUT_DIR/$base.bpmn" + node "$CLI" build "$src" -o "$OUT_DIR/$base.bpmn" --force count=$((count + 1)) done diff --git a/packages/cli/README.md b/packages/cli/README.md index 76bc1df..8721bb7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -28,7 +28,7 @@ bpmns parse invoice-approval.bpmn -o invoice-approval.bpmnscript When the layout library cannot place the graph, `build` writes the document without diagram data instead, prints `Warning: no diagram could be drawn for this process (...); the file deploys but opens without shapes in a modeler` to stderr, and still exits `0`, since Operaton deploys such a file unchanged (`BpmnParse.parseDiagramInterchangeElements` reads a diagram only when one is present). `parse` writes `.bpmnscript` source. -Exit codes are `0` for success, `1` for validation or parse errors, `2` for I/O errors. +Exit codes are `0` for success, `1` for validation errors on `build` or a refused BPMN construct on `parse`, and `2` for an I/O problem or input that cannot be read as BPMN XML. Both directions report non-fatal warnings on stderr without changing the exit code, so an undeclared variable reference on compile, or a dropped Operaton extension attribute or lane on import, is warned about but still produces a file. `parse` reports what the print hop could not carry into the script as well: some of those warnings bear on what a recompiled document runs, and some on whether the script builds back at all, so read them before running `build` on the output. After those warnings, `parse` also re-validates the script it just wrote and reports every error `build` would draw from it, still exiting `0` and still writing the file. @@ -39,6 +39,7 @@ Both subcommands read a file from disk; neither one reads stdin. An `-o` equal to the input path always exits `2` before anything is touched, `--force` included: there is no output left to write to once it's the same file as the source. An existing output file at a different path is left alone unless you pass `--force`; without it, the command exits `2` instead of overwriting it. An `-o` that names an existing directory writes into it under the input's own basename. +`build` refuses a file that is not `.bpmnscript` with exit `2` and a source with no process with exit `1`; `parse` refuses a file it cannot read as BPMN XML, an empty or non-XML one included, with exit `2`. The package is not meant to be imported programmatically. Import `@bpmn-script/transform` for the transforms and `@bpmn-script/language` for the Langium services. diff --git a/packages/cli/package.json b/packages/cli/package.json index 9a3f6ce..6f49527 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@bpmn-script/cli", - "description": "The cli specific package", + "description": "Command line interface compiling BPMNscript to BPMN 2.0 XML and back", "version": "0.0.1", "license": "Apache-2.0", "repository": { diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 7f3eb36..59df8c6 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -3,7 +3,12 @@ import type { Model } from '@bpmn-script/language'; import chalk from 'chalk'; import * as path from 'node:path'; -import { astToIr, irToXml, LayoutError } from '@bpmn-script/transform'; +import { + astToIr, + irToXml, + LayoutError, + NO_PROCESS_MESSAGE, +} from '@bpmn-script/transform'; import { CLI_VERSION, SEVERITY_ERROR, @@ -38,7 +43,8 @@ export async function buildAction( if (!extensions.includes(path.extname(resolvedInput))) { fail( 2, - `Error: expected a file with one of these extensions: ${extensions.join(', ')}`, + `Error: expected a file with one of these extensions: ${extensions.join(', ')}; ` + + 'a .bpmn file is decompiled with `bpmns parse`', ); } @@ -63,12 +69,10 @@ export async function buildAction( process.exit(1); } - // A blank or comment-only source parses clean into a model with no - // processes, which astToIr would refuse in its own internal wording. Checked - // after the error gate: a keyword typo also yields zero processes and should - // report its parser error instead. + // Checked after the error gate: a keyword typo also parses into a model with + // no processes, and should report its parser error instead. const ast = document.parseResult.value as Model; - if (ast.processes.length === 0) fail(1, 'Error: the file has no process'); + if (ast.processes.length === 0) fail(1, `Error: ${NO_PROCESS_MESSAGE}`); const warnings = (document.diagnostics ?? []).filter( (d) => d.severity === SEVERITY_WARNING, diff --git a/packages/cli/src/parse.ts b/packages/cli/src/parse.ts index 276c7d8..86e39ff 100644 --- a/packages/cli/src/parse.ts +++ b/packages/cli/src/parse.ts @@ -4,6 +4,9 @@ import * as fs from 'node:fs/promises'; import { xmlToIr, irToDsl, + EMPTY_INPUT_MESSAGE, + readableParseError, + xmlInputProblem, UnsupportedConstructError, UnsupportedServiceTaskFormError, UnsupportedElementError, @@ -26,37 +29,6 @@ type ParseOptions = { force?: boolean; }; -// `moddle-xml`'s one message for a root that resolves to no registered type; -// it never says what it found. -const WRONG_ROOT_MESSAGE = 'failed to parse document as '; -const BPMN_NAMESPACE = 'http://www.omg.org/spec/BPMN/20100524/MODEL'; - -function describeRoot(xml: string): string { - const tag = /<([\w:]+)/.exec(xml)?.[1]; - if (tag === undefined) return ''; - // A greedy `[^>]*` backtracks from the tag's end, so it lands on the last - // xmlns declared on the root, not the one for the root's own prefix. - const colon = tag.indexOf(':'); - const decl = colon === -1 ? 'xmlns' : `xmlns:${tag.slice(0, colon)}`; - const uri = new RegExp(`<${tag}[^>]*?\\s${decl}="([^"]+)"`).exec(xml)?.[1]; - const found = uri === undefined ? '' : `; found ${decl}=${uri}`; - return ` (root element is <${tag}>; expected in namespace ${BPMN_NAMESPACE}${found})`; -} - -// `saxen`'s parse errors quote the unparsed remainder of the document after a -// newline: megabytes for a large or binary file, control characters included. -function readableParseError(message: string, xml: string): string { - const firstLine = message.split('\n')[0]; - const capped = ( - firstLine.length > 200 ? firstLine.slice(0, 200) + '...' : firstLine - ).replace(/\p{C}/gu, ''); - return capped === WRONG_ROOT_MESSAGE ? capped + describeRoot(xml) : capped; -} - -function printablePreview(text: string): string { - return text.slice(0, 200).replace(/\p{C}/gu, '').slice(0, 40); -} - export async function parseAction( fileName: string, opts: ParseOptions, @@ -72,14 +44,12 @@ export async function parseAction( fail(2, `Error: could not read ${fileName}: ${(err as Error).message}`); } - // `trim` treats a leading BOM as whitespace (ECMA-262 WhiteSpace includes - // U+FEFF), so neither check needs a BOM strip. - if (xml.trim() === '') fail(2, 'Error: the file is empty'); - if (!xml.trimStart().startsWith('<')) { + const problem = xmlInputProblem(xml); + if (problem === EMPTY_INPUT_MESSAGE) fail(2, `Error: ${problem}`); + if (problem !== undefined) { fail( 2, - `Error: not an XML document (starts with "${printablePreview(xml.trimStart())}"); ` + - 'a .bpmnscript file is built with `bpmns build`', + `Error: ${problem}; a .bpmnscript file is built with \`bpmns build\``, ); } @@ -137,7 +107,7 @@ export async function parseAction( warn(`Warning: ${w.elementId}: ${w.message}`); } - // The print hop never refuses (ADR-0014) and none of the warnings above run + // The print hop never refuses and none of the warnings above run // the validator, so a setting carried as written may still draw an error; // building the written script the way `build` would is what catches it. const rebuilt = await buildDocument(outPath); diff --git a/packages/cli/test/build-parse.smoke.test.ts b/packages/cli/test/build-parse.smoke.test.ts index 1951fa5..2057556 100644 --- a/packages/cli/test/build-parse.smoke.test.ts +++ b/packages/cli/test/build-parse.smoke.test.ts @@ -444,7 +444,9 @@ describe('bpmns build / bpmns parse: guards against destructive or unclear failu inputPath, opts: {}, exit: 2, - line: 'Error: expected a file with one of these extensions: .bpmnscript', + line: + 'Error: expected a file with one of these extensions: .bpmnscript; ' + + 'a .bpmn file is decompiled with `bpmns parse`', }; }, ], @@ -699,20 +701,9 @@ describe('bpmns build / bpmns parse: guards against destructive or unclear failu }); describe('tmLanguage extension sync', () => { - test('the extension ships the current grammar, copied by a build step', () => { + test('the extension ships the current grammar', () => { expect(fs.readFileSync(EXTENSION_TMLANGUAGE, 'utf-8')).toBe( fs.readFileSync(LANGUAGE_TMLANGUAGE, 'utf-8'), ); - - const pkg = JSON.parse( - fs.readFileSync( - path.resolve(REPO_ROOT, 'packages/extension/package.json'), - 'utf-8', - ), - ) as { scripts?: Record }; - - const prepare = pkg.scripts?.['build:prepare'] ?? ''; - expect(prepare).toContain('language/syntaxes'); - expect(prepare, 'build:prepare must perform a file copy').toMatch(/\bcp\b/); }); }); diff --git a/packages/cli/test/decompile-contract.e2e.test.ts b/packages/cli/test/decompile-contract.e2e.test.ts index af3e341..c10ace7 100644 --- a/packages/cli/test/decompile-contract.e2e.test.ts +++ b/packages/cli/test/decompile-contract.e2e.test.ts @@ -54,7 +54,7 @@ const labeledTerminalsBpmn = ( `; -// BPMN vocabulary the DSL author never sees (ADR-0013). +// BPMN vocabulary the DSL author never sees. const FORBIDDEN_JARGON = ['flow node', 'gateway', 'token', 'sequence flow']; function assertNoForbiddenJargon(text: string): void { diff --git a/packages/extension/README.md b/packages/extension/README.md index 29b7693..aba0fe4 100644 --- a/packages/extension/README.md +++ b/packages/extension/README.md @@ -4,7 +4,7 @@ The VS Code extension for BPMNscript. It runs the Langium language server for highlighting, completion, hover, go to definition, references, rename, the outline and inline diagnostics, and it converts the open file between `.bpmnscript` and `.bpmn` from a sidebar panel or the command palette. The language intelligence itself lives in `@bpmn-script/language`; this package loads it in VS Code. -That wiring is mostly Langium scaffold, which is the point of choosing Langium in the first place ([ADR-0002](https://github.com/BPMNscript/bpmnscript/blob/main/docs/decisions/0002-use-langium-as-language-workbench.md)). +That wiring is mostly Langium scaffold, which is the point of choosing Langium in the first place ([ADR-0003](https://github.com/BPMNscript/bpmnscript/blob/main/docs/decisions/0003-use-langium-as-language-workbench.md)). The conversion layer on top is project-specific: three commands (`bpmnscript.compile`, `bpmnscript.decompile`, and `bpmnscript.openAndDecompile`, which picks a BPMN file from disk and decompiles it) and a sidebar webview that drives them for the active file. ## How it fits together @@ -24,7 +24,7 @@ The conversion layer splits along the VS Code boundary. `conversion-core.ts` is a pure, `vscode`-free module driving the `compileDslToBpmn` and `decompileBpmnToDsl` pipelines, and it holds the decisions: severity gating (warnings don't block a compile), error classification, unsupported-element handling. A successful decompile also returns `warnings`, what `xmlToIr` reported on the way in and `irToDsl` on the way out, in one list. Because nothing here touches `vscode`, it's testable under vitest with no editor host. -`conversion.ts` is the adapter around it: it resolves the source URI (an argument, otherwise the active editor), reads the text (preferring an unsaved in-memory document), calls the core, opens the Problems panel on a validation failure (the language server already publishes the same diagnostics), asks before overwriting an existing output file, writes the result next to the source with the extension swapped, and opens it. +`conversion.ts` is the adapter around it: it resolves the source URI (an argument, otherwise the active editor), refuses a file whose extension is not the one the command takes and names the command that is, reads the text (preferring an unsaved in-memory document), calls the core, opens the Problems panel on a validation failure (the language server already publishes the same diagnostics), asks before overwriting an existing output file, writes the result next to the source with the extension swapped, and opens it. A BPMN construct the transform refuses surfaces as an error notification and no file is written; any `warnings` surface as one aggregated notification listing what the two hops reported. `sidebar-view-provider.ts` implements `WebviewViewProvider` for the "Convert" view in the "BPMNscript" activity-bar container. @@ -49,7 +49,7 @@ npm run build --workspace packages/extension ``` `esbuild` bundles both entry points into CommonJS under `out/`, which is how VS Code loads extensions. -To try it live, press F5 in VS Code from the repo root: a second window opens with the extension loaded, where `.bpmnscript` and `.bpmn` files get language support and the sidebar panel. +To try it live, press F5 in VS Code from the repo root: a second window opens with the extension loaded, where `.bpmnscript` files get language support and `.bpmn` files get the decompile command and the sidebar panel. See [CONTRIBUTING.md](https://github.com/BPMNscript/bpmnscript/blob/main/CONTRIBUTING.md#trying-it-out-in-vs-code). Build order matters. diff --git a/packages/extension/media/sidebar.js b/packages/extension/media/sidebar.js index 724c093..a9f752e 100644 --- a/packages/extension/media/sidebar.js +++ b/packages/extension/media/sidebar.js @@ -4,10 +4,10 @@ * Runs inside the VS Code webview (Chromium sandbox). Communicates with the * extension host exclusively via postMessage (no DOM event handlers in HTML). * - * Message protocol (host → webview): + * Message protocol (host -> webview): * {type:'state', activeFile} - active-file state push * - * Message protocol (webview → host): + * Message protocol (webview -> host): * {type:'ready'} - page loaded; request current state * {type:'compile', uri} - run bpmnscript.compile on this URI * {type:'decompile', uri} - run bpmnscript.decompile on this URI @@ -27,7 +27,7 @@ vscode.postMessage({ type: 'ready' }); // ------------------------------------------------------------------------- - // Message handling (host → webview) + // Message handling (host -> webview) // ------------------------------------------------------------------------- window.addEventListener('message', function (event) { @@ -89,7 +89,7 @@ if (file.counterpart) { container.appendChild( linkButton( - 'Open ' + file.counterpart.name + ' →', + 'Open ' + file.counterpart.name + ' ->', file.counterpart.uri, ), ); @@ -106,7 +106,7 @@ const pickBtn = document.createElement('button'); pickBtn.className = 'action secondary'; - pickBtn.textContent = 'Pick a BPMN file…'; + pickBtn.textContent = 'Pick a BPMN file...'; pickBtn.title = 'Choose a .bpmn file to convert to BPMNscript'; pickBtn.addEventListener('click', function () { vscode.postMessage({ type: 'pick' }); diff --git a/packages/extension/package.json b/packages/extension/package.json index 3886677..3131c43 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -60,7 +60,7 @@ }, { "command": "bpmnscript.openAndDecompile", - "title": "Open a BPMN file and convert it\u2026", + "title": "Open a BPMN file and convert it...", "category": "BPMNscript" } ], diff --git a/packages/extension/src/extension/conversion-core.ts b/packages/extension/src/extension/conversion-core.ts index b97ffdb..599664c 100644 --- a/packages/extension/src/extension/conversion-core.ts +++ b/packages/extension/src/extension/conversion-core.ts @@ -10,6 +10,9 @@ import { irToXml, xmlToIr, irToDsl, + NO_PROCESS_MESSAGE, + readableParseError, + xmlInputProblem, UnsupportedConstructError, LayoutError, } from '@bpmn-script/transform'; @@ -78,6 +81,12 @@ export async function compileDslToBpmn( return { ok: false, kind: 'validation', diagnostics }; } + // Checked after the error gate: a keyword typo also parses into a model + // with no processes, and should report its parser error instead. + if (doc.parseResult.value.processes.length === 0) { + return { ok: false, kind: 'error', message: NO_PROCESS_MESSAGE }; + } + // The cli's build.ts and tests/helpers/pipeline.ts run the same // astToIr -> irToXml chain, each with its own failure reporting. let ir; @@ -127,6 +136,11 @@ export async function compileDslToBpmn( export async function decompileBpmnToDsl( xml: string, ): Promise { + const problem = xmlInputProblem(xml); + if (problem !== undefined) { + return { ok: false, kind: 'error', message: problem }; + } + let ir; let warnings: ImportWarning[]; try { @@ -135,10 +149,12 @@ export async function decompileBpmnToDsl( if (err instanceof UnsupportedConstructError) { return { ok: false, kind: 'unsupported', message: err.message }; } + // Straight into a notification otherwise: a parser error quotes the rest + // of the document, which for a large file is the whole file. return { ok: false, kind: 'error', - message: (err as Error).message, + message: readableParseError((err as Error).message, xml), }; } diff --git a/packages/extension/test/conversion-core.test.ts b/packages/extension/test/conversion-core.test.ts index 9633c05..e65955f 100644 --- a/packages/extension/test/conversion-core.test.ts +++ b/packages/extension/test/conversion-core.test.ts @@ -149,6 +149,19 @@ describe('compileDslToBpmn', () => { expect(result.output).not.toContain('bpmndi:'); expect(result.layoutWarning).toContain('bpmn-auto-layout'); }); + + test('a comment-only file is refused in plain words, not in astToIr wording', async () => { + const result = await compileDslToBpmn( + '// nothing but a comment here\n', + '0.0.1', + ); + + expect(result).toEqual({ + ok: false, + kind: 'error', + message: 'the file has no process', + }); + }); }); const LANE_AND_ASYNC_ATTR_BPMN = ` @@ -263,6 +276,31 @@ describe('decompileBpmnToDsl', () => { expect(result.kind).toBe(expected.kind); expectMentions(result.message, expected.mentions); }); + + // The sidebar's file picker offers every file, and whatever comes back here + // goes into a notification whole. + const UNPARSABLE_TAG = ' { + expect(await decompileBpmnToDsl(xml)).toEqual({ + ok: false, + kind: 'error', + message, + }); + }); }); describe('swapExtension', () => { diff --git a/packages/language/README.md b/packages/language/README.md index 21eb765..bcb609d 100644 --- a/packages/language/README.md +++ b/packages/language/README.md @@ -109,7 +109,7 @@ A user task names its deployed form with `formKey` or with `formRef`, never both #### Forms A process's own `start` and a `user` task each take one `form` block, `operaton:formData` on the wire, which Tasklist renders as one input per field and which fills one process variable per field when the form is submitted. -A field takes the shape every element takes ([ADR-0029](../../docs/decisions/0029-one-bracket-shape-for-every-element.md)): `id: type "label" = default (settings) { members }`, the label and the default optional. +A field takes the shape every element takes ([ADR-0011](../../docs/decisions/0011-structured-grammar-one-bracket-shape.md)): `id: type "label" = default (settings) { members }`, the label and the default optional. The parens hold what the engine validates a submission under, plus the date pattern; the braces hold what the field is built from, an `enum`'s values and its `property` lines. `id` is the process variable the field fills, so a `var` of the same name has to declare the same type. @@ -138,6 +138,7 @@ A `number` default has to be an integer, which `LongFormType.convertValue` reads A bare name or a `"${...}"` expression is left to the engine, since it is evaluated fresh at every render. An `enum` field lists its values in the braces, one `id "Label"` line each in the order the form offers them, the label optional. +One offering no values at all draws a warning rather than an error, since the engine deploys the form and then rejects every value submitted for the field. Its default, when it is a literal, has to name one of them: `FormFieldHandler.createFormField` converts the default through the type on every render of the form, so a default naming no value deploys and then fails to open. A `property = ""` line in the braces is an `operaton:property` on the field, a key and a text the engine parses into the form's description and never acts on itself. It is legal on a field of any type, and its value is a quoted string or a `"${...}"` expression, the shapes `field` takes. @@ -533,7 +534,7 @@ The colon is what separates a host from a bare trigger. Trigger words are ordinary identifiers rather than keywords (see below), so a host-less handler carrying a code and a hosted handler carrying none would otherwise both read as `on` followed by two identifiers; the colon settles which reading applies at the second token. A host has to be a step an engine token can be running at: a `user`, `service`, `script`, `send`, or `receive` task, a `step`, a `decide` step, a `subprocess`, an `attempt` block, or a `call`. -A `start` or `end` event, a `goto`, a `throw` or `emit`, and another handler are all rejected. +A `start` or `end` event, a `goto`, a `throw` or `emit`, and another handler are all rejected, and so is an `await` of any trigger kind: `BpmnParse.parseBoundaryEvents` attaches to an activity alone, and an intermediate catch event is none. `escalation` narrows the set further, to a `subprocess`, an `attempt` block, a `call`, or a `user` task, matching Operaton's own restriction. `cancel` narrows it to one kind, an `attempt` block, the only host Operaton accepts a cancel boundary on. `alongside` keeps its usual meaning, letting the host run beside the catch instead of being canceled the moment the trigger fires, and `error` still always cancels its host. @@ -575,7 +576,7 @@ Writing one of the first three after `await` is rejected with a diagnostic namin A plain `await` takes no host, no catch bindings, no `alongside`, and no body. An optional name between the trigger and the payload, `await message Paid("Paid")`, makes it a step a `goto` can target, under the same duplicate-name rules as any other step; without one the id is synthesized (`Catch_`). -[ADR-0020](../../docs/decisions/0020-intermediate-catch-events.md) covers why `await` beat `wait` and `receive`, and [ADR-0035](../../docs/decisions/0035-link-events-for-import-round-trip-symmetry.md) why every `await` can carry a name and why `link` joined the trigger scope. +[ADR-0026](../../docs/decisions/0026-intermediate-catch-events.md) covers why `await` beat `wait` and `receive`, and [ADR-0030](../../docs/decisions/0030-link-events-for-import-round-trip-symmetry.md) why every `await` can carry a name and why `link` joined the trigger scope. ### Link events @@ -603,13 +604,13 @@ process invoice-rework { `emit link` ends its path the way a `goto` does: the token continues at the `await link` of the same name and nowhere else, so a step written after it can never run. Nothing flows into a link catch, so the statement before an `await link` has to end the path: an `end`, a `throw`, a `goto`, an `emit link`, or a block whose every branch does one of those. One `await link` per name in the whole file, at any depth, and any number of `emit link` may name it. -Both ends sit in one container, the process, subprocess, or handler body, the same rule a `goto` follows, and an `emit link` cannot reach into a `parallel` or multi-branch `await` branch from outside it. +Both ends sit in one container, the process, subprocess, or handler body, the same rule a `goto` follows, and an `emit link` cannot reach into a `parallel` or multi-branch `await` branch from outside it, since a token arriving inside one branch leaves the block's join waiting for the branches the run never entered, so it never completes. `link` cannot head a branch of a multi-branch `await`, because Operaton refuses a link catch behind an event-based gateway. An engine setting or a listener on `emit link` is an error: the engine creates no activity for a link throw, so nothing written there would run; the same items on `await link` are fine. An `await link` nothing emits is a warning rather than an error, since the engine deploys it and an imported diagram may carry one. `goto` stays the way to jump in source: its target is scoped to its container and needs no file-wide name, and it compiles to the one sequence flow it prints as. -`link` exists for a diagram that already carries one, so that it imports and compiles back to the same two events; [ADR-0035](../../docs/decisions/0035-link-events-for-import-round-trip-symmetry.md) makes that argument. +`link` exists for a diagram that already carries one, so that it imports and compiles back to the same two events; [ADR-0030](../../docs/decisions/0030-link-events-for-import-round-trip-symmetry.md) makes that argument. ### Waiting on several triggers at once @@ -653,6 +654,7 @@ process order-intake { Four words are legal there: `message`, `signal`, `timer`, and `condition`, each with the payload it already carries elsewhere, a name for the first two, a duration or an `at` or `every` key for the third, and the condition expression, the same clause `on condition` and `await condition` carry, for the fourth. A subprocess start and an event-handler start take none, because both are entered by their container rather than by an event of their own; the validator rejects a trigger written on either. Neither takes `initiator` or a `form` block: `BpmnParse.parseScopeStartEvent` reads neither off a start that is not the process's own, so the validator rejects both there too. +A message start's name has to be a fixed one: an expression anywhere in it is rejected, whether it opens the name as a raw template or sits further in and leaves the name a plain string, since a process that has not started yet has no variables for the engine to evaluate it against. Writing `error`, `escalation`, or `compensation` on a process start is rejected too, for a different reason: Operaton's own start-event parser does not branch on those three, so the engine ignores the trigger and starts the process exactly as if none were written, and this surface refuses to compile XML the engine would disregard. @@ -840,7 +842,7 @@ The categories it covers: A `property` line errors the same way on a kind that takes none and on a `service`, `send`, or `decide` step not bound with `topic`. - Listeners: an event word the element does not have, a binding count other than one, a missing timer on `on timeout` or a timer on any other event, a repeated event on one element, and the same fence rules a `script` body follows. - Structure: an empty process, subprocess, or handler body, an empty branch (warning), an empty loop body, which would drop the loop and its condition, a `do` body that always ends or redirects the flow, which would leave the loop gateway with no incoming flow, a parameter or listener written inside a body rather than in an element's attribute block, an unreachable statement, a process-level `start` after a step whose flow still runs on, a `start` anywhere but first in its subprocess, attempt block, or handler body, a second plain or timer start on a process, counting the plain start the compiler adds to a body that does not open with a `start`, a second message, signal, or condition start repeating an earlier one's payload, `initiator` or a `form` block on a start inside a subprocess, attempt block, or handler body, a process with several starts and no plain or timer one among them (warning), a `form` block on a start that is not the default one (warning), a `goto` reaching into a `parallel` or `await` branch from outside it, a second `else` branch on a `parallel` statement, an `else` branch with no conditioned sibling, and an `else` branch beside a sibling carrying no condition. -- Names: a reused process name, step name, or `label`, a step name equal to the process id, which the compiled document can hold only once, and a name matching a synthesized-id pattern, the desugarer's own or the layouter's `_di`, `BPMNDiagram_`, and `BPMNPlane_` ids ([ADR-0010](../../docs/decisions/0010-deterministic-structural-ids.md)). +- Names: a reused process name, step name, or `label`, a step name equal to the process id, which the compiled document can hold only once, and a name matching a synthesized-id pattern, the desugarer's own or the layouter's `_di`, `BPMNDiagram_`, and `BPMNPlane_` ids ([ADR-0010](../../docs/decisions/0010-deterministic-synthesized-ids.md)). - Call activities: a missing `process`, an unknown `binding` value, `binding` and `version` together, `mapper` and `mapperDelegate` together, and duplicate `in` or `out` mappings. A `decide` step pins its decision table with `binding` and `version`, under those same two rules. - Form fields: a type outside the five, a repeated field id, a field typed against a `var` of its name, a settings key outside the seven constraints and `pattern`, a constraint on a type its validator refuses or a `pattern` off a `date` field, a value in a shape the engine cannot parse (`required: false`, a bound that is not an integer, an empty `pattern` or `validator`, a `pattern` holding a letter `SimpleDateFormat` does not read), a literal default that is not what its type converts (`number`, not an integer; `boolean`, not `true` or `false`; `date` with no `pattern`, an ISO date rather than the engine's own `dd/MM/yyyy`), a value line on a field that is not an `enum`, an `enum` offering no values (warning), a repeated value id, a literal default naming none of the values, a block member that is not a `property` line, and a property value that is neither a quoted string nor a `"${...}"` expression. @@ -883,7 +885,7 @@ Using a reserved keyword where an identifier belongs, as in `if (date > deadline ```ts import { createBpmnScriptServices, // factory: returns a fully wired Langium service container - BpmnScriptLanguageMetaData, // file-extension constants (e.g. fileExtensions: ['bpmnscript']) + BpmnScriptLanguageMetaData, // file-extension constants (e.g. fileExtensions: ['.bpmnscript']) renderExpression, // serialize a parsed expression AST node to a "${...}" string } from '@bpmn-script/language'; ``` @@ -903,7 +905,7 @@ npm run build # runs langium generate + tsc npm test ``` -`langium:generate` and `langium:watch` are also available for IDE use; the VS Code launch configuration relies on them. +`langium:generate` and `langium:watch` are also available for IDE use. ## Source layout diff --git a/packages/language/package.json b/packages/language/package.json index 1f8424b..82fc36f 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@bpmn-script/language", - "description": "The language specific package", + "description": "Langium grammar, validator and language server for BPMNscript", "version": "0.0.1", "license": "Apache-2.0", "repository": { diff --git a/packages/language/src/bpmn-script-hover.ts b/packages/language/src/bpmn-script-hover.ts index f539dc9..34a4968 100644 --- a/packages/language/src/bpmn-script-hover.ts +++ b/packages/language/src/bpmn-script-hover.ts @@ -17,7 +17,11 @@ import { isNamedStatement, type NamedStatement, } from './bpmn-script-scope-provider.js'; -import { attributeBlockRuleOf } from './vocabulary.js'; +import { + attributeBlockRuleOf, + EVENT_CODE_FIELD, + EVENT_MESSAGE_FIELD, +} from './vocabulary.js'; import { isVariableSymbolNode } from './bpmn-script-references.js'; import { declaredCodeOf, settingsOf } from './paren-items.js'; @@ -41,11 +45,11 @@ function codeDeclHover(decl: CodeDecl): string { // `declaredCodeOf` falls back to the name; hover quotes only a written // setting. const hasCodeSetting = settingsOf(decl.items).some( - (item) => item.key === 'code', + (item) => item.key === EVENT_CODE_FIELD, ); const code = hasCodeSetting ? declaredCodeOf(decl) : undefined; if (code !== undefined) line += ` with code "${code}"`; - const message = literalSetting(decl.items, 'message'); + const message = literalSetting(decl.items, EVENT_MESSAGE_FIELD); if (message !== undefined) line += `: ${message}`; return line; } diff --git a/packages/language/src/bpmn-script-parser-error-message-provider.ts b/packages/language/src/bpmn-script-parser-error-message-provider.ts index 30d519b..9eb09db 100644 --- a/packages/language/src/bpmn-script-parser-error-message-provider.ts +++ b/packages/language/src/bpmn-script-parser-error-message-provider.ts @@ -6,7 +6,7 @@ * Chevrotain paths, so both builders are overridden: `buildMismatchTokenMessage` * where the grammar expects exactly `ID`, `buildNoViableAltMessage` where `ID` * is one alternative among several. Every message stays free of BPMN - * vocabulary (ADR-0013). Only the message changes; recovery and the legal + * vocabulary. Only the message changes; recovery and the legal * token positions are Chevrotain's. */ diff --git a/packages/language/src/bpmn-script-validator.ts b/packages/language/src/bpmn-script-validator.ts index cf501c7..1ffc0df 100644 --- a/packages/language/src/bpmn-script-validator.ts +++ b/packages/language/src/bpmn-script-validator.ts @@ -156,6 +156,7 @@ import { ERROR_MAPPING_WHEN, EVENT_BINDING_FIELD_SET, EVENT_BINDING_FIELDS, + EVENT_MESSAGE_FIELD, EXECUTION_LISTENER_EVENTS, EXPRESSION_ANYWHERE, EXPRESSION_OPEN, @@ -202,6 +203,7 @@ import { THROW_TRIGGERS, TIMER_JOB_KEYS, TIMER_PARTICLES, + type TimerParticle, TRIGGER_PAYLOAD, TYPE_BINDING_KEY, TYPE_BINDING_VALUES, @@ -286,12 +288,8 @@ const USER_PRIORITY_KEY = 'priority'; export const NON_VARIABLE_ATTR_KEYS: ReadonlySet = new Set([ 'label', 'documentation', - 'class', 'formRef', - 'expression', - 'delegate', - EXTERNAL_BINDING_KEY, - TYPE_BINDING_KEY, + ...BUSINESS_RULE_BINDING_KEYS, 'process', 'binding', 'version', @@ -462,7 +460,6 @@ const ENGINE_KEY_BY_JOIN_KEY: Readonly> = Object.fromEntries( Object.entries(JOIN_KEY_BY_ENGINE_KEY).map(([key, join]) => [join, key]), ); -const TIMER_PARTICLE_SET: ReadonlySet = new Set(TIMER_PARTICLES); const IO_DIRECTION_SET: ReadonlySet = new Set(IO_DIRECTIONS); const FIELD_BINDING_KEY_SET: ReadonlySet = new Set(FIELD_BINDING_KEYS); /** The remaining binding keys, so the two lists cannot name the same word. */ @@ -1069,7 +1066,7 @@ export const EVERY_SHAPE_MESSAGE = timerShapeMessage( /** The shape each particle's calendar reads. */ const TIMER_SHAPE_BY_PARTICLE: Readonly< - Record + Record > = { after: { pattern: AFTER_TIME_TEXT, message: AFTER_SHAPE_MESSAGE }, at: { pattern: AT_TIME_TEXT, message: AT_SHAPE_MESSAGE }, @@ -1356,11 +1353,11 @@ function isSignalSubscriptionJobCarrier(owner: AttributeOwner): boolean { } /** - * Ids the `astToIr` desugarer synthesizes (ADR-0010); a statement name + * Ids the `astToIr` desugarer synthesizes; a statement name * matching one produces duplicate-id IR. A process or subprocess body's own * `StartEvent_` and `EndEvent_` are matched exactly by * {@link mintedTerminalRole} instead, since a prefix would refuse the - * Modeler's default `StartEvent_1` (ADR-0050); a handler body's and a boundary + * Modeler's default `StartEvent_1`; a handler body's and a boundary * escape's terminals are minted off a coordinate no author writes, so only a * prefix catches them. `_di$`, `BPMNDiagram_` and `BPMNPlane_` are `irToXml`'s * layout ids, which collide as a duplicate `xs:ID` the same way. @@ -4178,12 +4175,13 @@ export class BpmnScriptValidator { } return; } + const known = TIMER_PARTICLES.find((word) => word === particle); if (particle === undefined) { accept('error', LISTENER_TIMER_PAYLOAD_MESSAGE, { node: listener, property: 'event', }); - } else if (!TIMER_PARTICLE_SET.has(particle)) { + } else if (known === undefined) { accept( 'error', `Unknown timer particle '${particle}'; write ${formatWordList(TIMER_PARTICLES)}.`, @@ -4198,7 +4196,7 @@ export class BpmnScriptValidator { ); this.checkTimerShape( listener.time, - particle, + known, { node: listener, property: 'time' }, 'warning', accept, @@ -4213,14 +4211,14 @@ export class BpmnScriptValidator { */ private checkTimerShape( time: string, - particle: string, + particle: TimerParticle, target: { node: AstNode; property: string }, severity: 'error' | 'warning', accept: ValidationAcceptor, ): void { if (EXPRESSION_OPEN.test(time)) return; const shape = TIMER_SHAPE_BY_PARTICLE[particle]; - if (shape !== undefined && !shape.pattern.test(time)) { + if (!shape.pattern.test(time)) { accept(severity, shape.message, target); } } @@ -4295,7 +4293,7 @@ export class BpmnScriptValidator { ): void { for (const binding of caughtBindingsOf(handler.items)) { if ( - binding.field === 'message' && + binding.field === EVENT_MESSAGE_FIELD && !TRIGGER_PAYLOAD[handler.trigger]!.message ) { accept('error', ESCALATION_NO_MESSAGE_MESSAGE, { @@ -4679,7 +4677,10 @@ export class BpmnScriptValidator { for (const setting of settings) { if (!EVENT_BINDING_FIELD_SET.has(setting.key)) continue; - if (setting.key === 'message' && !TRIGGER_PAYLOAD[decl.kind]!.message) { + if ( + setting.key === EVENT_MESSAGE_FIELD && + !TRIGGER_PAYLOAD[decl.kind]!.message + ) { accept('error', ESCALATION_NO_MESSAGE_MESSAGE, { node: setting, property: 'key', diff --git a/packages/language/src/paren-items.ts b/packages/language/src/paren-items.ts index 8dd2e16..53bfd00 100644 --- a/packages/language/src/paren-items.ts +++ b/packages/language/src/paren-items.ts @@ -7,8 +7,10 @@ import { DECLARED_CODE_TRIGGERS, EVENT_BINDING_FIELD_SET, + EVENT_CODE_FIELD, namesACode, TIMER_PARTICLE_BY_KIND, + type TimerParticle, } from './vocabulary.js'; import type { AstNode } from 'langium'; import { @@ -76,14 +78,17 @@ export function caughtBindingsOf(items: ParenItem[]): CaughtBinding[] { */ export function isStructuralParenKey(owner: AstNode, key: string): boolean { if (isOnHandler(owner) && EVENT_BINDING_FIELD_SET.has(key)) return true; - return 'trigger' in owner && TIMER_PARTICLE_KEYS.has(key); + return ( + 'trigger' in owner && + TIMER_PARTICLE_KEYS.some((particle) => particle === key) + ); } /** A timer says a duration by writing it bare, and a date or a cycle by key. */ -const TIMER_PARTICLE_KEYS: ReadonlySet = new Set([ +const TIMER_PARTICLE_KEYS: readonly TimerParticle[] = [ TIMER_PARTICLE_BY_KIND.date, TIMER_PARTICLE_BY_KIND.cycle, -]); +]; export function configuredSettingsOf( owner: AstNode & { items?: ParenItem[] }, @@ -180,7 +185,9 @@ export function isCodePosition(node: AstNode): boolean { * both of which the validator reports on its own. */ export function declaredCodeOf(decl: CodeDecl): string | undefined { - const setting = settingsOf(decl.items).find((item) => item.key === 'code'); + const setting = settingsOf(decl.items).find( + (item) => item.key === EVENT_CODE_FIELD, + ); if (setting === undefined) return decl.name; if (!isLiteralString(setting.value) || setting.value.value.length === 0) { return undefined; @@ -211,7 +218,7 @@ export function hasQuotedPayload(items: ParenItem[]): boolean { /** A timer clause, and the item a diagnostic about it belongs on. */ export interface TimerPayload { - readonly particle: string; + readonly particle: TimerParticle; readonly time: string; readonly node: Setting | ParenValue; } @@ -220,10 +227,11 @@ export function timerParticleOf( items: ParenItem[], ): (TimerPayload & { node: Setting }) | undefined { for (const setting of settingsOf(items)) { - if (!TIMER_PARTICLE_KEYS.has(setting.key)) continue; + const particle = TIMER_PARTICLE_KEYS.find((key) => key === setting.key); + if (particle === undefined) continue; const time = timeTextOf(setting.value); if (time !== undefined) { - return { particle: setting.key, time, node: setting }; + return { particle, time, node: setting }; } } return undefined; diff --git a/packages/language/src/vocabulary.ts b/packages/language/src/vocabulary.ts index f3d700a..b2890b1 100644 --- a/packages/language/src/vocabulary.ts +++ b/packages/language/src/vocabulary.ts @@ -389,17 +389,25 @@ export type TimerKind = 'duration' | 'date' | 'cycle'; * The particle a timer clause is written with, keyed by the BPMN timer * definition it selects (`timeDuration`, `timeDate`, `timeCycle`). */ -export const TIMER_PARTICLE_BY_KIND: Readonly> = { +export const TIMER_PARTICLE_BY_KIND = { duration: 'after', date: 'at', cycle: 'every', -}; +} as const satisfies Record; + +export type TimerParticle = (typeof TIMER_PARTICLE_BY_KIND)[TimerKind]; -export const TIMER_PARTICLES: readonly string[] = Object.values( +export const TIMER_PARTICLES: readonly TimerParticle[] = Object.values( TIMER_PARTICLE_BY_KIND, ); -export const EVENT_BINDING_FIELDS: readonly string[] = ['code', 'message']; +export const EVENT_CODE_FIELD = 'code'; +export const EVENT_MESSAGE_FIELD = 'message'; + +export const EVENT_BINDING_FIELDS: readonly string[] = [ + EVENT_CODE_FIELD, + EVENT_MESSAGE_FIELD, +]; export const EVENT_BINDING_FIELD_SET: ReadonlySet = new Set( EVENT_BINDING_FIELDS, @@ -407,7 +415,8 @@ export const EVENT_BINDING_FIELD_SET: ReadonlySet = new Set( export function eventBindingFieldsFor(trigger: string): readonly string[] { return EVENT_BINDING_FIELDS.filter( - (field) => field !== 'message' || TRIGGER_PAYLOAD[trigger]?.message, + (field) => + field !== EVENT_MESSAGE_FIELD || TRIGGER_PAYLOAD[trigger]?.message, ); } diff --git a/packages/language/test/helpers/diagnostics.ts b/packages/language/test/helpers/diagnostics.ts index d2729f2..5cc4c22 100644 --- a/packages/language/test/helpers/diagnostics.ts +++ b/packages/language/test/helpers/diagnostics.ts @@ -24,7 +24,7 @@ export function withTextMessages( })); } -// ── Messages shared by more than one suite, spelled once ─────────────────── +// Messages shared by more than one suite, spelled once export const UNREACHABLE = 'This step can never run: an earlier `end`, `throw`, `goto`, `emit link`, ' + diff --git a/packages/language/test/validating.test.ts b/packages/language/test/validating.test.ts index 8d12c25..bf05545 100644 --- a/packages/language/test/validating.test.ts +++ b/packages/language/test/validating.test.ts @@ -211,7 +211,7 @@ function formFieldRows( ]); } -// ── The messages, each spelled once ───────────────────────────────────────── +// The messages, each spelled once // // A message the validator exports is imported; a helper below adapts the few // that take an AST node or a slot to the strings a case spells. @@ -557,7 +557,7 @@ const alreadyDeclared = (kind: string, name: string) => const duplicateDeclaredCode = (kind: string, code: string, owner: string) => `${capitalized(kind)} code '${code}' is already declared by '${owner}'; two declarations cannot share a code.`; -// ── Cases ─────────────────────────────────────────────────────────────────── +// Cases checks('Validation - variables in expressions', [ [ @@ -1254,6 +1254,11 @@ checks('Validation - service, send, and decision bindings', [ ), ], ], + [ + 'a bareword decision names a decision table, not a variable', + `process p { decide D(decision: riskRating) }`, + [], + ], [ 'a decision step combining binding and version is one error', `process p { decide D(decision: "riskRating", binding: latest, version: 3) }`, diff --git a/packages/transform/README.md b/packages/transform/README.md index 6c905de..4b93983 100644 --- a/packages/transform/README.md +++ b/packages/transform/README.md @@ -3,7 +3,7 @@ The conversion layer: the IR type definitions and the four transforms that move a process between formats. It's the bulk of the project's hand-written code, where the `language` package is mostly generated. -Everything pivots on the IR, a small set of plain TypeScript objects in `src/ir/types.ts` that describe a process without committing to any one file format ([ADR-0006](../../docs/decisions/0006-intermediate-representation-between-ast-and-bpmn.md)). +Everything pivots on the IR, a small set of plain TypeScript objects in `src/ir/types.ts` that describe a process without committing to any one file format ([ADR-0007](../../docs/decisions/0007-intermediate-representation-ast-bpmn.md)). Each transform converts between the IR and one neighboring format, so none of them has to know about any of the others. ```mermaid @@ -76,20 +76,20 @@ interface SequenceFlow { Every event and activity kind above also carries the flat engine settings Operaton reads off a flow node: `asyncBefore`, `asyncAfter`, `exclusive`, `jobPriority`, and `retryCycle` (the `operaton:failedJobRetryTimeCycle` element body), plus `executionListeners`. Only non-default values are stored, so `asyncBefore` and `asyncAfter` are `true` or absent and `exclusive` is `false` or absent. -On a node that carries a timer, `exclusive` is written on the `bpmn:timerEventDefinition` as well as on the event tag, since `BpmnParse.parseTimer` locks the timer job from the definition and the tag's copy reaches only the async continuation job; the importer takes the definition's value, falls back to the tag's, and warns once when the two are written apart ([ADR-0047](../../docs/decisions/0047-timer-job-settings-on-the-element-the-engine-reads.md)). +On a node that carries a timer, `exclusive` is written on the `bpmn:timerEventDefinition` as well as on the event tag, since `BpmnParse.parseTimer` locks the timer job from the definition and the tag's copy reaches only the async continuation job; the importer takes the definition's value, falls back to the tag's, and warns once when the two are written apart ([ADR-0021](../../docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md)). Every gateway kind carries the five settings too, on the statement head that synthesizes it, but none of the listeners. -`ExclusiveGateway`, `ParallelGateway`, `InclusiveGateway`, and `EventBasedGateway` extend `JobSettings` directly rather than the wider `EngineAttributes`, since a listener needs the textual identity a synthesized gateway has none of ([ADR-0022](../../docs/decisions/0022-engine-attributes-as-named-ir-fields.md), [ADR-0040](../../docs/decisions/0040-engine-settings-on-synthesized-gateways.md)). -A `StartEvent` and a `UserTask` also carry `formFields`, the `operaton:formData` block: a `FormField` has a type (`string`, `number`, `boolean`, `date`, or `enum`), a label, a default, the `datePattern` a date is parsed with, an enum's `values`, its `constraints` in the order the engine validates them, each a name from the closed `FormConstraintName` union and a `config`, and its `properties`, the `operaton:property` entries ([ADR-0037](../../docs/decisions/0037-form-field-constraints-values-and-properties.md)). +`ExclusiveGateway`, `ParallelGateway`, `InclusiveGateway`, and `EventBasedGateway` extend `JobSettings` directly rather than the wider `EngineAttributes`, since a listener needs the textual identity a synthesized gateway has none of ([ADR-0021](../../docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md), [ADR-0021](../../docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md)). +A `StartEvent` and a `UserTask` also carry `formFields`, the `operaton:formData` block: a `FormField` has a type (`string`, `number`, `boolean`, `date`, or `enum`), a label, a default, the `datePattern` a date is parsed with, an enum's `values`, its `constraints` in the order the engine validates them, each a name from the closed `FormConstraintName` union and a `config`, and its `properties`, the `operaton:property` entries ([ADR-0018](../../docs/decisions/0018-forms-and-field-injection.md)). The seven activity kinds additionally carry `inputParameters` and `outputParameters`, the `operaton:inputOutput` block in declaration order. An `IoParameter` is a name and one `IoValue`, tagged `text`, `script`, `list`, or `map`, so a value carrying two forms at once is unrepresentable rather than checked at runtime. The same seven carry an optional `loop`, a `LoopCharacteristics` holding the `cardinality`, the `collection` and the `elementVariable` each run binds from it, a `completionCondition`, and `sequential`, which is `true` or absent because the engine runs the instances at once by default. -`LoopCharacteristics` also extends `JobSettings` minus `jobPriority`: the four settings a repeated statement writes onto the `multiInstanceLoopCharacteristics` element itself, read by the engine onto each run rather than around the whole repetition ([ADR-0041](../../docs/decisions/0041-per-run-job-settings-on-a-repetition.md)). +`LoopCharacteristics` also extends `JobSettings` minus `jobPriority`: the four settings a repeated statement writes onto the `multiInstanceLoopCharacteristics` element itself, read by the engine onto each run rather than around the whole repetition ([ADR-0022](../../docs/decisions/0022-repetition-the-for-clause-and-its-per-run-job-settings.md)). A `UserTask` also carries `taskListeners`, whose events are the six points of a task's human lifecycle; `timeout` is the one that carries a timer. An `ExecutionListener` and a `TaskListener` carry exactly one binding, tagged `class`, `expression`, `delegateExpression`, or `script`, the first three being three of the forms a service task binds by and the fourth an inline script, which no service task takes. A `ServiceTask` binds to exactly one execution form, tagged by `binding.kind`: `class` (a Java delegate class, `operaton:class`), `expression`, `delegateExpression` (the DSL spells this one `delegate`), `external` (`operaton:type="external"` plus `operaton:topic`, written `service X(topic: "...")`), `decision` (`operaton:decisionRef` plus the shared version pinning and `mapDecisionResult`, legal only when `element` is `businessRule`), or `builtin` (`operaton:type="mail"`/`"shell"`, written `service X(type: "mail")`, a behaviour Operaton builds itself from the `fields` beside it). -The external variant alone carries `taskPriority` (`operaton:taskPriority`, an integer or an expression), `properties` (an `operaton:properties` block keyed by `name`), and `errorMappings`, each an `ErrorMapping` of the `errorCode` a `bpmn:error` root is derived for and the `condition` an `operaton:errorEventDefinition` evaluates, since `BpmnParse.parseExternalServiceTask` reads the three and no other binding reaches it ([ADR-0038](../../docs/decisions/0038-external-task-extras-ride-the-topic-binding.md)). +The external variant alone carries `taskPriority` (`operaton:taskPriority`, an integer or an expression), `properties` (an `operaton:properties` block keyed by `name`), and `errorMappings`, each an `ErrorMapping` of the `errorCode` a `bpmn:error` root is derived for and the `condition` an `operaton:errorEventDefinition` evaluates, since `BpmnParse.parseExternalServiceTask` reads the three and no other binding reaches it ([ADR-0017](../../docs/decisions/0017-service-task-bindings.md)). `element` picks which of the three tags this node serializes to: absent for `bpmn:serviceTask`, `'send'` for `bpmn:sendTask`, `'businessRule'` for `bpmn:businessRuleTask`. The tagged union makes "more than one binding" unrepresentable at the type level and keeps every `switch (binding.kind)` exhaustive. A document naming more than one is resolved the way Operaton resolves it: `operaton:type` outranks the code attributes, then `class`, then `delegateExpression`, then `expression`, and on a business rule task an `operaton:decisionRef` outranks all of them. @@ -103,8 +103,8 @@ Event semantics ride on an `eventDefinition` field, optional on a start or end e `terminate` and `cancel` join the union on an end event alone and are both payload-free: one stops every running path of its scope at once and the other gives up the block the end sits in. Neither raises anything for a handler to catch by name, which is why the surface spells both on `end` instead of `throw`. Compensation is the odd one out, because BPMN expresses it through `isForCompensation` and an association rather than a boundary event: every holder may carry it except `BoundaryEvent`. -`IntermediateCatchEvent` is restricted to message, signal, timer, condition, or link: the first four are the triggers a linear flow can block on and then continue past ([ADR-0020](../../docs/decisions/0020-intermediate-catch-events.md)), and link is the catching end of a pair added for the import direction ([ADR-0035](../../docs/decisions/0035-link-events-for-import-round-trip-symmetry.md)). -The document-level `bpmn:Error`, `bpmn:Escalation`, `bpmn:Message`, and `bpmn:Signal` roots are synthesized rather than modeled ([ADR-0016](../../docs/decisions/0016-derived-event-root-elements.md), [ADR-0017](../../docs/decisions/0017-event-trigger-payload-surfaces.md)). +`IntermediateCatchEvent` is restricted to message, signal, timer, condition, or link: the first four are the triggers a linear flow can block on and then continue past ([ADR-0026](../../docs/decisions/0026-intermediate-catch-events.md)), and link is the catching end of a pair added for the import direction ([ADR-0030](../../docs/decisions/0030-link-events-for-import-round-trip-symmetry.md)). +The document-level `bpmn:Error`, `bpmn:Escalation`, `bpmn:Message`, and `bpmn:Signal` roots are synthesized rather than modeled ([ADR-0023](../../docs/decisions/0023-event-roots-from-usage-and-declared-codes.md), [ADR-0024](../../docs/decisions/0024-event-trigger-payloads-paren-slot.md)). A message or signal root comes from the names in use, an error or escalation root from the codes in use together with `errorDecls` and `escalationDecls`, which is where a declared root's `name` and message text live. `IntermediateCatchEvent` is a one-in, one-out node on the main flow, the topological twin of `IntermediateThrowEvent` and differing only in whether the token fires forward immediately or waits. @@ -112,7 +112,7 @@ A link pair is the one exception: no flow leaves a link throw and none enters a The catch's id is the synthesized `Catch_` unless the `await` carries a name, which is what lets an imported catch keep the id it came with. A token appears at a `BoundaryEvent` while its host activity (named by `attachedToRef`, a flow element of the same container) is running, entering from the host when the trigger fires rather than by traversing a sequence flow into it. -[ADR-0009](../../docs/decisions/0009-dominator-based-restructuring.md) covers how the restructuring analysis treats that second control-flow entry, and [ADR-0019](../../docs/decisions/0019-boundary-events-attached-to-an-activity.md) the rest of the construct. +[ADR-0014](../../docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md) covers how the restructuring analysis treats that second control-flow entry, and [ADR-0027](../../docs/decisions/0027-boundary-events-attached-to-an-activity.md) the rest of the construct. `cancelActivity` mirrors `StartEvent.isInterrupting` in storing only the non-default `false`, an `alongside` boundary. ## Public API @@ -131,15 +131,15 @@ const { source, warnings: printWarnings } = irToDsl(ir); // sync; warns for what ## The import contract `xmlToIr` never discards an element without saying so. -What it cannot represent falls into two buckets; the reasoning is in [ADR-0014](../../docs/decisions/0014-honest-bpmn-import-contract.md), and the rule that draws the line where the engine's own parse draws it is in [ADR-0054](../../docs/decisions/0054-import-tiers-follow-the-engine-parse.md). +What it cannot represent falls into two buckets; the reasoning is in [ADR-0012](../../docs/decisions/0012-honest-bpmn-import.md), and the rule that draws the line where the engine's own parse draws it is in [ADR-0012](../../docs/decisions/0012-honest-bpmn-import.md). Content the IR cannot express at all is refused: `xmlToIr` throws a subclass of `UnsupportedConstructError` before producing any IR, so there is no partial output. Content Operaton refuses to deploy is refused the same way, since there is nothing that runs to write back, and the message names the `BpmnParse` method that fails the deployment. -A document whose root is not `bpmn:definitions`, a process or flow element without an `id`, or a sequence flow whose `sourceRef` or `targetRef` resolves to nothing is malformed rather than unsupported and throws a plain `Error`, which the CLI reports as a parse failure. +A document whose root is not `bpmn:definitions`, a process or flow element without an `id`, or a sequence flow whose `sourceRef` or `targetRef` resolves to nothing is malformed rather than unsupported and throws a plain `Error`, which the CLI reports as a parse failure and exits `2`. Content the IR does not carry (an extra Operaton extension attribute, a lane, a text annotation) comes back through the `warnings` array instead. Every drop of a setting the engine reads names the method that reads it and says the document written back runs without it, so a reader can tell a cosmetic drop from one that changes what runs. -The diagram interchange data aside ([ADR-0003](../../docs/decisions/0003-auto-layout-for-diagram-interchange.md)), no element is dropped in silence. +The diagram interchange data aside ([ADR-0009](../../docs/decisions/0009-auto-layout-and-expansion-hint.md)), no element is dropped in silence. Every BPMN element and every extension element `xmlToIr` cannot carry is reported, named by the tag the document spells and by its own id, and attributed to the element it sat on. A construct is reported whole, so a dropped `bpmn:ioSpecification` names itself rather than each data input inside it. The lane structure goes the same way, reported lane by lane, with anything hung on a lane or a lane set leaving with it. @@ -155,7 +155,7 @@ The same attribute on an Operaton extension element the IR reads is reported, a The other is a BPMN attribute this surface neither reads nor reports: a process's `processType` or `isClosed`. Each of those is content left out. A lone `operaton:resource` on a sequence flow's condition expression or a conditional event definition's condition is the one exception that is reported rather than dropped: Operaton reads it only alongside a `language`, so on its own it reaches nothing, and the condition still imports as the expression its body writes. -`isExecutable="false"`, whose import changes what the document says, is reported instead of left for the reader to notice, and so is an absent `isExecutable`, which `BpmnParse.parseProcessDefinitions` skips in a new deployment. +`isExecutable="false"`, whose import changes what the document says, is reported instead of left for the reader to notice, and so is an absent `isExecutable`, which `BpmnParse.parseProcessDefinitions` defaults to `!deployment.isNew()` and therefore reads as false in every deployment of a new resource, skipping the process. ### Refusals @@ -164,10 +164,11 @@ Every class below extends `UnsupportedConstructError`, so catching that one clas `UnsupportedDocumentError` covers a document Operaton refuses to deploy whole, or one this tool cannot read as one process. That is a `` declaration, which `Parser.setXxeProcessing` disallows, and an entity reference XML does not predefine, which the engine's parse fails on. -It is an `isSequential` or `triggeredByEvent` value outside `true` and `false`, which the validating parse against `BPMN20.xsd` refuses. +It is an `isSequential` or `triggeredByEvent` value outside `true` and `false`, which `Parse.execute`, the validating parse against `BPMN20.xsd`, refuses. It is an id written on two elements, which the same validation refuses as a duplicate `xs:ID`, and an id outside the ASCII letters, digits, `_`, `-`, and `.` this tool reads, where the schema admits any letter. It is a `bpmn:import`, which `BpmnParse.parseImports` fails on for every type but WSDL. And it is no `bpmn:process` at all, or several processes of which none is marked `isExecutable="true"`, which `parseProcessDefinitions` deploys none of. +The first three of those are refused by `refuseDocumentShapes` on the raw document text, before it is parsed, on a copy with the comments and CDATA sections cut out, so a `` element beside its implementation attributes, which the Connect plugin runs in their place. A mail or shell task refuses when it misses a field its behaviour's parse requires, names a field its behaviour class does not declare, or carries a shell field as an expression or a flag outside `true`/`false`. -A thrown message refuses on a binding shape this tool cannot read, a `mail`/`shell` type included, since a thrown message has no member block to carry the fields either requires ([ADR-0042](../../docs/decisions/0042-mail-and-shell-as-a-type-binding.md)). -A service, send, or business rule task without a decision reference refuses when it carries `operaton:resultVariable`, or the older `operaton:resultVariableName`, beside `operaton:class` or `operaton:delegateExpression`, which `BpmnParse.parseServiceTaskLike` refuses to deploy ([ADR-0043](../../docs/decisions/0043-result-variable-beside-an-expression-binding.md)). +A thrown message refuses on a binding shape this tool cannot read, a `mail`/`shell` type included, since a thrown message has no member block to carry the fields either requires ([ADR-0017](../../docs/decisions/0017-service-task-bindings.md)). +A service, send, or business rule task without a decision reference refuses when it carries `operaton:resultVariable`, or the older `operaton:resultVariableName`, beside `operaton:class` or `operaton:delegateExpression`, which `BpmnParse.parseServiceTaskLike` refuses to deploy ([ADR-0017](../../docs/decisions/0017-service-task-bindings.md)). A business rule task refuses on an `operaton:decisionRefTenantId`, which pins the tenant `BpmnParse.parseTenantId` resolves the decision against, on an `operaton:mapDecisionResult` outside the four words the engine maps, and on a `decisionRefBinding` of `version` without a version or of `versionTag`. `UnsupportedFormFieldTypeError` covers a form field typed outside `string`/`long`/`boolean`/`date`/`enum`. @@ -210,7 +211,7 @@ An error end with no `errorRef` refuses, and so does an error or escalation thro An escalation throw with no `escalationRef` or one naming no root refuses, and so does an escalation catch naming no root (`findEscalationForEscalationEventDefinition`, `createEscalationEventDefinitionForEscalationHandler`). Two `bpmn:signal` roots sharing a name refuse (`parseSignals`). A cancel definition refuses on an end outside a `bpmn:transaction`, on a boundary whose host is not one, and on a second cancel boundary of one block. -A wait with several branches carrying `operaton:asyncAfter` refuses (`parseEventBasedGateway`, [ADR-0040](../../docs/decisions/0040-engine-settings-on-synthesized-gateways.md)). +A wait with several branches carrying `operaton:asyncAfter` refuses (`parseEventBasedGateway`, [ADR-0021](../../docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md)). `UnsupportedGatewayShapeError` covers an exclusive gateway whose outgoing flows `BpmnParse.validateExclusiveGateway` fails the deployment on, quoting the engine's own sentence: no outgoing flow, one outgoing flow carrying a condition, a default flow carrying a condition, or a flow without a condition that is not the default, beside a default or beside another unconditioned flow. One conditioned flow beside one plain flow with no default imports, since the engine deploys it and takes the plain flow as the default. @@ -261,6 +262,7 @@ It is an `operaton:inputOutput` on an intermediate catch, an intermediate throw, It is `formKey`, `formRef`, its binding and version, or `formHandlerClass` on the process's own start (`parseStartFormHandlers`), and `jobPriority`, `taskPriority`, or `isStartableInTasklist` on the process (`parseProcess`). And it is `async` or an `operaton:in` payload on a thrown signal (`parseSignalEventDefinition`). A `failedJobRetryTimeCycle` written as an attribute is reported with its own sentence: the engine reads the element form alone. +A timer-started event sub-process's own `exclusive`, `jobPriority`, or `retryCycle` is dropped with a warning: that copy reaches only the sub-process's async continuation job, and the `on timer` head the handler prints as spells those keys for the timer job its start event creates. An `operaton:initiator` or an `operaton:formData` on a start that is not the process's own is reported the same way: `parseScopeStartEvent` reads no `operaton:` attribute there and `parseStartFormHandlers` runs for the process's own start alone. It is asked again of every extension child the IR does read, so an unread attribute there is reported rather than leaving with the element that imports: an undeclared `operaton:` or foreign-namespace attribute on a listener or an input/output parameter, and the `id`/`name` decoration on an `operaton:value` list item. An `operaton:taskListener`'s `id` is consumed without a word: on a `timeout` listener it is the key `BpmnParse.parseTimeoutTaskListener` requires and the export mints one per timeout listener from the task id, and on every other event `parseTaskListener` never reads it. @@ -277,22 +279,24 @@ A `bpmn:script` or `operaton:script` with an empty body is carried the same way, A repeated enum value id is rewritten the way `FormTypes.parseFormPropertyType` keeps it, once at its first position with its last `name`, and the warning names the rewrite. An external task's `operaton:taskPriority`, `operaton:properties`, or `operaton:errorEventDefinition` on a service, send, or business rule task bound by class, expression, delegate expression, decision, or a built-in mail or shell type is reported here too, one warning per item, since `parseExternalServiceTask` alone reads them and the step runs without them either way. On a thrown message the same three, and an injected field, are reported by their reader: with `operaton:type="external"` the sentence says `parseExternalServiceTask` reads it off the message definition (or off the event, for `operaton:properties`) and this surface's throw has no position for it, so the document written back runs without it; with any other binding, that the engine never reads it there. +A thrown message is a service task to the engine: `parseIntermediateThrowEvent` and `parseEndEvents` hand its message definition to `parseServiceTaskLike`, so every binding word, the injected fields, the result variable, the priority, and the error mappings are read off it, and a throw carrying the binding alone drops or refuses each of the rest. A `resultVariable` beside an `external` or a built-in `type` binding on a task, or beside `external` or no binding on a thrown message, is carried on the task and dropped on the throw, either way with a warning that `parseServiceTaskLike` hands it to an expression binding alone. The `errorCodeVariable` and `errorMessageVariable` on such a definition warn as they do on a thrown error: the engine stores them and reads them off the catching definition alone. A task's `operaton:property` is keyed by `name`, as `BpmnParseUtil.parseOperatonExtensionProperties` reads it, and a form field's by `id`, as `DefaultFormHandler.parseProperties` reads it, so each side reports the other attribute as unread; an entry missing its key or its value is skipped with a warning, and a repeated key is kept once at its first position with its last value, as both readers keep it. A repetition naming its collection in both the BPMN and the `operaton:` spelling keeps the one `MultiInstanceActivityBehavior.resolveNrOfInstances` reads and reports the other: when exactly one of the two is an expression (a value containing `{`), that one, since `parseMultiInstanceLoopCharacteristics` stores an expression and a variable name in two fields and the expression field is read first; otherwise the `bpmn:loopDataInputRef`, which the engine writes into the one field second. An element variable named in both spellings keeps the `bpmn:inputDataItem`, written second into the engine's one field. An implementation attribute that a higher-ranked one shadows is reported here too, on a service, send, or business rule task, on a thrown message, and on a call activity naming both of its variable-mapping attributes, since Operaton never reads past the binding it resolves. -An `operaton:in`/`out` on a call activity naming more than one shape keeps the one `parseCallableElementProvider` reads first and reports the rest: `variables="all"` ahead of a `source`, `sourceExpression`, or `target`, a `source` ahead of a `sourceExpression`, and a `businessKey` ahead of them all; a second `businessKey` keeps the last, as `setBusinessKeyValueProvider` overwrites. +An `operaton:in`/`out` on a call activity naming more than one shape keeps the one `parseCallableElementProvider` reads first and reports the rest: `variables="all"` ahead of a `source`, `sourceExpression`, or `target`, a `source` ahead of a `sourceExpression`, and a `businessKey` ahead of them all, which `parseInputParameter` takes while reading nothing else off that element; a second `businessKey` keeps the last, as `setBusinessKeyValueProvider` overwrites. Listeners repeat freely: several on one event import in document order, which is the order `CoreModelElement.addListenerToMap` and `TaskDefinition.addTaskListener` run them in. A listener naming two or more bindings keeps the first in the order `parseExecutionListener` and `parseTaskListener` read them (`class`, `expression`, `delegateExpression`, then an `operaton:script` child) and reports each loser. -An execution listener with `expression=""` and a task listener with `class=""`, `expression=""`, or `delegateExpression=""` carry the empty text with a warning, since the engine deploys each (an empty expression evaluates to the empty text; an empty class or delegate expression fails when the listener's event fires) and the printed script draws an error there. +An execution listener with `expression=""` and a task listener with `class=""`, `expression=""`, or `delegateExpression=""` carry the empty text with a warning, since the engine deploys each (`ExpressionExecutionListener` and `ExpressionTaskListener` evaluate the empty text, while `ClassDelegateTaskListener` and `DelegateExpressionTaskListener` fail when the listener's event fires) and the printed script draws an error there. +An empty `name`, `label`, `defaultValue`, or `operaton:assignee` is read as the empty text in the same way and carried as `""`, so the document written back spells the attribute as the source did; every other reader folds an empty attribute into an absent one. A timer under a task listener that is not a `timeout` is dropped with a warning, since `parseTaskListener` reads no event definition there. An input/output parameter carrying body text beside one nested value keeps the nested value and reports the text, as `BpmnParseUtil.parseNestedParamValueProvider` reads it. An engine setting or a listener on a link throw is reported here as well, one warning per item: Operaton creates no activity for a link throw and so never reads them, and the re-exported document runs the same without them. Attribution is exact wherever moddle ties the content to its owning element; the few undeclared `operaton:` elements it cannot pin down are reported once against the process id instead, coarser but still reported. On a call activity, `calledElementTenantId` is execution-affecting rather than cosmetic, so it is refused instead of warned about: it pins which tenant the engine resolves the called process against, so dropping it changes which process runs. -Neither variable-mapping attribute falls on that side of the boundary, since Operaton runs a mapping delegate after the declared `in` and `out` mappings rather than in place of them, so both import into the IR's `mapper` field instead ([ADR-0033](../../docs/decisions/0033-call-activity-variable-mapping.md)). +Neither variable-mapping attribute falls on that side of the boundary, since Operaton runs a mapping delegate after the declared `in` and `out` mappings rather than in place of them, so both import into the IR's `mapper` field instead ([ADR-0019](../../docs/decisions/0019-a-call-activitys-variable-mapping.md)). `lane` covers a `bpmn:Lane`, one warning per lane, a lane nested in a `bpmn:childLaneSet` included. The flat IR has no lane concept, so every step lands in one process and the assignment goes. @@ -302,7 +306,7 @@ Those read from their trigger and code, so a differing label has nowhere to rend A link event's `name` is written from its link name on export, so a `name` equal to the link name is derived and a differing one alone is reported. A sequence flow's `name` is reported here when it differs from the label the rebuilt document derives, which is the condition's body inside its `${...}` on a conditioned flow and nothing on a plain one; `name=""`, which the Modeler writes on every unconditioned flow, is not a label. It also covers a label on a start or an end whose id is the one the compiler mints for its container (`StartEvent_`, `EndEvent_`, or `EndEvent_` for a boundary escape), since a script cannot spell that id back: the statement that would have carried the label is left out whole for a start, and for an end only at its block's tail; elsewhere the end prints under its reserved id and the print reports it. -Any other id prints, respelled if the script cannot spell it, with the print reporting the rename ([ADR-0050](../../docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md)). +Any other id prints, respelled if the script cannot spell it, with the print reporting the rename ([ADR-0010](../../docs/decisions/0010-deterministic-synthesized-ids.md)). `unreferencedRoot` covers a `bpmn:Message` or `bpmn:Signal` root that nothing in the process references, a receive task's `messageRef` included. It also covers an error or escalation root carrying no code, which nothing can key it by, unless it is an error root carrying an `operaton:errorMessage`, which refuses; one carrying a code imports as a declaration whether or not anything raises it. @@ -310,14 +314,15 @@ It also covers an error or escalation root carrying no code, which nothing can k `documentation` covers `bpmn:documentation` at every position where `label` above is warned, for the same reason: an event handler, a typed end event, an intermediate throw, an intermediate catch, a boundary event, and a start or an end whose id is the one the compiler mints for its container. It also covers a `bpmn:documentation` on a position that reads no `name` at all: the definitions root, an event definition, an external task's `operaton:errorEventDefinition`, an Error/Escalation/Message/Signal root, a multi-instance loop characteristics element, and a sequence flow. A second `bpmn:documentation` child on one element, or one whose `textFormat` is set to anything but plaintext, falls under the category regardless of position. -Everywhere else the text is carried onto the IR node alongside its `name` ([ADR-0031](../../docs/decisions/0031-carry-bpmn-documentation.md)). +Everywhere else the text is carried onto the IR node alongside its `name` ([ADR-0012](../../docs/decisions/0012-honest-bpmn-import.md)). `unmappedConstruct` covers BPMN content no reader on this transform reads, one warning per construct. Beside the process: a `bpmn:collaboration`'s participants and message flows, which `BpmnParse.parseCollaboration` records for the diagram alone, one warning per pool naming the process it wraps and one per message flow naming its ends; a process not marked `isExecutable="true"` beside the one that is, which `parseProcessDefinitions` does not deploy either; and a root element other than the error, escalation, message, and signal roots the events resolve against, such as a `bpmn:category`, a `bpmn:dataStore`, a `bpmn:itemDefinition`, or a `bpmn:interface`. A process holding no flow element at all imports with a warning that the printed script draws an error, since a process needs a step. On an element it touches: an artifact on a process or sub-process (a `bpmn:textAnnotation`, its `bpmn:association`, a `bpmn:group`), a `bpmn:ioSpecification`, a `bpmn:property`, a data association, a `bpmn:auditing` or `bpmn:monitoring` block, and a resource role the engine reads nothing of. On a user task a `bpmn:humanPerformer` and a `bpmn:potentialOwner` are read instead, the way `BpmnParse.parseTaskDefinition` reads them: the performer's formal expression is the assignee, each owner's is split as `parseCommaSeparatedList` splits it into `user(x)` candidate users and `group(x)` or bare candidate groups, and the role-derived entries come before the `operaton:` attribute's own, in the engine's order. -One warning per role names the rewrite, since the script writes `assignee`, `candidateUsers`, and `candidateGroups` and the exported document carries the Operaton attributes alone; the engine builds the same identity links from either spelling ([ADR-0039](../../docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md)). +That split runs on the commas outside an expression alone, so a list written as one `${...}` or `#{...}` body stays a single entry. +One warning per role names the rewrite, since the script writes `assignee`, `candidateUsers`, and `candidateGroups` and the exported document carries the Operaton attributes alone; the engine builds the same identity links from either spelling ([ADR-0012](../../docs/decisions/0012-honest-bpmn-import.md)). An `operaton:potentialStarter` on the process is read the same way, as `parseStartAuthorization` reads it: its formal expression is split onto `candidateStarterUsers` and `candidateStarterGroups` after the attributes' own, with one warning per element naming the rewrite; one carrying no formal expression is dropped, since the engine reads nothing else off it. What the engine reads nothing of stays a drop: a `bpmn:performer` or a bare `bpmn:resourceRole`, a role with no `bpmn:formalExpression` child (a `bpmn:expression` typed `tFormalExpression` through `xsi:type` included, since the engine fetches the child by tag), a role's `resourceRef` or parameter binding, and any role on an activity other than a user task. A `startQuantity` or `completionQuantity` other than `1` on an activity is reported here too: BPMN declares both with a default of `1` and `BpmnParse` never reads either, so the step runs as if it read `1`, which is what the source document runs. @@ -343,7 +348,7 @@ A `bpmn:intermediateThrowEvent` carrying no event definition, the Modeler's mile A spelling the engine reads as one of its own is imported under the spelling this tool writes, with a warning naming the rewrite. `operaton:async="true"` imports as `asyncBefore` (`BpmnParse.isAsyncBefore`), and `operaton:resultVariableName` as `resultVariable` (`parseResultVariable`). A `calledElementBinding` or `decisionRefBinding` word outside the four `parseBinding` matches imports as `latest`, since `BaseCallableElement.isLatestBinding` reads no binding as latest. -An `errorRef` naming no `bpmn:error` root imports as the error code spelled by its text, on a catch, a throw, and an external task's mapping alike: every engine reader takes a dangling reference's text as the code, and the rebuilt document declares an error root carrying it. +An `errorRef` naming no `bpmn:error` root imports as the error code spelled by its text, on a catch, a throw, and an external task's mapping alike: `parseBoundaryErrorEventDefinition`, `parseErrorStartEventDefinition`, `parseEndEvents`, and `parseOperatonErrorEventDefinitions` each take a dangling reference's text as the code, and the rebuilt document declares an error root carrying it. Two more members report neither a drop nor a change, but a construct that arrived half-written. A cancel end and the cancel boundary event that catches it are wired together when Operaton parses the boundary, and nothing but such an end ever reaches such a boundary, so either half alone deploys and then goes wrong at run time. @@ -395,7 +400,7 @@ On a route beside an unconditioned fallback the model diverts the run rather tha Each prints as the model has it and draws an error when the source is read back. `renamedId` covers an id the script writes as a process header, a statement name, or a `goto` target and cannot spell, which prints under a minted name that no id in the document and no earlier minted name collides with. -The document built from the script carries the minted id, and the engine keys history, migration plans, and a start-before-activity on the activity id, so what runs is not the same ([ADR-0050](../../docs/decisions/0050-reserve-the-minted-ids-and-respell-unspellable-ones.md)). +The document built from the script carries the minted id, and the engine keys history, migration plans, and a start-before-activity on the activity id, so what runs is not the same ([ADR-0010](../../docs/decisions/0010-deterministic-synthesized-ids.md)). `droppedSetting` covers job settings on a gateway that no statement head took over by the end of the walk, an elided pass-through or a gateway a jump was forwarded through among them. The process runs without them. @@ -414,19 +419,19 @@ npm test ## Source layout -| Path | Purpose | -| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/ir/types.ts` | IR type definitions (`BpmnProcess`, `FlowElement`, `SequenceFlow`, ...) | -| `src/synthesize-ids.ts` | Deterministic structural id generators; the contract is frozen ([ADR-0010](../../docs/decisions/0010-deterministic-structural-ids.md)) | -| `src/ast-to-ir.ts` | `astToIr`: desugar the structured AST into flat IR (gateway synthesis, implicit start and end) | -| `src/ir-to-xml.ts` | `irToXml`: IR to BPMN 2.0 XML with Operaton extensions and auto-layout | -| `src/xml-to-ir.ts` | `xmlToIr`: BPMN 2.0 XML to `{ ir, warnings }` | -| `src/cfg-analysis.ts` | `analyzeCfg`: dominator, post-dominator, and back-edge analysis for `irToDsl` | -| `src/ir-to-dsl.ts` | `irToDsl`: restructure flat IR into `{ source, warnings }`, degrading to `goto` ([ADR-0009](../../docs/decisions/0009-dominator-based-restructuring.md)) | -| `src/juel.ts` | `parseJuel`, `renderRawFallback`: the JUEL-subset parser and serializer for the import path | -| `src/errors.ts` | `UnsupportedConstructError` and its refusal subclasses | -| `src/index.ts` | Package barrel export | -| `src/operaton-moddle.json` | Trimmed Operaton moddle extension descriptor ([ADR-0007](../../docs/decisions/0007-operaton-moddle-extension-fork.md)) | +| Path | Purpose | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/ir/types.ts` | IR type definitions (`BpmnProcess`, `FlowElement`, `SequenceFlow`, ...) | +| `src/synthesize-ids.ts` | Deterministic structural id generators; the contract is frozen ([ADR-0010](../../docs/decisions/0010-deterministic-synthesized-ids.md)) | +| `src/ast-to-ir.ts` | `astToIr`: desugar the structured AST into flat IR (gateway synthesis, implicit start and end) | +| `src/ir-to-xml.ts` | `irToXml`: IR to BPMN 2.0 XML with Operaton extensions and auto-layout | +| `src/xml-to-ir.ts` | `xmlToIr`: BPMN 2.0 XML to `{ ir, warnings }` | +| `src/cfg-analysis.ts` | `analyzeCfg`: dominator, post-dominator, and back-edge analysis for `irToDsl` | +| `src/ir-to-dsl.ts` | `irToDsl`: restructure flat IR into `{ source, warnings }`, degrading to `goto` ([ADR-0014](../../docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md)) | +| `src/juel.ts` | `parseJuel`, `renderRawFallback`: the JUEL-subset parser and serializer for the import path | +| `src/errors.ts` | `UnsupportedConstructError` and its refusal subclasses | +| `src/index.ts` | Package barrel export | +| `src/operaton-moddle.json` | Trimmed Operaton moddle extension descriptor ([ADR-0008](../../docs/decisions/0008-fork-camunda-moddle-extension.md)) | ## Implementation notes @@ -436,15 +441,15 @@ When `bpmn-auto-layout` throws on a validator-clean shape (a `goto` restructurin Operaton deploys it the same, since `BpmnParse.parseDiagramInterchangeElements` reads a diagram only when one is present. The CLI and the extension write that document either way and report the layout failure as a warning. Version 1.x exposes `layoutProcess(xml)` as a flat named export; the `new BpmnAutoLayout()` constructor belongs to the 0.x series and is not used here. -The Operaton namespace comes from `src/operaton-moddle.json`, a trimmed fork of the camunda-bpmn-moddle descriptor ([ADR-0007](../../docs/decisions/0007-operaton-moddle-extension-fork.md)). +The Operaton namespace comes from `src/operaton-moddle.json`, a trimmed fork of the camunda-bpmn-moddle descriptor ([ADR-0008](../../docs/decisions/0008-fork-camunda-moddle-extension.md)). -`irToDsl` recognizes structured patterns through the dominator analysis in `cfg-analysis.ts`, and edges that match nothing become `goto` ([ADR-0009](../../docs/decisions/0009-dominator-based-restructuring.md)). +`irToDsl` recognizes structured patterns through the dominator analysis in `cfg-analysis.ts`, and edges that match nothing become `goto` ([ADR-0014](../../docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md)). Every synthesized gateway, flow, and boundary-event id comes from `synthesize-ids.ts`. Gateway and flow ids are positional, derived from the element's structural coordinate. An unnamed throw or catch is minted a positional id the same way (`Throw__`, `Catch__`); compiling the same unnamed statement again after the printer reordered it (a multi-start chain, a hoisted branch) renumbers the id, which is cosmetic and does not change what the process runs. A boundary event's id is host-derived instead (`Boundary__`), so it survives a round trip unmoved no matter where the decompiler places the handler. -The templates are frozen: changing one means updating the round-trip normalizer and regenerating `tests/golden/invoice-approval-generated.bpmn` ([ADR-0010](../../docs/decisions/0010-deterministic-structural-ids.md), [ADR-0019](../../docs/decisions/0019-boundary-events-attached-to-an-activity.md)). +The templates are frozen: changing one means updating the round-trip normalizer and regenerating `tests/golden/invoice-approval-generated.bpmn` ([ADR-0010](../../docs/decisions/0010-deterministic-synthesized-ids.md), [ADR-0027](../../docs/decisions/0027-boundary-events-attached-to-an-activity.md)). `juel.ts` is a hand-rolled recursive-descent parser mirroring the Langium expression sub-grammar in `bpmn-script.langium`. It runs on the import path: `xmlToIr` reads raw `${...}` bodies, and `irToDsl` decides between native syntax and the quoted fallback. diff --git a/packages/transform/src/ast-to-ir.ts b/packages/transform/src/ast-to-ir.ts index 161ca8e..470b2a8 100644 --- a/packages/transform/src/ast-to-ir.ts +++ b/packages/transform/src/ast-to-ir.ts @@ -86,6 +86,7 @@ import { TASK_LISTENER_EVENTS, THROW_TRIGGERS, TIMER_PARTICLE_BY_KIND, + type TimerKind, isNamedStatement, } from '@bpmn-script/language'; import type { @@ -1371,12 +1372,10 @@ function handlerEventDefinition(stmt: OnHandler): EventDefinition { } /** `TIMER_PARTICLE_BY_KIND` read backwards; a timer with no readable time lands on `duration`. */ -function timerParticleKind( - particle: string | undefined, -): 'duration' | 'date' | 'cycle' { +function timerParticleKind(particle: string | undefined): TimerKind { for (const [kind, word] of Object.entries(TIMER_PARTICLE_BY_KIND)) { if (word === particle) { - return kind as 'duration' | 'date' | 'cycle'; + return kind as TimerKind; } } return 'duration'; diff --git a/packages/transform/src/host-messages.ts b/packages/transform/src/host-messages.ts new file mode 100644 index 0000000..2b2f99d --- /dev/null +++ b/packages/transform/src/host-messages.ts @@ -0,0 +1,59 @@ +/** + * What a host shows a user for a file it cannot convert, plus the checks that + * catch input no BPMN parser can do anything with. The CLI and the VS Code + * extension both put these strings in front of a person, so they carry no host + * wording: a caller adds what it wants around them. + */ + +// `moddle-xml`'s one message for a root that resolves to no registered type; +// it never says what it found. +const WRONG_ROOT_MESSAGE = 'failed to parse document as '; +const BPMN_NAMESPACE = 'http://www.omg.org/spec/BPMN/20100524/MODEL'; + +/** The message for input that is empty, as opposed to merely not XML. */ +export const EMPTY_INPUT_MESSAGE = 'the file is empty'; + +/** The message for a source that parses but declares no process. */ +export const NO_PROCESS_MESSAGE = 'the file has no process'; + +function describeRoot(xml: string): string { + const tag = /<([\w:]+)/.exec(xml)?.[1]; + if (tag === undefined) return ''; + // A greedy `[^>]*` backtracks from the tag's end, so it lands on the last + // xmlns declared on the root, not the one for the root's own prefix. + const colon = tag.indexOf(':'); + const decl = colon === -1 ? 'xmlns' : `xmlns:${tag.slice(0, colon)}`; + const uri = new RegExp(`<${tag}[^>]*?\\s${decl}="([^"]+)"`).exec(xml)?.[1]; + const found = uri === undefined ? '' : `; found ${decl}=${uri}`; + return ` (root element is <${tag}>; expected in namespace ${BPMN_NAMESPACE}${found})`; +} + +function printablePreview(text: string): string { + return text.slice(0, 200).replace(/\p{C}/gu, '').slice(0, 40); +} + +/** + * `saxen`'s parse errors quote the unparsed remainder of the document after a + * newline: megabytes for a large or binary file, control characters included. + */ +export function readableParseError(message: string, xml: string): string { + const firstLine = message.split('\n')[0]; + const capped = ( + firstLine.length > 200 ? firstLine.slice(0, 200) + '...' : firstLine + ).replace(/\p{C}/gu, ''); + return capped === WRONG_ROOT_MESSAGE ? capped + describeRoot(xml) : capped; +} + +/** + * Why `xml` cannot be a BPMN document, before a parser quotes it back in full, + * or undefined when it is worth parsing. + */ +export function xmlInputProblem(xml: string): string | undefined { + // `trim` treats a leading BOM as whitespace (ECMA-262 WhiteSpace includes + // U+FEFF), so neither check needs a BOM strip. + if (xml.trim() === '') return EMPTY_INPUT_MESSAGE; + if (!xml.trimStart().startsWith('<')) { + return `not an XML document (starts with "${printablePreview(xml.trimStart())}")`; + } + return undefined; +} diff --git a/packages/transform/src/index.ts b/packages/transform/src/index.ts index 008f6e5..0e6e581 100644 --- a/packages/transform/src/index.ts +++ b/packages/transform/src/index.ts @@ -107,3 +107,9 @@ export { export { astToIr } from './ast-to-ir.js'; export { irToDsl, UNSTRUCTURED_MARKER } from './ir-to-dsl.js'; export type { PrintWarning, PrintWarningCategory } from './ir-to-dsl.js'; +export { + EMPTY_INPUT_MESSAGE, + NO_PROCESS_MESSAGE, + readableParseError, + xmlInputProblem, +} from './host-messages.js'; diff --git a/packages/transform/src/ir-to-dsl.ts b/packages/transform/src/ir-to-dsl.ts index e764907..fa3aa5c 100644 --- a/packages/transform/src/ir-to-dsl.ts +++ b/packages/transform/src/ir-to-dsl.ts @@ -16,12 +16,20 @@ import { END_TRIGGERS, ENGINE_KEYS, type EngineKey, + ERROR_MAPPING_HEAD, + ERROR_MAPPING_WHEN, EXPRESSION_OPEN, + EXTERNAL_BINDING_KEY, EXTERNAL_TASK_EL_NAME, + FIELD_DIRECTION, + INPUT_DIRECTION, isReservedName, joinSettingKey, LOOP_VARIABLES, + OUTPUT_DIRECTION, + PROPERTY_DIRECTION, runSettingKey, + TASK_PRIORITY_KEY, TIMER_PARTICLE_BY_KIND, TYPE_BINDING_KEY, USER_TASK_VERBATIM_KEYS, @@ -2303,17 +2311,23 @@ function engineSettings(el: EngineAttributes & Repeatable): string[] { */ function fieldMembers(binding: ServiceTaskBinding | ListenerBinding): Lines[] { const fields = carriesFields(binding) ? (binding.fields ?? []) : []; - return fields.map((field) => [`field ${field.name} = ${quote(field.value)}`]); + return fields.map((field) => [ + `${FIELD_DIRECTION} ${field.name} = ${quote(field.value)}`, + ]); } /** Inputs before outputs, each in IR order, which the engine evaluates in. */ function ioParameters(el: IoMapped): Lines[] { const members: Lines[] = []; for (const param of el.inputParameters ?? []) { - members.push([`input ${param.name} = ${renderIoValue(param.value)}`]); + members.push([ + `${INPUT_DIRECTION} ${param.name} = ${renderIoValue(param.value)}`, + ]); } for (const param of el.outputParameters ?? []) { - members.push([`output ${param.name} = ${renderIoValue(param.value)}`]); + members.push([ + `${OUTPUT_DIRECTION} ${param.name} = ${renderIoValue(param.value)}`, + ]); } return members; } @@ -2799,7 +2813,7 @@ function renderFormValue(value: FormFieldValue): string { /** `quote`, not `quoteLiteral`: a `${...}` value has to re-lex as a raw expression. */ function propertyMembers(properties: ExtensionProperty[] | undefined): Lines[] { return (properties ?? []).map((p) => [ - `property ${p.key} = ${quote(p.value)}`, + `${PROPERTY_DIRECTION} ${p.key} = ${quote(p.value)}`, ]); } @@ -2813,7 +2827,7 @@ function errorMappingMembers( names: PrintNames, ): Lines[] { return (mappings ?? []).map((m) => [ - `error ${names.error.get(m.errorCode) ?? m.errorCode} when ${renderRawCondition(m.condition)}`, + `${ERROR_MAPPING_HEAD} ${names.error.get(m.errorCode) ?? m.errorCode} ${ERROR_MAPPING_WHEN} ${renderRawCondition(m.condition)}`, ]); } @@ -2879,11 +2893,14 @@ function bindingSettings(binding: ServiceTaskBinding): string[] { return [renderCodeBinding(binding)]; case 'external': return [ - setting('topic', quote(binding.topic)), + setting(EXTERNAL_BINDING_KEY, quote(binding.topic)), ...(binding.taskPriority === undefined ? [] : [ - setting('taskPriority', renderNumericValue(binding.taskPriority)), + setting( + TASK_PRIORITY_KEY, + renderNumericValue(binding.taskPriority), + ), ]), ]; case 'decision': diff --git a/packages/transform/src/xml-to-ir.ts b/packages/transform/src/xml-to-ir.ts index 98f206c..eebdecc 100644 --- a/packages/transform/src/xml-to-ir.ts +++ b/packages/transform/src/xml-to-ir.ts @@ -2021,7 +2021,7 @@ const BOUNDARY_HOST_NOUNS = { scriptTask: 'script task', subProcess: 'subprocess', callActivity: 'call activity', -} as const satisfies Partial>; +} as const satisfies Record<(typeof ACTIVITY_KINDS)[number], string>; type BoundaryHostKind = keyof typeof BOUNDARY_HOST_NOUNS; type BoundaryHost = Extract; diff --git a/packages/transform/test/ast-to-ir.test.ts b/packages/transform/test/ast-to-ir.test.ts index ba67986..c9b8cae 100644 --- a/packages/transform/test/ast-to-ir.test.ts +++ b/packages/transform/test/ast-to-ir.test.ts @@ -74,7 +74,7 @@ beforeAll(() => { parse = parseHelper(services.BpmnScript); }); -// ── Test helpers ───────────────────────────────────────────────────────────── +// Test helpers async function ir(source: string): Promise { const doc = await parse(source); @@ -202,7 +202,7 @@ describe('astToIr: implicit sequence and implicit start/end', () => { expect(only(result, 'startEvent').id).toBe(startId); expect(only(result, 'endEvent').id).toBe(endId); - expect(flow(result, startId, endId)).toBeDefined(); + flow(result, startId, endId); }); }); @@ -1091,8 +1091,8 @@ describe('astToIr: call activity lowering', () => { const startId = makeStartEventId('p', new Set()); const endId = makeEndEventId('p', new Set()); - expect(flow(result, startId, 'F')).toBeDefined(); - expect(flow(result, 'F', endId)).toBeDefined(); + flow(result, startId, 'F'); + flow(result, 'F', endId); const call = only(result, 'callActivity'); expect(call).toEqual(callActivity('F', 'fulfilment')); @@ -1219,8 +1219,8 @@ describe('astToIr: on-handler lowering', () => { sequenceFlows: [edge(startId, 'R'), edge('R', endId)], }); - expect(flow(result, 'StartEvent_p', 'A')).toBeDefined(); - expect(flow(result, 'A', 'EndEvent_p')).toBeDefined(); + flow(result, 'StartEvent_p', 'A'); + flow(result, 'A', 'EndEvent_p'); for (const f of result.sequenceFlows) { expect(f.sourceRef).not.toBe(handlerId); expect(f.targetRef).not.toBe(handlerId); @@ -1436,7 +1436,7 @@ describe('astToIr: hosted-handler lowering', () => { const result = await afterA('on A: error(PF) { }', 'error PF'); const boundaryId = makeBoundaryEventId('A', 'error', new Set()); const endId = makeEndEventId(boundaryId, new Set()); - expect(flow(result, boundaryId, endId)).toBeDefined(); + flow(result, boundaryId, endId); expect(byId(result, endId).kind).toBe('endEvent'); }); @@ -1453,8 +1453,8 @@ describe('astToIr: hosted-handler lowering', () => { 'Boundary_A_timer', 'Boundary_A_timer_2', ]); - expect(flow(result, 'Boundary_A_timer', 'R')).toBeDefined(); - expect(flow(result, 'Boundary_A_timer_2', 'S')).toBeDefined(); + flow(result, 'Boundary_A_timer', 'R'); + flow(result, 'Boundary_A_timer_2', 'S'); }); it('lands a handler hosted inside a sub-process in that sub-process container', async () => { @@ -1609,8 +1609,8 @@ describe('astToIr: throw/emit lowering', () => { const result = await throwChain(statement); expect(byId(result, emitId).kind).toBe('intermediateThrowEvent'); expect(definitionOf(result, emitId)).toEqual(expected); - expect(flow(result, 'A', emitId)).toBeDefined(); - expect(flow(result, emitId, 'B')).toBeDefined(); + flow(result, 'A', emitId); + flow(result, emitId, 'B'); }, ); @@ -1739,8 +1739,8 @@ describe('astToIr: handler inside a sub-process', () => { }`, ); const sub = subProcess(result, 'S'); - expect(flow(sub, 'StartEvent_S', 'A')).toBeDefined(); - expect(flow(sub, 'A', 'EndEvent_S')).toBeDefined(); + flow(sub, 'StartEvent_S', 'A'); + flow(sub, 'A', 'EndEvent_S'); const handlerId = makeEventSubProcessId('p_0_1'); const handler = subProcess(sub, handlerId); @@ -1762,8 +1762,8 @@ describe('astToIr: await intermediate catch lowering', () => { const catchId = makeIntermediateCatchEventId('p_1'); expect(byId(result, catchId).kind).toBe('intermediateCatchEvent'); expect(definitionOf(result, catchId)).toEqual(messageDef('M')); - expect(flow(result, 'A', catchId)).toBeDefined(); - expect(flow(result, catchId, 'B')).toBeDefined(); + flow(result, 'A', catchId); + flow(result, catchId, 'B'); }); it.each([ @@ -2991,7 +2991,7 @@ describe('astToIr: race lowering', () => { const empty = await ir( `process P { await { message("M") { } signal("S") { user B } } }`, ); - expect(flow(empty, firstCatch, joinId)).toBeDefined(); + flow(empty, firstCatch, joinId); const terminating = await ir( `process P { error Failed await { message("M") { end Done } signal("S") { throw error B(Failed) } } }`, @@ -3154,7 +3154,7 @@ describe('astToIr: gateway settings', () => { }); }); -// ── Local helpers ──────────────────────────────────────────────────────────── +// Local helpers function definitionOf(container: FlowContainer, id: string): unknown { return (byId(container, id) as { eventDefinition?: unknown }).eventDefinition; diff --git a/packages/transform/test/ir-to-xml.test.ts b/packages/transform/test/ir-to-xml.test.ts index 40665fe..660b241 100644 --- a/packages/transform/test/ir-to-xml.test.ts +++ b/packages/transform/test/ir-to-xml.test.ts @@ -84,7 +84,7 @@ const importShapedIr: BpmnProcess = { name: 'Invoice Approval', }; -// ── Shared XML output ──────────────────────────────────────────────────────── +// Shared XML output let xml: string; @@ -2741,7 +2741,7 @@ describe('irToXml: blocks that can be given up', () => { }); }); -// ── Helpers ────────────────────────────────────────────────────────────────── +// Helpers /** * Re-read with the Operaton extension registered, the stricter read: an diff --git a/packages/transform/test/ir/types.test.ts b/packages/transform/test/ir/types.test.ts index a8834cd..72b421c 100644 --- a/packages/transform/test/ir/types.test.ts +++ b/packages/transform/test/ir/types.test.ts @@ -377,7 +377,8 @@ const ENGINE_ATTRIBUTES: EngineAttributes = { * extra fields (`versionTag`, `resultVariable`, the user-task assignment * fields, `taskListeners`) sit alongside them. The tuple annotation gives each * literal its exact type, so a literal only compiles if every field the table - * promises is present on that interface. + * promises is present on that interface. Nothing runs these literals; `tsc -b` + * compiles this file, which is where the check lives. */ const TYPE_TABLE: [ StartEvent, @@ -554,22 +555,14 @@ describe('eventIdentities: an external service task mapping counts as a use of i }); }); -describe('the IR type table', () => { - it('compiles', () => { - // The literals above are the content: each one only type-checks if the IR - // interfaces still carry the fields the table promises, and each - // `@ts-expect-error` only type-checks while the constraint it names holds. - // This keeps them referenced and confirms every one is a real value. - for (const literal of [ - ...TYPE_TABLE, - GATEWAY_WITH_JOB_SETTINGS, - GATEWAY_WITH_IO_MAPPED, - EXCLUSIVE_AT_DEFAULT, - IO_VALUE_WITH_TWO_FORMS, - LISTENER_BINDING_WITH_NONE, - LISTENER_BINDING_WITH_TWO, - ]) { - expect(literal).toBeTypeOf('object'); - } - }); -}); +// The literal tables above are compiled, never run: `tsc -b` is the check, and +// exporting them is what keeps `noUnusedLocals` from failing on them. +export { + TYPE_TABLE, + GATEWAY_WITH_JOB_SETTINGS, + GATEWAY_WITH_IO_MAPPED, + EXCLUSIVE_AT_DEFAULT, + IO_VALUE_WITH_TWO_FORMS, + LISTENER_BINDING_WITH_NONE, + LISTENER_BINDING_WITH_TWO, +}; diff --git a/tests/boundary-events.round-trip.test.ts b/tests/boundary-events.round-trip.test.ts index 054cc4c..35fb9d3 100644 --- a/tests/boundary-events.round-trip.test.ts +++ b/tests/boundary-events.round-trip.test.ts @@ -336,7 +336,7 @@ describeImportFirst( ]); }); - it('keeps every host, trigger payload, and cancelActivity paired correctly', () => { + it('keeps every host, trigger payload, and cancelActivity paired', () => { const expected = [ 'InspectCrate error MISSING_ITEM interrupting', 'InspectCrate error TORN_BOX interrupting', diff --git a/tests/golden/README.md b/tests/golden/README.md index cd9e0ab..d1992b0 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -156,7 +156,7 @@ Both handler forms are here so the placement rule is pinned in the artifact rath A hosted `on ApprovePayout: timer` writes its settings on the boundary event it lowers to, and its `exclusive` on the timer definition as well, since `BpmnParse.parseTimer` locks the timer job from there and the tag's copy reaches only the `asyncAfter` job. A host-less `on escalation` writes them on the event sub-process rather than on the trigger start event nested inside it. That start event's own block belongs to the `start` statement written inside the handler body, and a synthesized start prints no statement at all, so a setting stored there could have nowhere to go. -A host-less `on timer` is the one exception: its `jobPriority`, `retryCycle` and `exclusive` go on the trigger start event, whose job they configure, and the printer lifts them back into the head as long as that start prints no statement ([ADR-0047](../../docs/decisions/0047-timer-job-settings-on-the-element-the-engine-reads.md)). +A host-less `on timer` is the one exception: its `jobPriority`, `retryCycle` and `exclusive` go on the trigger start event, whose job they configure, and the printer lifts them back into the head as long as that start prints no statement ([ADR-0021](../../docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md)). A `while`, an `if`, and a `parallel` put five synthesized gateways in the same artifact, none of which is written with a setting, so the pair pins that no direction invents one on a gateway; what a gateway written with settings carries is the `gateway-settings` pair's to pin. Every named node carries an explicit id, and the frozen artifact imports without a single warning. @@ -167,7 +167,7 @@ Contract: the `(node id, attribute, value)` table in `tests/engine-attributes.ro A process exercising the `operaton:inputOutput` block in all four value forms, a scalar, an inline script, a list, and a map, on a user task, a service task, a script task, a subprocess, and a call. The scalar and the inline script each appear in both directions; as a parameter's own value the list is only ever an input and the map only ever an output, and each of the two structured forms nests inside the other. The call carries its own `in`/`out` variable mappings beside its parameters, so one artifact pins the two mechanisms as distinct: a variable mapping crosses the process boundary into the callee, a parameter binds a value into the activity's own execution scope. -The `on message` handler at the end carries no parameter: it lowers to an event sub-process, which `BpmnParse.checkActivityInputOutputSupported` refuses a mapping on ([ADR-0046](../../docs/decisions/0046-listener-shapes-the-engine-deploys.md)). +The `on message` handler at the end carries no parameter: it lowers to an event sub-process, which `BpmnParse.checkActivityInputOutputSupported` refuses a mapping on ([ADR-0020](../../docs/decisions/0020-listeners-reuse-on-inside-the-attribute-block.md)). Contract: the parameter names and their declaration order per direction, each value's form, the `scriptFormat` on each inline script, and the call's variable mappings serialized beside the `operaton:inputOutput` block rather than inside it. @@ -176,7 +176,7 @@ Contract: the parameter names and their declaration order per direction, each va A process registering execution listeners on a service task, a subprocess, an end event, and an `on message` handler, and all six task-listener events, `create`, `assignment`, `complete`, `update`, `delete`, and `timeout`, on one user task. The service task carries both execution events, `start` and `end`; the other three carry one of the two each. All four bindings appear, a Java class, a JUEL expression, a delegate expression, and an inline fenced script, and the `timeout` listener carries the timer clause a caught timer event spells the same way. -The `timeout` listener is written with the id `InspectVehicle_timeout_1`, which `BpmnParse.parseTimeoutTaskListener` requires and the export mints from the task id, and the `on message` handler carries no parameter, since the engine refuses a mapping on the event sub-process it lowers to ([ADR-0046](../../docs/decisions/0046-listener-shapes-the-engine-deploys.md)). +The `timeout` listener is written with the id `InspectVehicle_timeout_1`, which `BpmnParse.parseTimeoutTaskListener` requires and the export mints from the task id, and the `on message` handler carries no parameter, since the engine refuses a mapping on the event sub-process it lowers to ([ADR-0020](../../docs/decisions/0020-listeners-reuse-on-inside-the-attribute-block.md)). Three bindings in the artifact also carry an injected field, every carrier one rides but the built-in `type` binding the `mail-and-shell` pair pins: the service task's own class binding takes two, a literal and a `${...}` expression, its `on start` execution listener takes one, and the delegate-bound `on complete` task listener takes one. The expression-bound `on assignment` listener carries none, because Operaton hands a field list to a class binding, a delegate binding, and a built-in `type` binding, and to no other. diff --git a/tests/vitest.config.ts b/tests/vitest.config.ts index 2f0320a..a52638a 100644 --- a/tests/vitest.config.ts +++ b/tests/vitest.config.ts @@ -7,5 +7,9 @@ export default defineConfig({ }, include: ['**/*.test.ts'], exclude: ['out/**', 'node_modules/**'], + // Each end-to-end file boots its own Operaton container, and Vitest runs + // one file per worker, so on a hosted runner the containers have to take + // turns. + maxWorkers: process.env.CI ? 2 : undefined, }, }); From abec6851f4c84fd183333982517e5b27d01c743c Mon Sep 17 00:00:00 2001 From: Marlon Kranz Date: Sat, 26 Sep 2026 14:17:02 +0200 Subject: [PATCH 07/11] Add a round-trip fuzz harness and fix the printer shapes it finds A seeded generator, a model comparator up to documented canonicalization, and a regression table. The printer fixes cover loops entered by gotos, jump cycles, loops with no exit, branch ownership, link pairs, boundary handler order, authored ids, and independence from element and route order. --- CONTRIBUTING.md | 12 +- README.md | 46 +- ...boundary-events-attached-to-an-activity.md | 8 +- .../cli/test/decompile-contract.e2e.test.ts | 26 +- packages/language/README.md | 62 +- packages/transform/src/cfg-analysis.ts | 563 ++- packages/transform/src/ir-to-dsl.ts | 1603 ++++++++- packages/transform/src/synthesize-ids.ts | 7 +- packages/transform/test/cfg-analysis.test.ts | 333 +- packages/transform/test/ir-to-dsl.test.ts | 297 +- packages/transform/test/xml-to-ir.test.ts | 30 +- tests/cyclic-loop-performance.test.ts | 46 + tests/fuzz-regressions.test.ts | 3071 +++++++++++++++++ tests/fuzz.test.ts | 1041 ++++++ tests/gateway-settings.round-trip.test.ts | 23 + tests/goto-fallback.round-trip.test.ts | 21 +- tests/helpers/fuzz-generator.ts | 2402 +++++++++++++ tests/helpers/model-equivalence.test.ts | 270 ++ tests/helpers/model-equivalence.ts | 327 ++ tests/helpers/pipeline.ts | 16 +- tests/invoice-receipt.import.test.ts | 2 +- tests/round-trip-constructs.test.ts | 17 +- 22 files changed, 9853 insertions(+), 370 deletions(-) create mode 100644 tests/cyclic-loop-performance.test.ts create mode 100644 tests/fuzz-regressions.test.ts create mode 100644 tests/fuzz.test.ts create mode 100644 tests/helpers/fuzz-generator.ts create mode 100644 tests/helpers/model-equivalence.test.ts create mode 100644 tests/helpers/model-equivalence.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03c732e..cbcca93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,13 +64,15 @@ CI's build job still runs `git diff --exit-code` right after `npm run build`; th ### Running tests ```sh -npm test # Full suite (all packages + e2e) -npm test --workspace packages/language # Language package only -npm test --workspace packages/transform # Transform package only -npm test --workspace packages/cli # CLI package only -npm test --workspace packages/extension # Extension package only +npm test # Full suite (all packages + e2e) +npm test --workspace packages/language # Language package only +npm test --workspace packages/transform # Transform package only +npm test --workspace packages/cli # CLI package only +npm test --workspace packages/extension # Extension package only +FUZZ_N=2000 FUZZ_OUT=/tmp/fuzz npm test --workspace tests -- fuzz.test.ts # Round-trip fuzz over 2000 generated programs ``` +`npm test` runs the round-trip fuzz over the first 200 generated programs as part of the tests workspace. The extension tests require `@bpmn-script/language` and `@bpmn-script/transform` to be built first (they are consumed as compiled `out/` directories). Run `npm run build` from the repo root before running the extension suite in isolation. diff --git a/README.md b/README.md index f91ecfc..08442b0 100644 --- a/README.md +++ b/README.md @@ -118,50 +118,10 @@ Every step carries an id (`user ReviewInvoice`), which is what `goto` and bounda A step, a call and a sub-process take their diagram name from that id (`ReviewInvoice` -> "Review Invoice") unless a `label` setting gives one instead; a `start` and an `end` are named by `label` alone, and their id is never humanized into a name. The event statements `on`, `throw`, `emit` and `await` are the exception: their id is a jump target only, so they take no `label` and show no name at all, except on the two ends of a link pair, which carry their link name. -| BPMNscript | What it means | BPMN element | -| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `process X { }` | the bpmn process, one per file | `bpmn:process` | -| `var x: number` | declare a variable so its uses get type-checked | none; the importer declares `var x: any` for every variable the printed script reads and nothing else declares | -| `start X` / `end X` | where the flow begins and ends | start / end event | -| `start X (...)` | start when an event arrives | start event with a trigger | -| `end X terminate` | stop every running path | terminate end event | -| `end X cancel` | give up the block of steps around this end | cancel end event | -| `form { x: number "Label" }` | a form on a `start` or a `user` step; on a start it pre-fills variables | form fields on the start event or user task | -| `user X(assignee: "...")` | a step a person performs | user task | -| `service X(class: "...")` | a step the system performs, in the engine | service task | -| `service X(topic: "...")` | a step an external worker picks up by topic | service task, external type | -| `service X(type: "shell")` | a step the engine runs itself, a shell command or a mail | service task, built-in type | -| `script X` + a fenced body | an inline script, in JS, Groovy, Python, Ruby or FEEL | script task | -| `step X` | a step nothing in the engine automates | task | -| `send X(class: "...")` | a step that sends something out | send task | -| `receive X(message: "...")` | wait here until that message arrives | receive task | -| `decide X(decision: "...")` | a step a decision table answers | business rule task | -| `if` / `else if` / `else` | a decision | exclusive gateway | -| `while` / `do ... while` | a loop | exclusive gateway loop | -| `parallel { { } { } }` | branches that run at the same time | parallel gateway fork and join | -| `parallel { if (c) { } else { } }` | the branches whose condition holds run, or the `else` branch when none does | inclusive gateway fork and join | -| `subprocess X { }` | a group of steps as one unit | embedded sub-process | -| `attempt X { }` | a group of steps that can be given up as one | transaction sub-process | -| `call X(process: "other")` | start another process and wait for it | call activity | -| `goto X` | jump to a named step in the same container | sequence flow | -| `on { }` | catch an event anywhere in this body | event sub-process | -| `on Host: { }` | catch an event only while `Host` runs | boundary event | -| `await (...)` | stop here until the event arrives | intermediate catch event | -| `await { (...) { } (...) { } }` | wait on several triggers, continue down whichever fires first | event-based gateway, a catch event per branch, exclusive join | -| `throw` / `emit ` | raise an event, ending the path or continuing | throw event | -| `emit link("X")` / `await link("X")` | jump to the catch of the same name, a diagram's off-page connector | intermediate throw and catch event, with no flow between them | -| `for each x in c` | run a step once per item, or a set number of times | multi-instance marker | -| `asyncBefore: true` | engine settings: async, exclusive, priority, retries, or, with a `run` prefix, on each run of a repetition | `operaton:` attribute/element | -| `input x = "..."` | data into a step's execution, and `output` back out | `operaton:inputOutput` | -| `on create(class: "...")` | code on a step's lifecycle, not a caught event | execution / task listener | - -The `for` row is a modifier rather than a statement: every activity in the table takes it between the id and the parens, meaning `user`, `service`, `script`, `step`, `send`, `receive`, `decide`, `subprocess`, `attempt`, and `call`. -The last three rows are members of an element rather than statements: an engine setting joins the rest in the `( )`, a parameter and a listener go in the `{ }`, and not every element takes every one. -The process header's own settings are written with the other process declarations. -The listener row reuses `on` instead of opening a new event sub-process; the body block a handler always carries and a listener never does is what tells the two apart ([ADR-0020](docs/decisions/0020-listeners-reuse-on-inside-the-attribute-block.md)). -The `attempt` head and the `cancel` end are one construct: the end gives up the block it sits in, `on : cancel` beside the block catches it, and the block's finished steps that carry an `on compensation` block (the undo block) are undone in between. +The [statement table](packages/language/README.md#statements) lists every statement, what it means, and the BPMN element it compiles to. +The same page gives the keys each element takes, which elements take parameters and listeners, and what a conditioned `parallel` branch compiles to. -[packages/language/README.md](packages/language/README.md) is the full specification: which keys each element takes, which elements take parameters and listeners, and what a conditioned `parallel` branch compiles to. +The `attempt` head and the `cancel` end are one construct: the end gives up the block it sits in, `on : cancel` beside the block catches it, and the block's finished steps that carry an `on compensation` block (the undo block) are undone in between. ### Events diff --git a/docs/decisions/0027-boundary-events-attached-to-an-activity.md b/docs/decisions/0027-boundary-events-attached-to-an-activity.md index 53847ea..4213901 100644 --- a/docs/decisions/0027-boundary-events-attached-to-an-activity.md +++ b/docs/decisions/0027-boundary-events-attached-to-an-activity.md @@ -57,7 +57,9 @@ The dominance result is correct: a region two independent entry points can both A boundary event's id is `Boundary__`, collision-resolved against the same document-wide `taken` set the implicit start and end ids already share. The one case this scheme does not fully disambiguate is two boundary handlers sharing a host and a trigger but differing in code: `on Pack: error(A)` and `on Pack: error(B)` are legal, non-duplicate handlers under the validator's `(host, trigger, code)` key, yet both base to the identical `Boundary_Pack_error`, so which one receives the `_2` suffix is positional. -That is stable in the generation direction, since lowering assigns suffixes in statement order and the decompiler reprints handlers in that same order, but it is exposed on import, where another tool may present the two in whatever order it chose. +That is stable in the generation direction, since lowering assigns suffixes in statement order. +The decompiler reprints such a group in the rank each handler's own id already carries, the id equal to the base first and `_2`, `_3` and so on after it, so a reimport that lists the group in a different order still keeps each handler under its own id. +Two ids that both fall outside the minted pattern carry no such rank, so nothing but model order settles which prints first, the same exposure to another tool's ordering choice on a first import. Folding the code into the base id was rejected: no other id constructor sanitizes arbitrary author-supplied text into an id fragment, and a numeric suffix would still be needed for two `timer` boundaries on one host, which carry no engine subscription key and so are never rejected as duplicates. The round-trip normalizer compensates on the comparison side instead, keying a boundary event's signature on the event definition's own payload rather than on the printed id, so two IR snapshots compare equal regardless of which physical id each assigns to which occurrence. @@ -72,12 +74,14 @@ The round-trip normalizer compensates on the comparison side instead, keying a b - Good, because a host-derived id survives every reordering the decompiler's trailing-position rule performs, without adding a re-key rule for the generation direction. - Bad, because a node reachable from both the main flow and an escape chain loses a tight immediate dominator, degrading a main-flow `if`/`else` whose join such a chain jumps into into `goto`s on decompile. - Bad, because a host-less `on` handler written inside a hosted handler's body lowers into the outer container and therefore guards that whole container rather than just the escape path it is written inside, which follows necessarily from transparency and which an author cannot see from the source alone. -- Bad, because two boundary handlers sharing a host and a trigger but differing only in code cannot be told apart by id text before the positional collision suffix is applied, which is stable across a recompile but exposed on an import written by another tool. +- Bad, because two boundary handlers sharing a host and a trigger but differing only in code cannot be told apart by id text until one carries a minted rank. + Two such handlers that both import under an id outside the minted pattern still fall back to model order, exposed to whatever order another tool presents them in on a first import. ### Confirmation `packages/language/test/` pins the colon's disambiguation, the transparency rule in both directions, and the trigger scope, the escalation host restriction, and the compensation-has-no-host refusal, each with an exact-message assertion. `packages/transform/test/` pins the promotion consequence of transparency, the virtual-entry wiring and the accepted dominance trade-off as an explicit regression case, the self-contained body including a nested `if`/`else` inside an escape chain restructuring cleanly, and the host-derived id template with its collision suffixing. +`tests/fuzz-regressions.test.ts` pins the mint-rank print order for two boundary handlers sharing a host and trigger, both when one side already carries a minted id and when both are authored and fall back to model order. The frozen `tests/golden/boundary-events.bpmn` fixture and its round-trip suite exercise these decisions together across a real DSL -> XML -> IR -> DSL -> IR cycle, including the import-first direction that is this scheme's one open disambiguation case. ## More Information diff --git a/packages/cli/test/decompile-contract.e2e.test.ts b/packages/cli/test/decompile-contract.e2e.test.ts index c10ace7..d37ab5a 100644 --- a/packages/cli/test/decompile-contract.e2e.test.ts +++ b/packages/cli/test/decompile-contract.e2e.test.ts @@ -198,7 +198,10 @@ describe('decompile contract: the script it hands back goes through the pipeline }); // Only the exact ids the compiler generates for the process are left out of - // the script; a modeller's defaults (`StartEvent_1`) are names like any other. + // the script; a modeller's defaults (`StartEvent_1`) are names like any + // other. A start with nowhere else to carry a label still drops it, warned + // at import; an end always has the statement to carry one, so it prints + // and is refused on the name alone instead, its label never lost. it.each([ [ "a modelling tool's default ids keep their statements and labels", @@ -209,17 +212,26 @@ describe('decompile contract: the script it hands back goes through the pipeline 'end EndEvent_1(label: "Order filed")', ], [], + 0, ], [ - 'the ids this tool generates for the process drop their statements, and each dropped label is warned about', + 'the start id this tool generates for the process drops its statement and label, but a like-named end keeps both and is refused on the name alone', 'StartEvent_generated-id-labels', 'EndEvent_generated-id-labels', - [], - ['StartEvent_generated-id-labels', 'EndEvent_generated-id-labels'], + ['end EndEvent_generated-id-labels(label: "Order filed")'], + ['StartEvent_generated-id-labels'], + 1, ], ])( - 'the DSL produced from a diagram where %s re-parses with zero diagnostics', - async (_title, startId, endId, printedLines, warnedIds) => { + 'the DSL produced from a diagram where %s', + async ( + _title, + startId, + endId, + printedLines, + warnedIds, + diagnosticCount, + ) => { const { ir, warnings } = await xmlToIr( labeledTerminalsBpmn(startId, endId), ); @@ -229,7 +241,7 @@ describe('decompile contract: the script it hands back goes through the pipeline expect(document.parseResult.parserErrors).toHaveLength(0); const { diagnostics } = await validate(dsl); - expect(diagnostics).toHaveLength(0); + expect(diagnostics).toHaveLength(diagnosticCount); expect( dsl.split('\n').filter((line) => /^\s+(start|end) /.test(line)), ).toEqual(printedLines.map((line) => ` ${line}`)); diff --git a/packages/language/README.md b/packages/language/README.md index bcb609d..c324bef 100644 --- a/packages/language/README.md +++ b/packages/language/README.md @@ -41,34 +41,46 @@ A `documentation` setting carries free-form text alongside `label`, spelled the ### Statements -| Statement | BPMN element | Notes | -| ------------------------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `start X` / `end X` | start event / end event | a trigger on either, see [Starting on an event](#starting-on-an-event) and [Ending every path](#ending-every-path) | -| `user X(...)` | user task | assignment and form attributes, see below | -| `service X(...)` | service task | exactly one binding attribute, see below | -| `script X` + a fenced body | script task | fence tag picks the language, see below | -| `step X(...)` | task | no binding of its own | -| `send X(...)` | send task | binds like a service task, see below | -| `receive X(...)` | receive task | waits for a message, or for the engine, see below | -| `decide X(...)` | business rule task | binds to a decision table, or like a service task, see below | -| `if (cond) (settings)` / `else if` / `else` | exclusive gateway | settings on the head only, see [Job settings on a gateway](#job-settings-on-a-gateway) | -| `while (cond) (settings) { }` | exclusive gateway loop | see [Job settings on a gateway](#job-settings-on-a-gateway) | -| `do { } while (cond) (settings)` | exclusive gateway loop | see [Job settings on a gateway](#job-settings-on-a-gateway) | -| `parallel (settings) { { } { } }` | parallel gateway fork and join, or inclusive when a branch is conditioned | see [Conditioning a parallel branch](#conditioning-a-parallel-branch) and [Job settings on a gateway](#job-settings-on-a-gateway) | -| `subprocess X(...) { }` | embedded sub-process | a nested container, see below | -| `attempt X(...) { }` | transaction sub-process | the same container, for work that can be given up, see [Giving a block of work up](#giving-a-block-of-work-up) | -| `call X(...) { }` | call activity | starts another process, see below | -| `goto X` | sequence flow | targets a step in the same container | -| `on { }` | event sub-process | see [The event layer](#the-event-layer) | -| `on Host: { }` | boundary event | see [Attaching a handler to one step](#attaching-a-handler-to-one-step) | -| `await (...)` | intermediate catch event | see [Awaiting an event inline](#awaiting-an-event-inline) | -| `await (settings) { (...) { } (...) { } }` | event-based gateway, a catch event per branch, exclusive join | see [Waiting on several triggers at once](#waiting-on-several-triggers-at-once) and [Job settings on a gateway](#job-settings-on-a-gateway) | -| `throw` / `emit ` | throw event | see [The event layer](#the-event-layer) | -| `emit link("X")` / `await link("X")` | intermediate throw and catch event, with no flow between them | see [Link events](#link-events) | +| Statement | What it means | BPMN element | Notes | +| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `process X { }` | the BPMN process, one per file | `bpmn:process` | header settings and `var` declarations go before the body, see [The language](#the-language) | +| `var x: number` | declare a variable so its uses get type-checked | none | the importer declares `var x: any` for every variable the printed script reads and nothing else declares | +| `start X` / `end X` | where the flow begins and ends | start event / end event | | +| `start X (...)` | start when an event arrives | start event with a trigger | see [Starting on an event](#starting-on-an-event) | +| `end X terminate` | stop every running path | terminate end event | see [Ending every path](#ending-every-path) | +| `end X cancel` | give up the block of steps around this end | cancel end event | see [Giving a block of work up](#giving-a-block-of-work-up) | +| `user X(...)` | a step a person performs | user task | assignment and form attributes, see below | +| `form { x: number "Label" }` | a form on a `start` or a `user` step; each field fills a process variable when the form is submitted | form fields on the start event or user task | see [Forms](#forms) | +| `service X(...)` | a step the system performs: in the engine, by an external worker that picks it up by topic, or as a built-in shell command or mail | service task; `topic` makes it the external type, `type` a built-in one | exactly one binding attribute, see below | +| `script X` + a fenced body | an inline script, in JS, Groovy, Python, Ruby or FEEL | script task | fence tag picks the language, see below | +| `step X(...)` | a step nothing in the engine automates | task | | +| `send X(...)` | a step that sends something out | send task | binds like a service task, see below | +| `receive X(...)` | wait here until a named message arrives, or until the engine is told to move on | receive task | see below | +| `decide X(...)` | a step a decision table answers, or one bound like a service task | business rule task | see below | +| `if (cond) (settings)` / `else if` / `else` | a decision: one branch runs | exclusive gateway | settings on the head only, see [Job settings on a gateway](#job-settings-on-a-gateway) | +| `while (cond) (settings) { }` | a loop that checks its condition before each pass | exclusive gateway loop | see [Job settings on a gateway](#job-settings-on-a-gateway) | +| `do { } while (cond) (settings)` | a loop that runs its body once before the first check | exclusive gateway loop | see [Job settings on a gateway](#job-settings-on-a-gateway) | +| `parallel (settings) { { } { } }` | branches that run at the same time; a branch headed by `if` runs only when its condition holds, and an `else` branch only when no sibling's condition did | parallel gateway fork and join, or inclusive when a branch is conditioned | see [Conditioning a parallel branch](#conditioning-a-parallel-branch) and [Job settings on a gateway](#job-settings-on-a-gateway) | +| `subprocess X(...) { }` | a group of steps as one unit | embedded sub-process | a nested container, see below | +| `attempt X(...) { }` | a group of steps that can be given up as one | transaction sub-process | the same container, for work that can be given up, see [Giving a block of work up](#giving-a-block-of-work-up) | +| `call X(...) { }` | start another process and wait for it | call activity | see below | +| `goto X` | jump to a named step in the same container | sequence flow | see [What `goto` can reach](#what-goto-can-reach) | +| `on { }` | catch an event anywhere in this body | event sub-process | see [The event layer](#the-event-layer) | +| `on Host: { }` | catch an event only while `Host` runs | boundary event | see [Attaching a handler to one step](#attaching-a-handler-to-one-step) | +| `await (...)` | stop here until the event arrives | intermediate catch event | see [Awaiting an event inline](#awaiting-an-event-inline) | +| `await (settings) { (...) { } (...) { } }` | wait on several triggers, continue down whichever fires first | event-based gateway, a catch event per branch, exclusive join | see [Waiting on several triggers at once](#waiting-on-several-triggers-at-once) and [Job settings on a gateway](#job-settings-on-a-gateway) | +| `throw` / `emit ` | raise an event, ending the path or continuing | throw event | see [The event layer](#the-event-layer) | +| `emit link("X")` / `await link("X")` | jump to the catch of the same name, a diagram's off-page connector | intermediate throw and catch event, with no flow between them | see [Link events](#link-events) | +| `for each x in c` | run a step once per item, or a set number of times | multi-instance marker | see [Repetition](#repetition) | +| `asyncBefore: true` | engine settings: async, exclusive, priority, retries, or, with a `run` prefix, on each run of a repetition | `operaton:` attribute/element | see [Attribute keys per element](#attribute-keys-per-element) | +| `input x = "..."` | data into a step's execution, and `output` back out | `operaton:inputOutput` | see [Input and output parameters](#input-and-output-parameters) | +| `on create(class: "...")` | code on a step's lifecycle, not a caught event | execution / task listener | see [Listeners](#listeners) | Every statement in that table that takes settings of its own also takes the engine settings and an execution listener, and most of them take input and output parameters as well. -The ten that map to an activity also take a repetition clause, see [Repetition](#repetition). +The ten that map to an activity (`user`, `service`, `script`, `step`, `send`, `receive`, `decide`, `subprocess`, `attempt`, and `call`) also take a repetition clause between the id and the parens, see [Repetition](#repetition). A brace holds what has internal structure: the body of a `while`, `do`, `parallel`, `subprocess`, `attempt`, `on`, or multi-branch `await`, and the form fields, parameters, listeners, error mappings, and call mappings an element carries. +The last four rows are members of an element rather than statements: the `for` clause is that repetition modifier, an engine setting joins the rest in the `( )`, a parameter and a listener go in the `{ }`, and not every element takes every one. +The listener row reuses `on` instead of opening a new event sub-process; the body block a handler always carries and a listener never does is what tells the two apart ([ADR-0020](../../docs/decisions/0020-listeners-reuse-on-inside-the-attribute-block.md)). #### Attribute keys per element diff --git a/packages/transform/src/cfg-analysis.ts b/packages/transform/src/cfg-analysis.ts index dfd6169..ad6f0e7 100644 --- a/packages/transform/src/cfg-analysis.ts +++ b/packages/transform/src/cfg-analysis.ts @@ -8,6 +8,7 @@ * names the query set. */ +import { isGateway } from './ir/types.js'; import type { FlowContainer, SequenceFlow } from './ir/types.js'; /** Synthetic single source: dominator analysis needs one root. */ @@ -25,16 +26,58 @@ export interface CfgAnalysis { /** `undefined` at the virtual entry, and for an unreachable or unknown node. */ immediateDominator(node: string): string | undefined; - /** `undefined` at the virtual exit, and for a node that cannot reach it. */ + /** + * `undefined` at the virtual exit, and for a node in a fragment the + * virtual entry never reaches: `loopEntries` walks only what + * `VIRTUAL_ENTRY` reaches, so no latch is recorded for such a fragment and + * `withLoopExits` has no head there to wire to the exit. + */ immediatePostDominator(node: string): string | undefined; dominates(a: string, b: string): boolean; postDominates(a: string, b: string): boolean; + /** + * Dominance read from the process starts alone, the edges a handler or link + * catch re-enters pruned first (see `withoutReentries`). A re-entry into a + * loop body must not unmake the loop's own nesting, which plain `dominates` + * would: it counts the virtual re-entry root as a second path in, so a + * nested loop's test gateway stops dominating the outer one's. + */ + loopDominates(a: string, b: string): boolean; + + /** The immediate dominator under {@link loopDominates}. */ + loopImmediateDominator(node: string): string | undefined; + /** Original {@link SequenceFlow} objects, in input order. */ backEdges(): SequenceFlow[]; + /** + * The heads of the loops holding `to` past their head, for a `from` outside + * them: getting from `from` to `to` takes a jump into the loop body, as a + * `goto` does. Outermost first, and empty for any other pair. + */ + headsEnteredPast(from: string, to: string): string[]; + + /** + * Whether a back edge into `node` (see {@link backEdges}) only closes a + * cycle of jumps: an enclosing loop's own exit route, not a nested loop's + * test. A caller reading such a back edge as a `while`'s or `do`'s own test + * would print a loop where the source has a `goto`. + */ + isJumpCycleHead(node: string): boolean; + + /** + * The gateway whose own test closes the cycle at `node` (see + * {@link backEdges}): `node` itself for a `while`, or the distinct gateway + * a `do`'s test returns from. `undefined` where `node` heads no tested + * cycle. A second back edge into the same head, a `goto` closing there + * too, is never this gateway, however conditioned it prints, and however + * many of `node`'s back edges a caller has already consumed. + */ + ownLoopTest(node: string): string | undefined; + outgoing(node: string): string[]; incoming(node: string): string[]; @@ -45,19 +88,32 @@ export function analyzeCfg(container: FlowContainer): CfgAnalysis { const idom = computeIdom(graph.succ, graph.pred, VIRTUAL_ENTRY); - // Post-dominators are the dominators of the reversed graph. - const ipdom = computeIdom(graph.pred, graph.succ, VIRTUAL_EXIT); + // A back-edge closes a cycle at its head (see `loopEntries`), read from the + // starts alone (see `withoutReentries`). Where the loop is reducible that is + // exactly u -> v with v dominating u. Filtering the raw flow list keeps + // every parallel edge and excludes the sentinel edges. + const forward = withoutReentries(graph); + const loopIdom = computeIdom(forward.succ, forward.pred, VIRTUAL_ENTRY); + const loopDominates = makeDominanceQuery(loopIdom, VIRTUAL_ENTRY); + const { latches, entered, jumpCycleHeads, testGateways } = loopEntries( + forward, + container, + ); + const backEdgeList = container.sequenceFlows.filter((f) => + latches.get(f.targetRef)?.has(f.sourceRef), + ); + + // Post-dominators are the dominators of the reversed graph, rooted at a + // virtual exit (see {@link withLoopExits}). Every latch head is wired + // there, whether or not its own test survives `loopTestRank`'s + // nested-test filtering: a jump routed back through another test on the + // cycle can leave a real loop's test unranked. + const exitGraph = withLoopExits(graph, [...latches.keys()]); + const ipdom = computeIdom(exitGraph.pred, exitGraph.succ, VIRTUAL_EXIT); const dominates = makeDominanceQuery(idom, VIRTUAL_ENTRY); const postDominates = makeDominanceQuery(ipdom, VIRTUAL_EXIT); - // A back-edge is u -> v where v dominates u; filtering the raw flow list - // keeps every parallel edge and excludes the sentinel edges. Computed once, - // since `tryWhile` and `tryDoWhileEntry` ask for it per emitted node. - const backEdgeList = container.sequenceFlows.filter((f) => - dominates(f.targetRef, f.sourceRef), - ); - return { immediateDominator(node) { return idom.get(node); @@ -67,9 +123,24 @@ export function analyzeCfg(container: FlowContainer): CfgAnalysis { }, dominates, postDominates, + loopDominates, + loopImmediateDominator(node) { + return loopIdom.get(node); + }, backEdges() { return backEdgeList; }, + headsEnteredPast(from, to) { + return entered + .filter((l) => l.past.has(to) && !l.cycle.has(from)) + .map((l) => l.head); + }, + isJumpCycleHead(node) { + return jumpCycleHeads.has(node); + }, + ownLoopTest(node) { + return testGateways.get(node); + }, outgoing(node) { return [...(graph.succ.get(node) ?? [])]; }, @@ -83,6 +154,8 @@ interface Graph { /** Insertion-ordered and de-duplicated; dominance is set-based. */ succ: Map; pred: Map; + /** The boundary events and link catches wired to the virtual entry. */ + reentries: Set; } function buildGraph(container: FlowContainer): Graph { @@ -116,17 +189,22 @@ function buildGraph(container: FlowContainer): Graph { // A boundary event and a link catch are each an entry beside the start: a // boundary's token appears when its trigger fires, a link catch's when the - // engine reroutes a throw of its name, never along a drawn flow. Any other - // node without a predecessor stays unwired: it really is unreachable. A - // link throw has no successor and drains to VIRTUAL_EXIT below. + // engine reroutes a throw of its name, never along a drawn flow. A caller + // that draws the throw -> catch hop as a flow has placed the catch, so it + // is no entry then. Any other node without a predecessor stays unwired: it + // really is unreachable. A link throw has no successor and drains to + // VIRTUAL_EXIT below. const hasAnyStart = container.flowElements.some( (e) => e.kind === 'startEvent', ); + const reentries = new Set(); for (const el of container.flowElements) { const hasRealPred = pred.get(el.id)!.length > 0; const isLinkCatch = el.kind === 'intermediateCatchEvent' && - el.eventDefinition.kind === 'link'; + el.eventDefinition.kind === 'link' && + !hasRealPred; + if (el.kind === 'boundaryEvent' || isLinkCatch) reentries.add(el.id); if ( el.kind === 'startEvent' || el.kind === 'boundaryEvent' || @@ -145,7 +223,83 @@ function buildGraph(container: FlowContainer): Graph { if (el.kind === 'endEvent' || !hasRealSucc) addEdge(el.id, VIRTUAL_EXIT); } - return { succ, pred }; + return { succ, pred, reentries }; +} + +/** + * `graph` without the edges by which a re-entry's chain joins the flow the + * starts reach, for finding loops: a handler or link catch jumping into a loop + * body prints as a `goto` and must not unmake the loop. A node only a re-entry + * reaches keeps its dominators from that re-entry. The other queries keep the + * re-entries' edges, since a split whose merge a handler also enters has no + * block to print. + */ +function withoutReentries(graph: Graph): Graph { + const reached = new Set([VIRTUAL_ENTRY]); + const pending = graph.succ + .get(VIRTUAL_ENTRY)! + .filter((n) => !graph.reentries.has(n)); + while (pending.length > 0) { + const n = pending.pop()!; + if (reached.has(n)) continue; + reached.add(n); + pending.push(...graph.succ.get(n)!); + } + const joinsStartFlow = (from: string, to: string) => + !reached.has(from) && reached.has(to) && to !== VIRTUAL_EXIT; + const prune = (edges: Map, forward: boolean) => + new Map( + [...edges].map(([n, ns]) => [ + n, + ns.filter( + (m) => !(forward ? joinsStartFlow(n, m) : joinsStartFlow(m, n)), + ), + ]), + ); + return { + succ: prune(graph.succ, true), + pred: prune(graph.pred, false), + reentries: graph.reentries, + }; +} + +/** + * `graph` with an edge to `VIRTUAL_EXIT` from every cycle's head that has no + * route there already: the head is where the loop leaves, a `while`'s + * gateway or a `do`'s first step (or, a cycle of jumps alone, the statement + * a flow into it lands on), so wiring it stands in for the route the loop + * would take out were one printable. That only equals the head's actual + * exit route when it leaves straight from there; the actual exit route can + * instead loop back through another node into the cycle first. `heads` + * lists outer cycles before the ones nested in them (see + * `loopEntries`), so a head already reached through an enclosing one's new + * edge, the two sharing one cycle a jump joins, needs none of its own. + */ +function withLoopExits(graph: Graph, heads: readonly string[]): Graph { + const succ = new Map([...graph.succ].map(([n, ns]) => [n, [...ns]])); + const pred = new Map([...graph.pred].map(([n, ns]) => [n, [...ns]])); + for (const head of heads) { + if (reachesExit(pred).has(head)) continue; + succ.get(head)!.push(VIRTUAL_EXIT); + pred.get(VIRTUAL_EXIT)!.push(head); + } + return { succ, pred, reentries: graph.reentries }; +} + +/** Every node with a route to `VIRTUAL_EXIT`, walked backward from it. */ +function reachesExit(pred: Map): Set { + const seen = new Set([VIRTUAL_EXIT]); + const stack = [VIRTUAL_EXIT]; + while (stack.length > 0) { + const n = stack.pop()!; + for (const p of pred.get(n) ?? []) { + if (!seen.has(p)) { + seen.add(p); + stack.push(p); + } + } + } + return seen; } /** @@ -246,6 +400,385 @@ function reversePostorder(root: string, succ: Map): string[] { return postorder.reverse(); } +interface EnteredLoop { + head: string; + cycle: Set; + /** + * The nodes of `cycle` other than its head and the gateways it is entered + * at; reaching one from outside without passing the head takes a jump. + */ + past: Set; +} + +/** + * The cycles the starts reach: `latches`, keyed by head, are the flows closing + * a cycle there, the back-edges, and `entered` holds each tested cycle with + * the nodes past its head. A cycle is a strongly connected set of nodes; its + * loops nested inside are the cycles left once its head is taken out, so an + * outer cycle comes first. + * + * A reducible loop is entered only at its head. A `goto` into a loop body + * adds a second entry, and neither entry dominates the other, so the head is + * the entry the cycle's own test touches (see {@link loopTestRank}): its + * gateway, a `while`, ahead of the node it returns to, a `do`. Model order, + * arbitrary in a model another tool wrote, only breaks a tie. A cycle whose + * own test touches none of its entries is made of jumps alone, so nothing in + * it is past a head; nor is a gateway the cycle is entered at, since a jump + * lands on a statement and a flow into a merge is the block around the cycle. + * Once the compiler has read a print back, the printed loop keeps its head + * ahead of its body, so every later print closes the same cycle. + */ +function loopEntries( + graph: Graph, + container: FlowContainer, +): { + latches: Map>; + entered: EnteredLoop[]; + jumpCycleHeads: Set; + testGateways: Map; +} { + const listed = new Map(container.flowElements.map((e, i) => [e.id, i])); + const gateways = new Set( + container.flowElements.filter(isGateway).map((e) => e.id), + ); + const conditioned = container.sequenceFlows.filter( + (f) => f.conditionExpression !== undefined, + ); + const latches = new Map>(); + const entered: EnteredLoop[] = []; + const jumpCycleHeads = new Set(); + // Backs `ownLoopTest` (see the interface doc), filled as each head is found. + const testGateways = new Map(); + const pending = [new Set(reversePostorder(VIRTUAL_ENTRY, graph.succ))]; + while (pending.length > 0) { + const nodes = pending.pop()!; + for (const scc of cycles(nodes, graph.succ)) { + const inside = new Set(scc); + const outside = (n: string) => + graph.pred.get(n)!.filter((p) => !inside.has(p)); + // An unconditioned flow in from outside `inside`: the fall-in a loop + // after a statement has. Only a hint of the cycle's real way in, not + // proof: a loop opening an `if` branch is entered by that branch's + // conditioned route, and `user Z; goto B` enters B unconditioned. + const hasNaturalEntry = (n: string) => + outside(n).some((p) => + container.sequenceFlows.some( + (f) => + f.sourceRef === p && + f.targetRef === n && + f.conditionExpression === undefined, + ), + ); + // A node already latched to an enclosing head stays in `inside` here + // (only the head is removed below), so its own exit route can pull an + // otherwise acyclic tail back into a pseudo-cycle, the way a `goto` + // after the enclosing loop does. Only a reused node with no test of + // its own inside this cycle counts as such a waypoint: a while nested + // in a while reuses the same node as both the inner test's gateway and + // the outer's back-edge source, which does have its own test. + const testSources = new Set( + conditioned + .filter((f) => inside.has(f.sourceRef) && inside.has(f.targetRef)) + .map((f) => f.sourceRef), + ); + const justWaypoints = scc.filter( + (n) => + !testSources.has(n) && [...latches.values()].some((s) => s.has(n)), + ); + const jumpsOnly = + justWaypoints.length > 0 && + cycles( + new Set(scc.filter((n) => !justWaypoints.includes(n))), + graph.succ, + ).length === 0; + const rank = jumpsOnly + ? (n: string) => (gateways.has(n) ? 1 : 0) + : loopTestRank( + inside, + conditioned, + graph.succ, + hasNaturalEntry, + (n) => outside(n).length > 0, + ); + const entries = scc + .filter((n) => outside(n).length > 0) + .sort((a, b) => rank(a) - rank(b) || listed.get(a)! - listed.get(b)!); + const head = entries[0]!; + const backSources = new Set( + graph.pred.get(head)!.filter((p) => inside.has(p)), + ); + latches.set(head, backSources); + if (jumpsOnly) { + jumpCycleHeads.add(head); + } else { + // Whether `head` is genuinely tested does not read `rank`: a guard + // several passes out, its own body swept broad or thin by the same + // subtraction `loopTestRank` already works around, can still outrank + // the real test there the way it outranks one closer by. A `while` + // head is tested by its own outgoing conditioned flow; a `do` head's + // conditioned back edges are its own test candidates, minus a + // multi-way branch (an `else if` chain, several conditioned routes + // off the one gateway): a loop's own test always has exactly one, so + // more marks a `goto` sharing the head, however conditioned it + // prints. Several genuine candidates left (loops nested at one body + // entry, as a `while` immediately inside another `while`'s own back + // edge) stay untold apart here: `tryDoWhileEntry`'s own dominance + // reads the outermost first and the body walk re-enters `node` for + // the next one in, so leaving more than one candidate defers to it. + // Whether a candidate is multi-way is asked of the whole container, + // not `inside`: an earlier pass has already taken its other target + // out of `inside` once that target's own head is found, which would + // otherwise make a later pass mistake the same branch gateway for a + // plain binary test. + const ownTests = [...backSources].filter( + (s) => + conditioned.some( + (f) => f.sourceRef === s && f.targetRef === head, + ) && conditioned.filter((f) => f.sourceRef === s).length === 1, + ); + // An unconditioned back edge from the body's tail, paired with the + // head's own conditioned exit, makes it a `while` head; only + // conditioned back edges make it a `do` head instead, whatever else + // the gateway also tests on its way out. Neither makes it no loop + // head at all here, only a cycle of jumps a later pass may still + // recognize by its statement. + const isWhileHead = + gateways.has(head) && + conditioned.filter((f) => f.sourceRef === head).length === 1 && + [...backSources].some((s) => + container.sequenceFlows.some( + (f) => + f.sourceRef === s && + f.targetRef === head && + f.conditionExpression === undefined, + ), + ); + const owner = isWhileHead + ? head + : ownTests.length === 1 + ? ownTests[0] + : undefined; + // Whether this cycle is tested at all still reads `rank`, not + // `owner`: several genuine candidates (see `ownTests` above) still + // close a real loop at `head`, just not one `ownLoopTest` can name + // yet, and gating `entered` on `owner` too would drop the cycle from + // `headsEnteredPast` the moment a second candidate appears. + if (rank(head) < UNTESTED) { + const past = new Set(scc); + past.delete(head); + for (const e of entries) if (gateways.has(e)) past.delete(e); + entered.push({ head, cycle: new Set(scc), past }); + } + if (owner !== undefined) { + testGateways.set(head, owner); + } + } + inside.delete(head); + pending.push(inside); + } + } + return { latches, entered, jumpCycleHeads, testGateways }; +} + +const UNTESTED = 2; + +/** + * Ranks a node by the cycle's own tests: 0 for a test's gateway (a `while` + * head), 1 for the node a test returns to (a `do` head), `UNTESTED` otherwise. + * A test is a conditioned flow `g -> t` inside the cycle, and its body is what + * `t` reaches without passing `g`, less what `g`'s other routes reach too: an + * `if` in a loop body closes at its merge, so its body is its branch alone. + * A test is natural when its gateway or target has an unconditioned entry + * from beyond the cycle (see `hasNaturalEntry`). + * A test is the cycle's own unless its gateway sits in another test's body + * that it does not hold in turn, which is where a nested loop's test sits, or + * its loop closes without passing the other test's gateway while the other's + * does not close without passing its: a jump from after the outer loop back + * into the inner body puts that body on the outer test's exit route, so the + * subtraction takes it out of the outer test's body, which the first rule + * alone misses. The second never disqualifies a natural test, so a + * conditional nested inside it must not outrank the loop's own test. + * The first rule turns round, and the outer test loses, where only the + * inner one is natural and the outer returns into the inner's loop: a + * conditioned `goto` after the loop back into its body spans the whole + * cycle, so read as a test it would enclose the loop's own. A real outer + * loop's test returns ahead of the inner loop, which keeps an + * unconditioned `goto` into a loop opening an `if` branch from turning it + * round. + * A test into a node entered from outside only by a jump is no test of this + * cycle when its gateway's other routes come back to it past that node, + * through an entry the flow before the cycle falls into: the cycle closes + * there without the test, which is an `if` jumping into a loop of plain + * flow, recognized again in the nested pass once the join is removed. Its + * target ranks below every other entry, so model order cannot make it the + * head. + */ +function loopTestRank( + inside: Set, + conditioned: SequenceFlow[], + succ: Map, + hasNaturalEntry: (n: string) => boolean, + entered: (n: string) => boolean, +): (n: string) => number { + const reach = (from: string, avoid: string) => { + const seen = new Set(); + const stack = [from]; + while (stack.length > 0) { + const n = stack.pop()!; + if (n === avoid || !inside.has(n) || seen.has(n)) continue; + seen.add(n); + stack.push(...succ.get(n)!); + } + return seen; + }; + const tests = conditioned.filter( + (f) => inside.has(f.sourceRef) && inside.has(f.targetRef), + ); + const body = new Map( + tests.map((f) => { + const g = f.sourceRef; + const own = reach(f.targetRef, g); + for (const other of succ.get(g)!) { + if (other === f.targetRef) continue; + for (const n of reach(other, g)) own.delete(n); + } + return [f, own]; + }), + ); + // `closesAvoiding` and `turnsRound` below each ask "is x reachable from a + // test's own target, avoiding some node", the same root over and over + // across the O(T^2) own/turnsRound checks that follow. Removing a node d + // from a graph disconnects exactly the nodes d dominates, so one + // dominator tree per test (`computeIdom`, restricted to `inside`, rooted + // at the test's target) answers every such query in O(1). + const insideSucc = new Map( + [...inside].map((n) => [n, succ.get(n)!.filter((m) => inside.has(m))]), + ); + const insidePred = new Map([...inside].map((n) => [n, []])); + for (const [n, outs] of insideSucc) { + for (const m of outs) insidePred.get(m)!.push(n); + } + const domFrom = new Map( + tests.map((f) => [ + f, + makeDominanceQuery( + computeIdom(insideSucc, insidePred, f.targetRef), + f.targetRef, + ), + ]), + ); + const closesAvoiding = (f: SequenceFlow, avoid: string): boolean => + !domFrom.get(f)!(avoid, f.sourceRef); + const jumpsIntoCycle = (f: SequenceFlow): boolean => { + if (!entered(f.targetRef) || hasNaturalEntry(f.targetRef)) return false; + const around = new Set( + succ + .get(f.sourceRef)! + .filter((o) => o !== f.targetRef) + .flatMap((o) => [...reach(o, f.targetRef)]), + ); + return around.has(f.sourceRef) && [...around].some(hasNaturalEntry); + }; + const enclosed = (f: SequenceFlow, o: SequenceFlow): boolean => + body.get(o)!.has(f.sourceRef) && !body.get(f)!.has(o.sourceRef); + const natural = (f: SequenceFlow): boolean => + hasNaturalEntry(f.sourceRef) || hasNaturalEntry(f.targetRef); + const turnsRound = (outer: SequenceFlow, inner: SequenceFlow): boolean => + enclosed(inner, outer) && + natural(inner) && + !natural(outer) && + !domFrom.get(inner)!(inner.sourceRef, outer.targetRef); + const jumps = tests.filter(jumpsIntoCycle); + const own = tests.filter( + (f) => + !jumps.includes(f) && + !tests.some( + (o) => + o.sourceRef !== f.sourceRef && + ((enclosed(f, o) && !turnsRound(o, f)) || + turnsRound(f, o) || + (!natural(f) && + closesAvoiding(f, o.sourceRef) && + !closesAvoiding(o, f.sourceRef))), + ), + ); + return (n) => + own.some((f) => f.sourceRef === n) + ? 0 + : own.some((f) => f.targetRef === n) + ? 1 + : jumps.some((f) => f.targetRef === n) + ? UNTESTED + 1 + : UNTESTED; +} + +/** + * The strongly connected components of `succ` restricted to `nodes` that hold + * a cycle: more than one node, or one flowing into itself. Tarjan's + * algorithm, iterative like {@link reversePostorder}. + */ +function cycles(nodes: Set, succ: Map): string[][] { + const index = new Map(); + const low = new Map(); + const stack: string[] = []; + const onStack = new Set(); + const components: string[][] = []; + + const enter = (node: string) => { + index.set(node, index.size); + low.set(node, index.get(node)!); + stack.push(node); + onStack.add(node); + }; + + for (const root of nodes) { + if (index.has(root)) continue; + enter(root); + const frames: Array<{ node: string; childIdx: number }> = [ + { node: root, childIdx: 0 }, + ]; + while (frames.length > 0) { + const frame = frames[frames.length - 1]; + const children = succ.get(frame.node) ?? []; + if (frame.childIdx < children.length) { + const child = children[frame.childIdx++]; + if (!nodes.has(child)) continue; + if (!index.has(child)) { + enter(child); + frames.push({ node: child, childIdx: 0 }); + } else if (onStack.has(child)) { + low.set( + frame.node, + Math.min(low.get(frame.node)!, index.get(child)!), + ); + } + continue; + } + frames.pop(); + const parent = frames[frames.length - 1]; + if (parent) { + low.set( + parent.node, + Math.min(low.get(parent.node)!, low.get(frame.node)!), + ); + } + if (low.get(frame.node) === index.get(frame.node)) { + const component: string[] = []; + let n: string; + do { + n = stack.pop()!; + onStack.delete(n); + component.push(n); + } while (n !== frame.node); + if (component.length > 1 || children.includes(frame.node)) { + components.push(component); + } + } + } + } + return components; +} + /** * `a` dominates `b` when `a` sits on `b`'s idom chain. Reflexive and total. * `idom` is a tree rooted at `root`, so one DFS numbers each node with a diff --git a/packages/transform/src/ir-to-dsl.ts b/packages/transform/src/ir-to-dsl.ts index fa3aa5c..acab243 100644 --- a/packages/transform/src/ir-to-dsl.ts +++ b/packages/transform/src/ir-to-dsl.ts @@ -70,6 +70,7 @@ import { splitTimerJobSettings, } from './ir/types.js'; import { + boundaryEventIdBase, CATCH_EVENT_PREFIX, claimDeclarationName, ID_SHAPED, @@ -286,6 +287,13 @@ function printedNames( return printed; } +interface Branch { + route: SequenceFlow; + join: string | undefined; + ownEntry?: string; + outer?: Branch; +} + /** * One per container: a sub-process's body lives in the child container's * arrays, so the parent's CFG treats the sub-process as one opaque node and no @@ -307,6 +315,16 @@ class Emitter { */ private readonly branchOf = new Map(); private branchPath = ''; + /** + * The innermost `await`/`parallel` branch, or if-chain branch, being walked: + * its route and join. `ownEntry` is set only for an if-chain branch (see + * {@link emitIfBranch}): a sibling branch's `goto` legally targets another + * branch's first statement, unlike an `await`/`parallel` branch's entry, + * which only its own split may reach. `outer` is the branch this one is + * nested in; see {@link leavesInnermostBranch} for how `ownEntry` and + * `outer` bound a `goto`. + */ + private branch: Branch | undefined; private readonly jumps: { target: string; branchPath: string }[] = []; private readonly deferredEnds: { lines: string[]; @@ -321,6 +339,10 @@ class Emitter { * array of its own, where nothing follows its end. */ private readonly elidedTail: string | undefined; + /** Each link throw whose catch prints right behind it, from {@link linkedCatches}. */ + private readonly linkedCatch: ReadonlyMap; + /** Each split's own merge, from {@link pairMerges}. */ + private readonly ownMerges: ReadonlyMap; constructor( private readonly container: FlowContainer, @@ -333,7 +355,6 @@ class Emitter { */ private readonly startTriggerSuppressed = false, ) { - this.cfg = analyzeCfg(container); this.elidedTail = container.flowElements.find( (el) => el.kind === 'endEvent' && @@ -356,6 +377,160 @@ class Emitter { list.push(f); this.outgoingBySource.set(f.sourceRef, list); } + this.linkedCatch = linkedCatches(container, this.elidedTail); + // The throw -> catch hop counts as a route in every shape predicate, so a + // branch ending in the throw reads as continuing where its catch goes. + this.cfg = analyzeCfg(withLinkHops(container, this.linkedCatch)); + this.ownMerges = this.pairMerges(); + } + + /** + * Each split's own merge. The compiler lowers every if, parallel and await + * with a merge whenever a route reaches it, and that merge closes the split + * in the dominator tree: walking up from the merge, every split passed is + * closed by a merge passed before it, and the first split left open is its + * own. A while's head and a do-while's test open and close nothing, being + * one back edge's two ends. A conditioned `goto` into a `do` body can read + * as that `do`'s test, and then its merge climbs to an enclosing split + * too; of the merges a split collects, the one nearest it in the dominator + * tree is its own, whatever order the model lists them in. Dominance is + * read from the starts alone, so a handler jumping into a branch leaves the + * branch in its split, but a merge that a flow from outside the split + * enters is no block's own. + */ + private pairMerges(): Map { + const loopEnds = new Set( + this.cfg.backEdges().flatMap((f) => { + if (this.isDoWhileTest(f)) return [f.sourceRef]; + const isWhileHead = + f.conditionExpression === undefined && + this.successors(f.targetRef).length > 1; + return isWhileHead ? [f.targetRef] : []; + }), + ); + const routes = (n: string): number => + loopEnds.has(n) ? 0 : this.successors(n).length; + const isMerge = (n: string): boolean => !loopEnds.has(n) && this.isMerge(n); + const idom = (n: string) => this.cfg.loopImmediateDominator(n); + const nests = (outer: string, split: string): boolean => + outer !== split && this.cfg.loopDominates(outer, split); + + const owner = new Map(); + for (const el of this.container.flowElements) { + if (!isMerge(el.id)) continue; + let open = 0; + let n = idom(el.id); + for (; n !== undefined; n = idom(n)) { + if (isMerge(n)) open++; + else if (routes(n) > 1 && open-- === 0) break; + } + if ( + n !== undefined && + this.cfg.incoming(el.id).every((p) => this.cfg.loopDominates(n, p)) + ) { + owner.set(el.id, n); + } + } + const mergeOf = (split: string): string | undefined => + [...owner].find(([, s]) => s === split)?.[0]; + + // A goto from an outer branch into a nested one bypasses the nested split, + // so the nested merge's dominator is the outer split and the count above + // hands it the nested merge. The nested split's routes still fall into + // that merge: it keeps it, and the outer split takes the next one. Splits + // pair innermost first, so a route falling into a nested block's merge + // runs on past it. + const fallsInto = (from: string, split: string): string | undefined => { + const seen = new Set(); + for (let n: string | undefined = from; n !== undefined;) { + if (n === split || seen.has(n)) return undefined; + seen.add(n); + const closes = owner.get(n); + if (isMerge(n) && (closes === undefined || !nests(split, closes))) { + return n; + } + const past: string | undefined = + routes(n) === 1 ? n : routes(n) > 1 ? mergeOf(n) : undefined; + n = past === undefined ? undefined : this.successors(past)[0]; + } + return undefined; + }; + const depth = (n: string): number => { + let d = 0; + for (let m = idom(n); m !== undefined; m = idom(m)) d++; + return d; + }; + const unpaired = this.container.flowElements + .map((el) => el.id) + .filter((n) => routes(n) > 1 && mergeOf(n) === undefined) + .sort((a, b) => depth(b) - depth(a)); + for (const split of unpaired) { + const reached = new Set( + this.successors(split).map((t) => fallsInto(t, split)), + ); + reached.delete(undefined); + const [merge] = reached; + const outer = merge === undefined ? undefined : owner.get(merge); + if (reached.size !== 1 || outer === undefined || !nests(outer, split)) { + continue; + } + owner.set(merge!, split); + const next = fallsInto(this.successors(merge!)[0]!, outer); + if (next !== undefined && (owner.get(next) ?? outer) === outer) { + owner.set(next, outer); + } + } + + const merges = new Map(); + for (const [merge, split] of owner) { + const held = merges.get(split); + if (held === undefined || depth(merge) < depth(held)) { + merges.set(split, merge); + } + } + return merges; + } + + /** + * The split's own merge while the walk can still continue at it: unprinted, + * of the kind a fork or race synchronizes at, and reached by a route before + * `stop`. A route that needs a link throw's hop to reach it does not count + * once something prints past the merge ({@link reachableOverFlow}): the + * routes past it sink into the branch that carries them instead. A merge + * with nothing past it costs nothing to place past the hop either way, so + * it is kept the plain way. + */ + private ownJoin( + splitId: string, + outs: readonly SequenceFlow[], + stop: string | undefined, + kind?: Gateway['kind'], + ): string | undefined { + const merge = this.ownMerges.get(splitId); + if ( + merge === undefined || + this.emittedNodes.has(merge) || + (kind !== undefined && this.byId.get(merge)?.kind !== kind) || + this.entersUnprintedLoop(splitId, merge) || + // One route reaching this split's own merge is enough to place it: the + // rest, whatever they do, print inside their own branches instead. + !outs.some( + (f) => + this.reachable(f.targetRef, splitId, stop).has(merge) && + this.crossesHopInto(f.targetRef, splitId, merge), + ) + ) { + return undefined; + } + const runsPast = outs.some((f) => + [...this.reachableInRegion(f.targetRef, splitId, merge)].some( + (n) => + n !== merge && + this.isGatewayId(n) && + !this.cfg.loopDominates(splitId, n), + ), + ); + return runsPast ? undefined : merge; } /** @@ -381,19 +556,53 @@ class Emitter { this.emitFrom(el.id, undefined, lines, 0); } } - for (const el of this.container.flowElements) { - if (el.kind === 'startEvent' && !this.emittedNodes.has(el.id)) { - this.emitStartGroup(el, lines); - } + // A chain printed after the one reaching the elided end pushes that end + // off the block's tail, where it prints under its reserved id. Only the + // elided start is exempt from moving, being the process's implicit + // entry; every other start whose own chain reaches the tail moves + // behind the rest, so the tail stays implicit instead of printing under + // that id. + const tail = this.elidedTail; + const reachesTail = ( + el: Extract, + ): boolean => + tail !== undefined && + !isElidedOnPrint(el, this.container, this.startTriggerSuppressed) && + this.reachable(el.id, undefined, undefined).has(tail); + const starts = this.container.flowElements + .filter((el) => el.kind === 'startEvent') + .toSorted((a, b) => Number(reachesTail(a)) - Number(reachesTail(b))); + for (const el of starts) { + if (!this.emittedNodes.has(el.id)) this.emitStartGroup(el, lines); } - // 2. Every chain an entry reaches that pass 1 left for a jump, in model - // order like the orphan sweep, which is what places a link catch's - // chain among them. + // 2. Every chain an entry reaches that pass 1 left for a jump. Each walk + // starts at a chain's head, one whose every owned predecessor already + // printed, or in a cycle at a node a printed one flows into: started + // mid-chain, the walk prints the steps before it as a jump target + // behind it. Model order only breaks ties. const owned = this.reachableFromEntries(); - for (const el of this.container.flowElements) { - if (owned.has(el.id) && !this.emittedNodes.has(el.id)) { - this.emitFrom(el.id, undefined, lines, 0); + const leftover = () => + this.container.flowElements.filter( + (el) => owned.has(el.id) && !this.emittedNodes.has(el.id), + ); + const preds = (el: FlowElement) => this.cfg.incoming(el.id); + for (let rest = leftover(); rest.length > 0; rest = leftover()) { + const head = + rest.find((el) => + preds(el).every((p) => !owned.has(p) || this.emittedNodes.has(p)), + ) ?? + rest.find((el) => preds(el).some((p) => this.emittedNodes.has(p))) ?? + rest[0]!; + const before = this.emittedNodes.size; + this.emitFrom(head.id, undefined, lines, 0); + // Every walked node is marked, so leftover() shrinks each pass; + // otherwise this loop never terminates, which is malformed IR, as a + // duplicate id is. + if (this.emittedNodes.size === before) { + throw new Error( + `irToDsl: walking '${head.id}' in container '${this.container.id}' printed nothing.`, + ); } } @@ -402,13 +611,47 @@ class Emitter { // element's index so pass 6 can place it among the event sub-processes // the way the model orders them. const boundaryBlocks = new Map(); + const rankedBoundaries: { + index: number; + base: string; + rank: number; + block: string[]; + }[] = []; this.container.flowElements.forEach((el, index) => { if (isBoundary(el) && !this.emittedNodes.has(el.id)) { const block: string[] = []; this.emitBoundaryHandler(el, block, 0); boundaryBlocks.set(index, block); + const base = boundaryEventIdBase( + nameOf(this.names, el.attachedToRef), + renderTrigger(el.eventDefinition, this.names).head, + ); + const rank = boundaryIdRank(el.id, base); + rankedBoundaries.push({ index, base, rank, block }); } }); + // Two boundaries sharing a host and trigger kind mint their id from + // statement order (the base id first, then `_2`, `_3`, ...), so laying + // their blocks down in model order can print the `_2` handler first and + // swap the ids on the next compile. Reassigning each such group's blocks + // across its own index slots by that rank keeps every boundary under its + // own id whatever order the model lists them in; the slots themselves, + // and every other block's index, are untouched. + const byBase = new Map(); + for (const m of rankedBoundaries) { + const group = byBase.get(m.base); + if (group === undefined) byBase.set(m.base, [m]); + else group.push(m); + } + for (const group of byBase.values()) { + if (group.length < 2) continue; + const slots = group.map((m) => m.index).toSorted((a, b) => a - b); + // Two authored ids (both rank Infinity) tie and keep model order. + const byRank = [...group].sort((a, b) => + a.rank === b.rank ? 0 : a.rank - b.rank, + ); + slots.forEach((slot, i) => boundaryBlocks.set(slot, byRank[i]!.block)); + } // 4. Orphaned fragments: a cycle no entry reaches, and what a handler's // walk left for a jump. @@ -484,13 +727,8 @@ class Emitter { } /** - * Every unprinted start leaving for the same step as `start` prints with it, - * back to back, and the step's chain is walked once under them, since starts - * written back to back all enter the statement after them. Walked one at a - * time, the second start would find the step printed and jump onto it, which - * a jump cannot do for an elided end, and a `start` after the first chain - * would leave that chain's elided end off its block's tail. A start leaving - * on several routes is a split of its own and prints as one. + * A start leaving on several routes is a split of its own and prints as one; + * otherwise its step's chain is walked with the start printed ahead of it. */ private emitStartGroup( start: Extract, @@ -501,30 +739,84 @@ class Emitter { this.emitFrom(start.id, undefined, lines, 0); return; } - for (const el of this.container.flowElements) { - if (el.kind !== 'startEvent' || this.emittedNodes.has(el.id)) continue; - const own = this.soleRoute(el.id); - if (own === undefined || own.targetRef !== route.targetRef) continue; + this.emitStartsInto(route.targetRef, lines); + this.emitFrom(route.targetRef, undefined, lines, 0); + } + + /** + * Every unprinted start leaving for `step` alone prints right before it, + * back to back, since starts written back to back all enter the statement + * after them. Walked one at a time, the second start would find the step + * printed and jump onto it, which a jump cannot do for an elided end. + */ + private emitStartsInto(step: string, lines: string[]): void { + for (const el of this.startsInto(step)) { this.emittedNodes.add(el.id); this.branchOf.set(el.id, this.branchPath); lines.push( ...renderStartEvent(el, this.startTriggerSuppressed, this.names), ); - this.consume(own); + this.consume(this.soleRoute(el.id)!); } - this.emitFrom(route.targetRef, undefined, lines, 0); + } + + private startsInto( + step: string, + ): Extract[] { + return this.container.flowElements.filter( + (el): el is Extract => + el.kind === 'startEvent' && + !this.emittedNodes.has(el.id) && + this.soleRoute(el.id)?.targetRef === step, + ); + } + + /** + * Whether a sequence flow runs from `from` to `to`, printed statement + * order included: a link throw's hop into its catch counts only where + * `hops` names the throw, meaning this same walk already printed that + * catch right behind the throw with no `goto` between them. Unlike + * {@link reachable}, a hop `hops` leaves out does not cross: this walk has + * not committed to printing that catch adjoining yet, so a start placed + * past it would print ahead of a `goto` this walk still owes (see + * {@link reachableOverFlow} for what the validator itself makes of an + * `emit link`). + */ + private flowsOnTo( + from: string, + to: string, + hops: ReadonlySet, + ): boolean { + const seen = new Set(); + const queue = [from]; + for (let i = 0; i < queue.length; i++) { + const n = queue[i]!; + if (n === to) return true; + if (seen.has(n)) continue; + seen.add(n); + for (const f of this.outgoingBySource.get(n) ?? []) + queue.push(f.targetRef); + if (hops.has(n)) { + const linked = this.linkedCatch.get(n); + if (linked !== undefined) queue.push(linked); + } + } + return false; } /** * Every node a flow can carry a token to from one of the container's own * entries: a start, or a node no flow enters, which is a link catch or a * stranded fragment. A boundary event is an entry too, but its chain is the - * handler's own until an entry's chain reaches into it. + * handler's own until an entry's chain reaches into it. A catch printed + * behind its throw is no entry: the walk that prints the throw owns it, + * which for a throw in a boundary body is the handler's walk. */ private reachableFromEntries(): Set { - const entered = new Set( - this.container.sequenceFlows.map((f) => f.targetRef), - ); + const entered = new Set([ + ...this.container.sequenceFlows.map((f) => f.targetRef), + ...this.linkedCatch.values(), + ]); const reached = new Set(); const pending = this.container.flowElements .filter( @@ -538,13 +830,28 @@ class Emitter { const id = pending.pop()!; if (reached.has(id)) continue; reached.add(id); - for (const f of this.outgoingBySource.get(id) ?? []) { - pending.push(f.targetRef); - } + pending.push(...this.successors(id)); } return reached; } + /** The model's routes out of `id`, and the hop from a link throw into the catch printed behind it. */ + private successors(id: string): string[] { + const next = (this.outgoingBySource.get(id) ?? []).map((f) => f.targetRef); + const linked = this.linkedCatch.get(id); + return linked === undefined ? next : [...next, linked]; + } + + private isGatewayId(id: string): boolean { + const el = this.byId.get(id); + return el !== undefined && isGateway(el); + } + + /** A gateway with one route out: where the compiler closes a block. */ + private isMerge(id: string): boolean { + return this.isGatewayId(id) && this.successors(id).length === 1; + } + /** The one route the model gives a node, or `undefined` where it splits or ends. */ private soleRoute(id: string): SequenceFlow | undefined { const outs = this.outgoingBySource.get(id) ?? []; @@ -650,19 +957,242 @@ class Emitter { return; } let current = node; + // Grows below as this walk prints a throw right into its catch; see + // flowsOnTo for what that lets it follow. + const hops = new Set(); // Bounded by the node count, against a malformed IR cycle. let guard = this.byId.size + 1; while (current !== undefined && current !== stop && guard-- > 0) { - if (this.emittedNodes.has(current)) { + // A start whose step runs into the elided end prints right before + // that step, keeping the end implicit at the tail. A start opens an + // entry of its own, so nothing else may flow into it: where the chain + // walked so far still runs on to the step, a `goto` closes that flow + // first. The elided end takes no `goto`, so a start entering it behind + // a live chain keeps its place after it. + if ( + depth === 0 && + this.elidedTail !== undefined && + this.startsInto(current).length > 0 && + this.reachable(current, undefined, undefined).has(this.elidedTail) + ) { + const flowsOn = + node !== undefined && + current !== node && + this.flowsOnTo(node, current, hops); + if (!flowsOn) this.emitStartsInto(current, lines); + else if (current !== this.elidedTail) { + this.pushGoto(current, lines); + this.emitStartsInto(current, lines); + } + } + if ( + this.emittedNodes.has(current) || + (this.branch !== undefined && this.leavesInnermostBranch(current)) + ) { this.pushGoto(current, lines); return; } const next = this.emitNode(current, stop, lines, depth); if (next === STOP) return; + if (this.linkedCatch.get(current) === next) hops.add(current); current = next; } } + /** + * Whether `current`, walked under `this.branch`, must print as a `goto` + * here rather than inline. An if-chain branch (`ownEntry` set) only + * forbids carrying an enclosing block's tail into it (see + * {@link reachesEnclosingJoin}); a `goto` into one is otherwise legal. It + * says nothing about an `await`/`parallel` ancestor the if-chain is nested + * in, whose branch a `goto` may never enter from outside: that ancestor is + * shadowed in `this.branch` while the if-chain is being walked, so it is + * looked up through `outer`, skipping any if-chain branches in between + * (they carry no such restriction of their own). + */ + private leavesInnermostBranch(current: string): boolean { + const branch = this.branch!; + if ( + this.liesOutsideBranch(current, branch.route, branch.join) && + (branch.ownEntry === undefined || + this.reachesEnclosingJoin(current, branch)) + ) { + return true; + } + if (branch.ownEntry === undefined) return false; + let ancestor = branch.outer; + while (ancestor !== undefined && ancestor.ownEntry !== undefined) { + ancestor = ancestor.outer; + } + return ( + ancestor !== undefined && + this.liesOutsideBranch(current, ancestor.route, ancestor.join) + ); + } + + /** + * Whether `node`, reached inside the `await` or `parallel` branch `route` + * opens, is also entered from outside it without leading back in. The + * validator refuses a `goto` into such a branch from outside, so the branch + * jumps to the node and the walk from outside prints it. A node leading + * into the block's `join` still ends the branch, which no placement outside + * it prints valid, so it stays. A node no token reaches enters nothing. + */ + private liesOutsideBranch( + node: string, + route: SequenceFlow, + join: string | undefined, + ): boolean { + const first = route.targetRef; + const entered = this.cfg + .incoming(node) + .some( + (p) => + this.cfg.loopImmediateDominator(p) !== undefined && + !this.cfg.loopDominates(first, p) && + !(p === route.sourceRef && node === first), + ); + return ( + entered && + (join === undefined || + !this.reachable(node, route.sourceRef, undefined).has(join)) + ); + } + + /** + * Whether `p` lies in an `await` or `parallel` branch that jumps to `entry` + * by {@link liesOutsideBranch}: the route out of the nearest fork or race + * above `p`, unless that route's branch holds `entry` too. The fork is not + * printed yet, so its own merge stands in for the join it will take. + */ + private jumpsOutOfBranch(p: string, entry: string): boolean { + let route = this.forkRoute(p, entry); + for (let d = p; route === undefined && !this.cfg.loopDominates(d, entry);) { + const split = this.cfg.loopImmediateDominator(d); + if (split === undefined) return false; + route = this.forkRoute(split, d); + d = split; + } + return ( + route !== undefined && + this.liesOutsideBranch(entry, route, this.ownMerges.get(route.sourceRef)) + ); + } + + /** + * Whether `node`, reached in the if-chain branch `branch`, runs on into the + * join of a block enclosing it, or lies past that join: a `goto` into an + * if-chain branch is legal, so a step a sibling also enters prints wherever + * the walk meets it first, unless printing it here would carry code an + * enclosing block prints after this one into this branch. The walk stops + * at any step on a cycle through the branch's entry, a loop the branch + * prints itself rather than code to carry in, but runs through one only a + * later `goto` jumps back to, since that closes no loop. The same holds + * for an enclosing join: the walk from it stops at this branch's entry + * unless every route out of the join runs through the entry, or one loops + * back into it (see {@link wallStaysOff}), and a step whose routes all end + * on their own prints here rather than after an end the outermost block + * would then have to spell out. + * + * Every walk leaves out the enclosing block's split, since a loop around + * the block would otherwise pull every one of its nodes past the join. + * Only a merge gateway counts as a join reached, since a step there prints + * as this walk's own `goto`. + */ + private reachesEnclosingJoin(node: string, branch: Branch): boolean { + if (branch.join !== undefined) { + const split = branch.route.sourceRef; + const entry = branch.route.targetRef; + const loops = this.cyclesThroughEntry(entry, split); + const after = this.reachable( + branch.join, + split, + undefined, + (n) => !loops(n), + ); + if ( + after.has(node) && + !loops(node) && + this.cfg.postDominates(node, branch.join) + ) { + return true; + } + } + for (let b = branch.outer; b !== undefined; b = b.outer) { + if (b.join === undefined) continue; + const split = b.route.sourceRef; + const seen = this.reachable( + node, + branch.route.sourceRef, + undefined, + (n) => n !== split, + ); + if (this.isGatewayId(b.join) && seen.has(b.join)) return true; + const entry = branch.route.targetRef; + const walled = !this.wallStaysOff(entry, b.join, split); + const past = this.reachable( + b.join, + split, + undefined, + (n) => !walled || n !== entry, + ); + if (past.has(node) && !(walled && node === entry)) return true; + } + return false; + } + + /** + * A predicate for whether `n` lies on a cycle through `entry`, inside + * `split`'s region: reachable from `entry`, and reaching back to it. + */ + private cyclesThroughEntry( + entry: string, + split: string, + ): (n: string) => boolean { + const fromEntry = this.reachable(entry, split, undefined); + return (n) => + fromEntry.has(n) && this.reachable(n, split, undefined).has(entry); + } + + /** + * Whether a wall placed at `entry` against reaching `join` should stay + * off: `entry` post-dominates `join` (every route from `split` to `join` + * already runs through it, so walling it off would hide nothing), or a + * route from `join` runs back into `entry` over a predecessor on a cycle + * through it, so the flow past `join` reaches `entry` again regardless of + * the wall. `postDominates` is checked first, the cheaper test. + */ + private wallStaysOff(entry: string, join: string, split: string): boolean { + if (this.cfg.postDominates(entry, join)) return true; + const beforeEntry = this.reachable( + join, + split, + undefined, + (n) => n !== entry, + ); + const loops = this.cyclesThroughEntry(entry, split); + return this.cfg + .incoming(entry) + .some((p) => p !== entry && beforeEntry.has(p) && loops(p)); + } + + /** The route opening a branch of the fork or race `split` at `target`. */ + private forkRoute(split: string, target: string): SequenceFlow | undefined { + const kind = this.byId.get(split)?.kind; + if ( + (kind !== 'parallelGateway' && + kind !== 'inclusiveGateway' && + kind !== 'eventBasedGateway') || + this.successors(split).length < 2 || + this.ownMerges.get(split) === target + ) { + return undefined; + } + return this.outgoingBySource + .get(split)! + .find((f) => f.targetRef === target); + } + private emitNode( id: string, stop: string | undefined, @@ -752,6 +1282,8 @@ class Emitter { } else if (stmt !== undefined) { lines.push(...stmt); } + const linked = this.linkedCatch.get(id); + if (linked !== undefined) return linked; return this.followLinear(id, stop, lines, depth); } @@ -825,7 +1357,22 @@ class Emitter { if (outs.length === 0) return STOP; if (outs.length === 1) { - return this.takeFallThrough(outs[0]!, stop, lines); + const only = outs[0]!; + if (!this.leavesBesideStop(splitId, only, stop)) { + return this.takeFallThrough(only, stop, lines); + } + this.consumedFlows.add(only.id); + this.emitIfChain( + [only], + [], + undefined, + stop, + splitId, + this.takeHeadSettings(splitId), + lines, + depth, + ); + return STOP; } const { fallback, conditioned, unconditioned } = this.weighRoutes( @@ -835,6 +1382,7 @@ class Emitter { this.warnInventedFallback(splitId); const join = + this.ownJoin(splitId, outs, stop) ?? this.cleanJoin(splitId, outs, stop) ?? this.convergence(splitId, outs, stop) ?? this.guardClauseContinuation(unconditioned) ?? @@ -855,6 +1403,32 @@ class Emitter { return join !== undefined ? this.continueAt(join, stop, lines) : STOP; } + /** + * Whether `splitId` has, beside its last unprinted `route`, its default + * route running straight into `stop`, the way a gateway closing a `while` + * body routes back to the head: the block's end already stands for that + * route, so `route` keeps its `if` and a failed test falls through. An + * unconditioned route that is not the default can run alongside a + * conditioned one (an inclusive split, or a fork), so only the default is + * safe to leave unprinted this way. + */ + private leavesBesideStop( + splitId: string, + route: SequenceFlow, + stop: string | undefined, + ): boolean { + return ( + route.conditionExpression !== undefined && + (this.outgoingBySource.get(splitId) ?? []).some( + (o) => + o !== route && + o.targetRef === stop && + o.conditionExpression === undefined && + o.id === this.splitFallbackFlowId(splitId), + ) + ); + } + /** * Every route gets a form: a conditioned one heads a branch with its * condition, and an unconditioned one heads a branch as `true`, takes the one @@ -891,16 +1465,17 @@ class Emitter { ? `if (${condition})${headSettings(settings)} {` : `} else if (${condition}) {`, ); - this.emitIfBranch(f.targetRef, join, splitId, lines, depth); + lines.push(...this.emitIfBranch(f, join, splitId, depth)); }); - if (elseFlow === undefined || elseFlow.targetRef === join) { - lines.push('}'); - } else { - lines.push('} else {'); - this.emitIfBranch(elseFlow.targetRef, join, splitId, lines, depth); - lines.push('}'); - } + // The compiler lowers an empty `else` exactly as no `else`, so printing + // one would change on the next pass. + const elseBody = + elseFlow === undefined + ? [] + : this.emitIfBranch(elseFlow, join, splitId, depth); + if (elseBody.length > 0) lines.push('} else {', ...elseBody); + lines.push('}'); } /** @@ -959,12 +1534,29 @@ class Emitter { ); } + /** + * Whether getting from `from` to `to` jumps into a loop body past a head + * not printed yet. + * A loop statement prints ahead of its body, so until the head is out the + * jump stays a `goto`; after that the body is a chain like any other, + * ending on a jump back. + */ + private entersUnprintedLoop(from: string, to: string): boolean { + return this.cfg + .headsEnteredPast(from, to) + .some((head) => !this.emittedNodes.has(head)); + } + /** * `undefined` when the split is unstructured. A join past `stop` is refused: * inside a loop body the body dominates everything after the loop, so a * split whose routes leave the loop and reconverge behind it would pass the * dominance checks, walk the staying route past the printed loop head and - * drop the back edge. + * drop the back edge. Every route must also run into the join: one that + * never reaches an end, such as a step looping on itself, has no + * post-dominators, and the split's own then speaks only for the routes that + * do end. A route into the body of a loop still to print is a jump (see + * {@link entersUnprintedLoop}), so no node of that body is a join. */ private cleanJoin( splitId: string, @@ -977,9 +1569,15 @@ class Emitter { return undefined; } if (!this.cfg.dominates(splitId, join)) return undefined; + if (this.entersUnprintedLoop(splitId, join)) return undefined; for (const f of outs) { if (f.targetRef === join) continue; - if (!this.cfg.dominates(splitId, f.targetRef)) return undefined; + if ( + !this.cfg.dominates(splitId, f.targetRef) || + !this.cfg.postDominates(join, f.targetRef) + ) { + return undefined; + } } return join; } @@ -988,31 +1586,27 @@ class Emitter { * Where the routes come back together when the post-dominator queries * cannot say: a branch that can end puts the split's post-dominator at the * exit, and a step that loops on itself leaves the container with no - * post-dominators at all, so {@link cleanJoin} and {@link recoveredForkJoin} - * both miss a merge the routes plainly share. + * post-dominators at all, so {@link cleanJoin} misses a merge the routes + * plainly share. * * Read off the model's routes, bounded by `stop`. A route straight into a * sink never counts. The live routes are those reaching the block's exit * (`stop`, or the elided end at the top of the container); where none does, * those reaching a node another route reaches; where none does that either, * all of them. The answer is the first node in the first live route's - * breadth order that every live route reaches: a merge (a gateway with one - * route out) ahead of a step on the way to it, since the compiler lowers - * every block with a merge; then the exit; then the nearest step no live - * route can end or leave before. `joinKind` narrows a fork's or a race's - * answer to a merge of that kind the split dominates, the only node it - * synchronizes at: a merge entered from outside the block as well would - * synchronize that entry too. + * breadth order that every live route reaches: a merge ({@link isMerge}) + * ahead of a step on the way to it, since the compiler lowers every block + * with a merge; then the exit; then the nearest step no live route can end + * or leave before. */ private convergence( splitId: string, outs: readonly SequenceFlow[], stop: string | undefined, - joinKind?: Gateway['kind'], ): string | undefined { const exit = stop ?? this.elidedTail; const routes = outs - .filter((f) => (this.outgoingBySource.get(f.targetRef) ?? []).length > 0) + .filter((f) => this.successors(f.targetRef).length > 0) .map((f) => ({ f, reach: this.reachable(f.targetRef, splitId, stop) })); const shares = ({ reach }: (typeof routes)[number]): boolean => [...reach].some((n) => @@ -1034,36 +1628,21 @@ class Emitter { const shared = [...live[0]!.reach].filter( (n) => live.every((r) => r.reach.has(n)) && - (n === stop || !this.cfg.dominates(n, splitId)), + (n === stop || !this.cfg.dominates(n, splitId)) && + !this.entersUnprintedLoop(splitId, n), + ); + // A merge only a hop-crossing route reaches, where something prints past + // it, is {@link ownJoin}'s to refuse for the same reason ({@link + // crossesHopInto}): the routes past it sink into the branch that carries + // them, found below by the farthest-step rule. Unlike ownJoin, which + // only needs one route to place its own merge, a shared merge candidate + // here has to hold for every live route, since one that cannot cross + // disqualifies `n` as their common join. + const merge = shared.find( + (n) => + this.isMerge(n) && + live.every((r) => this.crossesHopInto(r.f.targetRef, splitId, n)), ); - const isMerge = (n: string, kind?: FlowElement['kind']): boolean => { - const el = this.byId.get(n); - return ( - el !== undefined && - isGateway(el) && - (this.outgoingBySource.get(n) ?? []).length === 1 && - (kind === undefined || - (el.kind === kind && this.cfg.dominates(splitId, n))) - ); - }; - // A lone live route reaches every merge on its way, nested and later - // blocks' beside this block's own, and the graph cannot tell them apart - // in general (the compiler keeps a join one route enters). It can tell - // this much: this block's join is entered by the lone route alone where a - // nested if/else's is entered by each of its routes, and a choice's join - // is a merge of its own kind where a nested fork's is not; a nested guard - // clause's one-in join can still be taken first, and the block's tail - // then prints after the block. - const ownKind = joinKind ?? this.byId.get(splitId)?.kind; - const merge = - live.length === 1 - ? (shared.find( - (n) => isMerge(n, ownKind) && this.cfg.incoming(n).length === 1, - ) ?? - shared.find((n) => isMerge(n, ownKind)) ?? - (joinKind === undefined ? shared.find((n) => isMerge(n)) : undefined)) - : shared.find((n) => isMerge(n, joinKind)); - if (joinKind !== undefined) return merge; if (merge !== undefined) return merge; if (exit !== undefined && shared.includes(exit)) { // A branch's chain leaves the block through its join, so a lone route @@ -1085,9 +1664,7 @@ class Emitter { // which the next pass prints hoisted. const leavesBefore = (entry: string, n: string): boolean => [...this.reachable(entry, splitId, n)].some( - (m) => - m !== n && - (m === stop || (this.outgoingBySource.get(m) ?? []).length === 0), + (m) => m !== n && (m === stop || this.successors(m).length === 0), ); const holds = (n: string): boolean => live.every((r) => !leavesBefore(r.f.targetRef, n)); @@ -1101,8 +1678,9 @@ class Emitter { */ private reachable( from: string, - splitId: string, + splitId: string | undefined, stop: string | undefined, + inRegion: (n: string) => boolean = () => true, ): Set { const seen = new Set(); const queue = [from]; @@ -1110,13 +1688,121 @@ class Emitter { const n = queue[i]!; if (n === splitId || seen.has(n)) continue; seen.add(n); - if (n === stop) continue; - for (const f of this.outgoingBySource.get(n) ?? []) + if (n === stop || !inRegion(n)) continue; + queue.push(...this.successors(n)); + } + return seen; + } + + /** + * The nodes reachable from `from` with the synthetic link-hop edge never + * taken: the validator reads an `emit link` as ending its path even where + * the matching `await link` prints right behind it, so a test or join + * reached only that way stays unreachable to it once printed, whatever + * {@link reachable} says. A nested split's own branches take no part in + * this walk; it jumps straight to that split's own merge instead, since a + * hop inside a nested split is that split's own to sink around, not a gap + * in this one's routing. `splitId` stops the walk from branching further + * the way {@link reachable} walls off its own split, but stays in the + * returned set here, where {@link reachable} leaves it out. + */ + private reachableOverFlow( + from: string, + splitId: string | undefined, + ): Set { + const seen = new Set(); + const queue = [from]; + for (let i = 0; i < queue.length; i++) { + const n = queue[i]!; + if (seen.has(n)) continue; + seen.add(n); + if (n === splitId) continue; + const nested = this.ownMerges.get(n); + if (nested !== undefined) { + queue.push(nested); + continue; + } + for (const f of this.outgoingBySource.get(n) ?? []) { queue.push(f.targetRef); + } } return seen; } + /** + * Whether a real statement prints somewhere past `n`: the elided tail and + * the gateways that carry a route to it print nothing of their own, so a + * merge whose only continuation is the process ending there is safe to + * place past a hop the plain way, unlike one a printed step follows. + */ + private hasPrintableContinuation(n: string): boolean { + return [...this.reachable(n, undefined, undefined)].some( + (m) => !this.isGatewayId(m) && m !== this.elidedTail, + ); + } + + /** + * Whether `m` counts as reached from `from`: always when nothing prints + * past `m`, otherwise only along a route that crosses no link hop (see + * {@link reachableOverFlow}). + */ + private crossesHopInto( + from: string, + splitId: string | undefined, + m: string, + ): boolean { + return ( + !this.hasPrintableContinuation(m) || + this.reachableOverFlow(from, splitId).has(m) + ); + } + + /** + * Whether a walk from `from`, along routes {@link tryDoWhileEntry} has not + * yet consumed, reaches `target` with `wall` walled off. A consumed edge + * has already been given a place in the print and will not run again from + * here, so it does not carry the walk on; the wall keeps a candidate's own + * back edge from answering for itself the way {@link reachable}'s `splitId` + * keeps a block's own route from doing. + */ + private reachesUnconsumed( + from: string, + wall: string, + target: string, + ): boolean { + const seen = new Set(); + const queue = [from]; + for (let i = 0; i < queue.length; i++) { + const n = queue[i]!; + if (n === wall || seen.has(n)) continue; + if (n === target) return true; + seen.add(n); + queue.push(...this.unconsumedOut(n).map((f) => f.targetRef)); + } + return false; + } + + /** + * {@link reachable} up to where a route jumps out of the split's region, the + * nodes it dominates from the starts: the jump target is reached and + * nothing past it, since the block never runs on from there. A route into a + * gateway outside the region falls off the end of the block into an + * enclosing merge, which is no jump: a `goto` lands on a statement. + */ + private reachableInRegion( + from: string, + splitId: string, + stop: string | undefined, + ): Set { + return this.reachable(from, splitId, stop, (n) => + this.inRegion(splitId, n), + ); + } + + private inRegion(splitId: string, n: string): boolean { + return this.cfg.loopDominates(splitId, n) || this.isGatewayId(n); + } + /** * A guard clause has no clean join: one branch terminates while the * unconditioned route carries the main flow, which prints after the `if` @@ -1157,14 +1843,24 @@ class Emitter { /** * Walk or `goto` is decided per branch, since branches mix: one can flow back * to the join while another jumps away. + * + * The walk installs `this.branch` the way {@link emitBranch} does: a + * branch's route can dip past its own join into a step a sibling branch + * owns (a nested if's route skipping its own merge to rejoin a step the + * outer if's other branch already owns), and without `this.branch` set here + * {@link liesOutsideBranch} never runs for the plain continuation loop in + * {@link emitFrom}, so the walk keeps going straight past the branch's own + * join. `ownEntry` marks the branch as an if-chain one for + * {@link leavesInnermostBranch} (see the `branch` field's doc above for + * why that matters for a sibling's `goto`). */ private emitIfBranch( - entry: string, + route: SequenceFlow, join: string | undefined, splitId: string, - lines: string[], depth: number, - ): void { + ): string[] { + const entry = route.targetRef; const body: string[] = []; if (join === undefined) { this.pushGoto(entry, body); @@ -1174,74 +1870,285 @@ class Emitter { !this.emittedNodes.has(entry) && this.branchStaysInRegion(entry, join, splitId) ) { + const outerBranch = this.branch; + this.branch = { route, join, ownEntry: entry, outer: outerBranch }; this.emitFrom(entry, join, body, depth + 1); - } else { + this.branch = outerBranch; + } else if (!this.runsInto(entry, join)) { this.pushGoto(entry, body); } - for (const l of body) lines.push(INDENT + l); + return body.map((l) => INDENT + l); } /** - * Four shapes walk inline: a body the join post-dominates; a synthesized - * terminal the split owns, which prints the same statement in either scope; - * a bare authored end the split's route alone enters; and a gateway the - * split owns, which a jump could not name anyway. + * A jump at the statement the block continues with means the same as + * falling through the join, and it prints as an empty branch: printed as a + * `goto`, the next pass reads it as a route the join no longer enters and + * picks another continuation. Both routes run the same once they meet, so + * only the gateways before that point count, and only exclusive merges with + * no settings leave the route unchanged: a parallel or inclusive join there + * would count the empty branch's token where the jump bypassed it. + */ + private runsInto(entry: string, join: string): boolean { + const fromEntry = this.plainChain(entry); + const fromJoin = this.plainChain(join); + const meet = fromJoin.findIndex((n) => fromEntry.includes(n)); + if (meet < 0) return false; + const onlyMerges = (chain: string[], end: number) => + chain + .slice(0, end) + .every((n) => this.byId.get(n)?.kind === 'exclusiveGateway'); + return ( + onlyMerges(fromJoin, meet) && + onlyMerges(fromEntry, fromEntry.indexOf(fromJoin[meet]!)) + ); + } + + /** + * `id` and the nodes after it through gateways with one outgoing flow and no + * settings, ending on the first node that is not one. + */ + private plainChain(id: string): string[] { + const chain: string[] = []; + for (let n = id; !chain.includes(n);) { + chain.push(n); + const el = this.byId.get(n); + const outs = this.outgoingBySource.get(n) ?? []; + if ( + el === undefined || + !isGateway(el) || + outs.length !== 1 || + jobSettingItems(el).length > 0 + ) { + break; + } + n = outs[0]!.targetRef; + } + return chain; + } + + /** + * A route into the body of a loop still to print is a jump (see + * {@link entersUnprintedLoop}). * - * An authored entry with a chain of its own is decided by the block's tail. - * A jump hoists the chain behind everything walked from the starts; where - * that ends on the plain end the printer leaves out, the chain would push - * that end off the one position the compiler re-derives it at, so the chain - * prints inside the branch. Where the block ends on authored terminals the - * chain stays a `goto` at its authored scope, so the coordinate-derived ids - * of its unnamed events survive the round trip; and a chain that reaches the - * elided end is that tail itself and stays a `goto` as well, since inside - * the branch the end would print under its reserved id. + * An authored entry with a chain of its own is decided by the block's + * tail, once a predecessor the flow after the block also reaches (other + * than the split's own route) is ruled out: that predecessor is the + * block's own tail re-entering the chain, so it stays a `goto` regardless + * of naming. A jump hoists the chain behind everything walked from the + * starts; where that ends on the plain end the printer leaves out, the + * chain would push that end off the one position the compiler re-derives + * it at, so the chain prints inside the branch. Where the block ends on + * authored terminals and the container has no loop of its own (see + * {@link noRealLoop}), a chain carrying an unnamed throw, catch or end + * stays a `goto` at its authored scope instead: hoisting there leaves + * that id's structural coordinate alone, while inlining it is what would + * re-derive it differently. A chain built only of authored ids walks + * inline instead, since no id in it depends on where it prints. A + * container with a loop, or a chain that opens a do-while of its own, + * keeps the chain hoisted clear of it regardless of naming: hoisted, a + * `do` would follow a terminator, which the validator refuses although + * the `do` lowers to no node of its own. A chain that reaches the elided + * end is that tail itself and stays a `goto` too, since inside the branch + * the end would print under its reserved id, unless it is reached only + * through code the flow after the block runs and every route out of the + * join passes through the entry, or a loop after the block runs back into + * it (see {@link wallStaysOff}). */ private branchStaysInRegion( entry: string, join: string, splitId: string, ): boolean { + if (this.entersUnprintedLoop(splitId, entry)) return false; if (this.cfg.postDominates(join, entry)) return true; const el = this.byId.get(entry); - if ( - el === undefined || - !this.cfg.dominates(splitId, entry) || - this.cfg.dominates(join, entry) - ) { + // A predecessor an `await` or `parallel` branch jumps to, one with no + // loop-immediate-dominator (unreachable, entering nothing), or one + // reached only through a boundary handler's own re-entry (rooted beside + // it at the virtual entry, not inside this split's branches) does not + // count against ownership. Nor does a predecessor this split, or an + // enclosing branch's own split, dominates: a later `goto` back into this + // branch, or from a sibling branch, both of which a nested branch keeps + // as its own entry the same way an outermost one does. Nor does the + // back edge of a loop `entry` heads ({@link doWhileBackEdges} and the + // `ownLoopTest` clause below): a `goto` into the body stops `entry` + // loop-dominating that source, but the edge still closes the loop this + // branch prints. + const splits = [splitId]; + for (let b = this.branch; b?.ownEntry !== undefined; b = b.outer) { + splits.push(b.route.sourceRef); + } + const owned = this.cfg + .incoming(entry) + .every( + (p) => + this.cfg.loopImmediateDominator(p) === undefined || + splits.some((s) => this.cfg.dominates(s, p)) || + this.cfg.loopDominates(entry, p) || + this.doWhileBackEdges(entry).some((f) => f.sourceRef === p) || + (this.cfg.ownLoopTest(entry) === entry && + this.cfg + .backEdges() + .some((f) => f.sourceRef === p && f.targetRef === entry)) || + this.jumpsOutOfBranch(p, entry) || + this.reachedOnlyThroughBoundary(p), + ); + if (el === undefined || !owned || this.cfg.dominates(join, entry)) { return false; } if (isGateway(el)) return true; + const joinEl = this.byId.get(join); + if ( + joinEl !== undefined && + isGateway(joinEl) && + this.reachable(entry, splitId, join).has(join) + ) { + return true; + } if (isSynthesizedTerminalId(entry, el.kind, this.container)) return true; if (el.kind === 'endEvent' && this.cfg.incoming(entry).length === 1) { return true; } - return ( - this.elidedTail !== undefined && - !this.reachable(entry, splitId, join).has(this.elidedTail) + const after = this.reachable(join, splitId, undefined); + if (this.elidedTail === undefined) { + if (this.doWhileBackEdges(entry).length > 0) return true; + // Past this point `entry` is weighed only as the head of its own named + // chain, decided by whether that chain's ids stay stable inline (see + // {@link namedChainKeepsIdsInline}). A predecessor other than the + // split's own route that the flow after the block also reaches is that + // block's tail re-entering `entry` directly, not a step this branch + // alone owns, so the chain stays a `goto` at its authored scope + // regardless of what the id check says: a gateway entry, a route into + // a gateway join, a synthesized id, or a single-route end has already + // returned above. + if (this.cfg.incoming(entry).some((p) => p !== splitId && after.has(p))) { + return false; + } + return ( + this.noRealLoop(splitId) && + this.namedChainKeepsIdsInline(entry, splitId, join) + ); + } + const walled = + after.has(entry) && this.wallStaysOff(entry, join, splitId) + ? new Set() + : after; + return !this.reachable( + entry, + splitId, + join, + (n) => !walled.has(n) && this.inRegion(splitId, n), + ).has(this.elidedTail); + } + + /** + * Whether `splitId` sits outside every do-while the printer recognizes: a + * do-while keeps a leaving branch hoisted clear of its body regardless of + * naming, so only a split outside every real loop is safe for + * {@link namedChainKeepsIdsInline} to weigh. A cycle with no head + * {@link doWhileBackEdges} recognizes never prints as `while`/`do`, so it + * does not count, however many nodes it runs through. A gateway that both + * closes a do-while and branches the choice after it, one node doing + * double duty as the loop's own test and a post-loop `if`, is inside its + * own loop by this measure, so a chain leaving it stays hoisted the same + * way a chain leaving the loop body proper does. + */ + private noRealLoop(splitId: string): boolean { + return !this.cfg + .backEdges() + .some( + (f) => + this.isDoWhileTest(f) && this.cfg.loopDominates(f.targetRef, splitId), + ); + } + + /** + * Whether `p` is reached only through a boundary handler's own body: its + * loop-immediate-dominator chain (see {@link CfgAnalysis.loopImmediateDominator}) + * reaches a boundary event before it bottoms out, since a node inside a + * handler's body is rooted at that boundary the same way an ordinary node + * is rooted at the process start. An event sub-process carries no outgoing + * flow of its own, so it can never itself be a predecessor here. + */ + private reachedOnlyThroughBoundary(p: string): boolean { + for ( + let cur: string | undefined = p; + cur !== undefined; + cur = this.cfg.loopImmediateDominator(cur) + ) { + const el = this.byId.get(cur); + if (el !== undefined && isBoundary(el)) return true; + } + return false; + } + + /** + * A chain of its own, walked inline, re-derives the same id everywhere: no + * node past `entry` is a synthesized throw, catch or end, whose id comes + * from its coordinate. A node `entry` does not loop-dominate ends the + * walk: its own id is still checked, since the chain prints a `goto` to + * it, but nothing past it is, since that belongs to another branch's + * chain. A back edge does not carry this walk on: a + * `goto` back into earlier flow is the printed jump itself, not the chain's + * own continuation, and following it would pull in nodes an unrelated + * branch owns. A bare entry with nothing past it (`join` excluded, which + * prints at the same position either way) is not a chain: several such + * routes into one authored terminal share it, and hoisting keeps neither + * claiming it over the other. + */ + private namedChainKeepsIdsInline( + entry: string, + splitId: string, + join: string, + ): boolean { + const backEdgeIds = new Set(this.cfg.backEdges().map((f) => f.id)); + const chain = new Set(); + const queue = [entry]; + for (let i = 0; i < queue.length; i++) { + const n = queue[i]!; + if (n === splitId || chain.has(n)) continue; + chain.add(n); + if (n === join || !this.cfg.loopDominates(entry, n)) { + continue; + } + for (const f of this.outgoingBySource.get(n) ?? []) { + if (!backEdgeIds.has(f.id)) queue.push(f.targetRef); + } + } + if ([...chain].every((n) => n === entry || n === join)) return false; + return [...chain].every( + (n) => + n === join || + !isSynthesizedTerminalId(n, this.byId.get(n)!.kind, this.container), ); } /** * With no `join` the body runs to its own end, which is how a race with no - * merge prints. `branch` is the id of the fork or race route this body - * prints under; a loop body passes none and stays in its enclosing branch. + * merge prints. `branch` is the fork or race route this body prints under; + * a loop body passes none and stays in its enclosing branch. */ private emitBranch( entry: string, join: string | undefined, lines: string[], depth: number, - branch?: string, + branch?: SequenceFlow, ): void { const outer = this.branchPath; - if (branch !== undefined) this.branchPath = `${outer}/${branch}`; + const outerBranch = this.branch; + if (branch !== undefined) { + this.branchPath = `${outer}/${branch.id}`; + this.branch = { route: branch, join, outer: outerBranch }; + } const body: string[] = []; if (entry !== join) { this.emitFrom(entry, join, body, depth + 1); } for (const l of body) lines.push(INDENT + l); this.branchPath = outer; + this.branch = outerBranch; } /** The post-loop continuation, or `undefined` when the pattern misses. */ @@ -1281,9 +2188,21 @@ class Emitter { } /** - * An exclusive gateway with a conditioned back-edge into `node`, which - * dominates it. The condition is what tells a post-test loop from a pre-test - * `while`, which would otherwise match here through its join-to-head edge. + * An exclusive gateway with a conditioned back-edge into `node`, the body + * between them held whole (see {@link enclosesBody}). The condition is what + * tells a post-test loop from a pre-test `while`, which would otherwise + * match here through its join-to-head edge. Operaton tests a gateway's + * conditioned routes in order and the printed `while (c)` reads back as the + * first, so a back edge tested after another route would move that route's + * test behind it. + * + * Loops nested at one body entry all return to `node`, the outermost + * opening first. With more than one candidate, its own test is the one + * whose other (non-back-edge) route never runs on to another candidate's + * gateway along still-unprinted flows: a continue's other route runs on to + * the real test, where the real test's other route leaves the loop instead. + * With zero or one, the filter below holds vacuously, so that candidate (or + * none) is already the answer. */ private tryDoWhileEntry( node: string, @@ -1291,17 +2210,20 @@ class Emitter { lines: string[], depth: number, ): string | typeof STOP | undefined { - const backEdge = this.unconsumed(this.cfg.backEdges()).find((f) => { - // A route back into the head itself has no body to run before the test. - if (f.targetRef !== node || f.sourceRef === node) return false; - if (f.conditionExpression === undefined) return false; - const head = this.byId.get(f.sourceRef); - return ( - head?.kind === 'exclusiveGateway' && - this.cfg.dominates(node, f.sourceRef) - ); - }); - if (backEdge === undefined) return undefined; + const candidates = this.doWhileBackEdges(node); + const outermost = candidates.filter((f) => + (this.outgoingBySource.get(f.sourceRef) ?? []) + .filter((o) => o.id !== f.id) + .every((o) => + candidates.every( + (c) => + c.sourceRef === f.sourceRef || + !this.reachesUnconsumed(o.targetRef, f.sourceRef, c.sourceRef), + ), + ), + ); + if (outermost.length !== 1) return undefined; + const backEdge = outermost[0]!; const loopId = backEdge.sourceRef; const outs = this.unconsumedOut(loopId); @@ -1320,6 +2242,69 @@ class Emitter { return this.emitRoutes(loopId, rest, stop, lines, depth); } + private doWhileBackEdges(node: string): SequenceFlow[] { + const candidates = this.unconsumed(this.cfg.backEdges()).filter( + (f) => f.targetRef === node && this.isDoWhileTest(f), + ); + // Where `ownLoopTest` already names a distinct gateway as `node`'s own + // test, only a back edge from that gateway is a candidate: a leftover + // back edge elsewhere must not stand in once the real test is known. + // `node` naming itself, or no owner at all, leaves every candidate in. + const owner = this.cfg.ownLoopTest(node); + return owner === undefined || owner === node + ? candidates + : candidates.filter((f) => f.sourceRef === owner); + } + + /** Whether the back edge `f` closes a `do` block, tested at its source. */ + private isDoWhileTest(f: SequenceFlow): boolean { + // A route back into the head itself has no body to run before the test. + if (f.sourceRef === f.targetRef) return false; + if (f.conditionExpression === undefined) return false; + // A jump-cycle head closes on a `goto` past an enclosing loop's exit + // route, not on this gateway's own test, however conditioned it prints. + if (this.cfg.isJumpCycleHead(f.targetRef)) return false; + const head = this.byId.get(f.sourceRef); + const firstTest = this.outgoingBySource + .get(f.sourceRef) + ?.find((o) => o.conditionExpression !== undefined); + return ( + head?.kind === 'exclusiveGateway' && + firstTest === f && + this.enclosesBody(f.targetRef, head.id) && + // ownMerges is not built yet here (pairMerges is still finding it), so + // this needs flowsOnTo's plain BFS with no hops taken, not + // reachableOverFlow's nested-split shortcut. + this.flowsOnTo(f.targetRef, head.id, new Set()) + ); + } + + /** + * Whether a `do` opening at `node` and tested at `test` holds its whole + * body. A goto into the body lands on a statement; a flow from outside into + * a gateway on the way to the test closes a block the loop would open + * inside, so the test would print past that block's end. A gateway past + * the test that the body reaches around it, and that the test's exit + * reaches without first jumping back into the body, merges a block the + * test sits inside of, so the `while` cannot close the loop there. + */ + private enclosesBody(node: string, test: string): boolean { + const body = this.reachable(node, test, test); + const reachesTest = (n: string): boolean => + this.reachable(n, node, test).has(test); + const afterTest = this.reachable( + test, + node, + undefined, + (n) => n === test || !reachesTest(n), + ); + return [...body].every((n) => { + if (n === node || !isGateway(this.byId.get(n)!)) return true; + if (reachesTest(n)) return this.cfg.incoming(n).every((p) => body.has(p)); + return !afterTest.has(n); + }); + } + /** * The routes the loop pattern did not spend, marked printed before the body * is walked so a jump landing on the head reads the same routing it did @@ -1360,13 +2345,12 @@ class Emitter { } const join = - this.cleanForkJoin(splitId, outs, kind) ?? - this.convergence(splitId, outs, stop, kind) ?? + this.ownJoin(splitId, outs, stop, kind) ?? this.recoveredForkJoin(splitId, outs, kind); for (const f of outs) this.consumedFlows.add(f.id); - if (join === undefined) { + if (join === undefined && !this.routesEndApart(splitId, outs, stop)) { this.warnings.push(degradedSplitWarning(splitId)); this.emitJumps(splitId, kind, outs, lines, depth); return STOP; @@ -1393,14 +2377,61 @@ class Emitter { branches.forEach((f) => { // `emitBranch` prefixes one INDENT; wrap and re-indent for `parallel {`. const branchLines: string[] = []; - this.emitBranch(f.targetRef, join, branchLines, depth, f.id); + this.emitBranch(f.targetRef, join, branchLines, depth, f); lines.push(INDENT + this.branchHead(fork, f) + '{'); for (const l of branchLines) lines.push(INDENT + l); lines.push(INDENT + '}'); }); lines.push('}'); - return this.continueAt(join, stop, lines); + return join === undefined ? STOP : this.continueAt(join, stop, lines); + } + + /** + * A fork no merge reconverges prints as a block whose every branch runs to + * its own end, the form the compiler lowers without a join. That holds only + * while each route is a region of its own, entered through its head alone, + * that terminates or jumps to a printed node a `goto` can name: a `goto` + * may not enter a branch from outside it, and a route reaching the block's + * exit would pull the exit into its branch. + */ + private routesEndApart( + splitId: string, + outs: readonly SequenceFlow[], + stop: string | undefined, + ): boolean { + const exit = stop ?? this.elidedTail; + const route = new Map(); + for (const f of outs) { + let ends = false; + const queue = [f.targetRef]; + for (let i = 0; i < queue.length; i++) { + const n = queue[i]!; + if (n === exit) return false; + if (this.emittedNodes.has(n)) { + if (this.forwardToRealTarget(n, new Set()) === undefined) { + return false; + } + ends = true; + continue; + } + const owner = route.get(n); + if (owner === f) continue; + if (owner !== undefined) return false; + route.set(n, f); + const next = this.successors(n); + if (next.length === 0) ends = true; + queue.push(...next); + } + if (!ends) return false; + } + return [...route].every(([n, f]) => + this.cfg + .incoming(n) + .every( + (p) => route.get(p) === f || (p === splitId && n === f.targetRef), + ), + ); } /** @@ -1423,7 +2454,7 @@ class Emitter { private isImplicitFallback( fork: Fork, flow: SequenceFlow, - join: string, + join: string | undefined, ): boolean { return ( fork.kind === 'inclusiveGateway' && @@ -1515,17 +2546,6 @@ class Emitter { this.warnings.push(unweighedBranchWarning(fork.id)); } - /** Narrowed to a merge of the fork's own kind, which tells an AND merge from an XOR one. */ - private cleanForkJoin( - forkId: string, - outs: SequenceFlow[], - joinKind: Gateway['kind'], - ): string | undefined { - const join = this.cleanJoin(forkId, outs, undefined); - if (join === undefined) return undefined; - return this.byId.get(join)?.kind === joinKind ? join : undefined; - } - /** * With a branch ending at a `throw` or `end` the fork has no clean * post-dominator, but the survivors still reconverge at a merge of @@ -1534,7 +2554,13 @@ class Emitter { * no node before reconverging, so the first candidate common to every chain * is the join, and nearest keeps the continuation at this fork rather than in * a sibling's nested block. `cur !== forkId` rejects a back-edge fork that - * post-dominates itself. + * post-dominates itself. Only a merge counts: a gateway with one route out, + * or one that more than one forward route enters and that splits again. A + * lone surviving chain otherwise offers a nested split, a do-while condition + * or a while head, and a branch walked to one prints its tail behind the + * block. A merge right behind a split on the chain is that split's own join, + * whatever the split's kind: taken as a candidate, a nested block in a branch + * that ends would outvote the one-in join the surviving branch runs into. */ private recoveredForkJoin( forkId: string, @@ -1544,17 +2570,22 @@ class Emitter { const survivorChains: string[][] = []; for (const f of outs) { const chain: string[] = []; - let cur = this.cfg.immediatePostDominator(f.targetRef); + let prev = f.targetRef; + let cur = this.cfg.immediatePostDominator(prev); const seen = new Set(); while (cur !== undefined && !seen.has(cur)) { seen.add(cur); + const nested = (this.outgoingBySource.get(prev) ?? []).length > 1; if ( cur !== forkId && + !nested && this.byId.get(cur)?.kind === joinKind && + (this.isMerge(cur) || this.mergesForward(cur)) && this.cfg.dominates(forkId, cur) ) { chain.push(cur); } + prev = cur; cur = this.cfg.immediatePostDominator(cur); } if (chain.length > 0) survivorChains.push(chain); @@ -1568,6 +2599,12 @@ class Emitter { return undefined; } + /** More than one route enters `id` and none of them is a back edge. */ + private mergesForward(id: string): boolean { + const ins = this.cfg.incoming(id); + return ins.length > 1 && !ins.some((p) => this.cfg.dominates(id, p)); + } + /** * Every branch opens on a wait, and the pattern is keyed on the waits alone, * so it holds with no merge to return to, the shape of a race whose every @@ -1605,8 +2642,7 @@ class Emitter { // An XOR merge: exactly one branch of a race ever runs. const join = - this.cleanForkJoin(race.id, outs, 'exclusiveGateway') ?? - this.convergence(race.id, outs, stop, 'exclusiveGateway') ?? + this.ownJoin(race.id, outs, stop, 'exclusiveGateway') ?? this.recoveredForkJoin(race.id, outs, 'exclusiveGateway'); const settings = [ @@ -1622,7 +2658,7 @@ class Emitter { // Nothing is written between the wait and its body, so `consume` // reports a condition on the edge as on a fall-through. this.consume(body); - this.emitBranch(body.targetRef, join, branchLines, depth, outs[i]!.id); + this.emitBranch(body.targetRef, join, branchLines, depth, outs[i]); } const trigger = renderTrigger(el.eventDefinition, this.names); const head = bodyHeader( @@ -1707,6 +2743,9 @@ class Emitter { * Returning a merge does not elide it: a synthesized join has one out-edge * left once the branch edges are consumed, so `emitNode` passes through it * and prints nothing, while a real node at the merge prints its statement. + * A printed pass-through that runs into `stop` is fallen through, not jumped + * past: a second branch reaching a merge the first one walked is still in + * its block. */ private continueAt( target: string, @@ -1715,6 +2754,7 @@ class Emitter { ): string | typeof STOP { if (target === stop) return STOP; if (this.emittedNodes.has(target)) { + if (stop !== undefined && this.runsInto(target, stop)) return STOP; this.pushGoto(target, lines); return STOP; } @@ -2139,6 +3179,211 @@ function isHandler( return el.kind === 'subProcess' && el.triggeredByEvent === true; } +/** + * The link throws whose catch prints right behind them, mapped to that catch. + * The compiler lowers `emit link("X") await link("X")` inside a branch or a + * handler body as a throw with no route out and a catch no flow enters, whose + * chain carries on to a position that exists only inside the block. + * + * A throw owns its catch when the catch's chain, walked until it meets the + * throw's block, meets a node no `goto` can name: a gateway of the block, a + * merge the run of one-in steps out of the catch reaches without having opened + * the split it closes (the one-in join of an enclosing block, which the + * compiler keeps where every other branch ends), or the minted end of a + * boundary body holding the throw. A chain that meets a step instead + * jumps back by name, as the authored idiom of a catch after the process `end` + * does, and keeps its place among the entries, unless the tail rule in `owns` + * links it. + * + * The block is read off the throw's dominators, taken with the catches left + * out: the analysis enters a graph at every link catch, which would let a + * catch's chain bypass the very split that frames the throw. A catch two + * throws own stays an entry: it prints once, so it can follow only one of + * them, and every shape predicate has to agree on which. + */ +function linkedCatches( + container: FlowContainer, + elidedTail: string | undefined, +): Map { + const entered = new Set(container.sequenceFlows.map((f) => f.targetRef)); + const byId = new Map(container.flowElements.map((el) => [el.id, el])); + const catches = new Map(); + for (const el of container.flowElements) { + if ( + el.kind === 'intermediateCatchEvent' && + el.eventDefinition.kind === 'link' && + !entered.has(el.id) && + !catches.has(el.eventDefinition.linkName) + ) { + catches.set(el.eventDefinition.linkName, el.id); + } + } + const catchIds = new Set(catches.values()); + // No link catches to own means no throw can be linked either, so skip the + // two `analyzeCfg` passes below: every container without a link pair would + // otherwise pay for them. + if (catchIds.size === 0) return new Map(); + const cfg = analyzeCfg({ + ...container, + flowElements: container.flowElements.filter((el) => !catchIds.has(el.id)), + }); + const outgoing = new Map(); + const incoming = new Map(); + for (const f of container.sequenceFlows) { + outgoing.set(f.sourceRef, [ + ...(outgoing.get(f.sourceRef) ?? []), + f.targetRef, + ]); + incoming.set(f.targetRef, (incoming.get(f.targetRef) ?? 0) + 1); + } + const next = (id: string): string[] => outgoing.get(id) ?? []; + // A loop's gateways open and close the loop itself, never a block around it. + const backEdges = analyzeCfg(container).backEdges(); + const loopGateways = new Set( + backEdges.flatMap((f) => [f.sourceRef, f.targetRef]), + ); + // A do-loop's back edge lands on its own first body step, not a gateway, so + // that step still shows two incoming flows in `incoming`. Its forward-only + // count is what decides whether the step stays on this chain's run. + const forwardIn = (id: string): number => + (incoming.get(id) ?? 0) - + backEdges.filter((f) => f.targetRef === id).length; + const onward = (id: string): string[] => { + const el = byId.get(id); + const hop = + el?.kind === 'intermediateThrowEvent' && + el.eventDefinition.kind === 'link' + ? catches.get(el.eventDefinition.linkName) + : undefined; + return hop === undefined ? next(id) : [hop]; + }; + // Starts print before catch entries, so a start reaching the elided tail + // over plain flows claims that position first regardless of what the + // catch's own chain reaches. + const tailFromStarts = (() => { + if (elidedTail === undefined) return false; + const seen = new Set(); + const queue = container.flowElements + .filter((el) => el.kind === 'startEvent') + .map((el) => el.id); + while (queue.length > 0) { + const n = queue.pop()!; + if (seen.has(n)) continue; + seen.add(n); + queue.push(...next(n)); + } + return seen.has(elidedTail); + })(); + + const owns = (throwId: string, catchId: string): boolean => { + const dominators = new Set(); + for ( + let d = cfg.immediateDominator(throwId); + d !== undefined && byId.has(d); + d = cfg.immediateDominator(d) + ) { + dominators.add(d); + } + const block = new Set(); + const pending = [...dominators].flatMap(next); + while (pending.length > 0) { + const n = pending.pop()!; + if (block.has(n) || dominators.has(n)) continue; + block.add(n); + pending.push(...next(n)); + } + + const walk = (step: (id: string) => string[]) => { + const chain = new Set(); + // Each entry carries the splits the chain has opened and not yet + // closed, or undefined once it has left the run of one-in steps out of + // the catch, where a goto could have named its way in. + const first = next(catchId); + const queue: [string, number | undefined][] = first.map((n) => [ + n, + first.length === 1 ? 0 : undefined, + ]); + let unnameable = false; + while (queue.length > 0) { + const [n, open] = queue.pop()!; + if (chain.has(n)) continue; + chain.add(n); + const node = byId.get(n); + if (node === undefined) continue; + const gateway = isGateway(node); + if (block.has(n) || dominators.has(n)) { + unnameable ||= gateway; + continue; + } + const merge = gateway && next(n).length === 1; + unnameable ||= + (open === 0 && merge) || + [...dominators].some( + (d) => + byId.get(d)?.kind === 'boundaryEvent' && isMintedEndId(n, d, []), + ); + const onRun = + open !== undefined && + (gateway || (forwardIn(n) === 1 && next(n).length === 1)); + const opens = !gateway || loopGateways.has(n) ? 0 : merge ? -1 : 1; + const depth = onRun ? open + opens : undefined; + queue.push( + ...step(n).map((m): [string, number | undefined] => [m, depth]), + ); + } + return { chain, unnameable }; + }; + const own = walk(next); + // The tail rule: where the container ends on the plain end the printer + // leaves out, a chain that never reaches that end links too. Printed as an + // entry, it would push that end off the tail, the one position the + // compiler re-derives it at. A chain that does reach the end is still an + // entry printed after it when a start already reaches that end: the start + // prints first and claims the tail, so this chain's own arrival there + // would still push it off. + const displacesTail = + elidedTail !== undefined && + (!own.chain.has(elidedTail) || tailFromStarts); + // A chain ending in another throw rejoins the block through that throw's + // catch, wherever that catch prints. + return own.unnameable || displacesTail || walk(onward).unnameable; + }; + + const owned = container.flowElements.flatMap((el) => { + if ( + el.kind !== 'intermediateThrowEvent' || + el.eventDefinition.kind !== 'link' + ) { + return []; + } + const catchId = catches.get(el.eventDefinition.linkName); + return catchId !== undefined && owns(el.id, catchId) + ? [[el.id, catchId] as const] + : []; + }); + return new Map( + owned.filter(([, c]) => owned.filter(([, o]) => o === c).length === 1), + ); +} + +/** `container` with each throw -> catch hop drawn as a flow, which also stops the analysis entering at that catch. */ +function withLinkHops( + container: FlowContainer, + linked: ReadonlyMap, +): FlowContainer { + return { + ...container, + sequenceFlows: [ + ...container.sequenceFlows, + ...[...linked].map(([sourceRef, targetRef]) => ({ + id: `${sourceRef}->${targetRef}`, + sourceRef, + targetRef, + })), + ], + }; +} + /** A boundary event has outgoing but no incoming flow, so a dedicated pass prints it. */ function isBoundary( el: FlowElement, @@ -2146,6 +3391,19 @@ function isBoundary( return el.kind === 'boundaryEvent'; } +/** + * The statement-order rank `id` would claim on the next compile if it were + * minted from `base` by {@link resolveCollision}: 1 for `base` itself, n for + * `base_n`. An authored id ranks after every minted one: the print drops it, + * so it is minted afresh on the next compile, and printed ahead of a minted + * sibling it would take that sibling's id. + */ +function boundaryIdRank(id: string, base: string): number { + if (id === base) return 1; + const suffix = id.startsWith(`${base}_`) ? id.slice(base.length + 1) : ''; + return /^[2-9]\d*$/.test(suffix) ? Number(suffix) : Infinity; +} + /** The definitions an `end` spells in its own head rather than raising; the word printed is the kind. */ const END_CARRIED_KINDS = new Set(END_TRIGGERS); @@ -2185,8 +3443,11 @@ export function isElidedOnPrint( if (!isSynthesizedTerminalId(el.id, el.kind, container)) return false; // The compiler cannot re-derive a definition the `end` statement // carries, so that always prints, and `end`'s mandatory `name=ID` means - // the synthesized id prints too. - if (el.eventDefinition === undefined) return !carriesPrintableContent(el); + // the synthesized id prints too. A `name` is the same: nothing but this + // statement can carry it, so it forces the print too, unlike a plain + // start (`isPlainUnnamed`), which drops a name at import instead. + if (el.eventDefinition === undefined) + return el.name === undefined && !carriesPrintableContent(el); // Every other definition prints as a `throw`, which drops a synthesized name. return !isEndCarried(el.eventDefinition); case 'intermediateThrowEvent': @@ -2246,9 +3507,10 @@ function isPlainUnnamed( /** * Every reader goes through {@link isElidedOnPrint}: a reason to print * reaching one reader but not another would leave a jump naming a statement - * never emitted. A label is not such a reason: printing the id to carry one - * writes a name the validator rejects, so the label is reported on import - * instead. + * never emitted. A start's label is not such a reason: printing the id to + * carry one writes a name the validator rejects, so `isPlainUnnamed` reports + * it on import instead; an end's label has nowhere else to go, so its caller + * checks it separately. */ function carriesPrintableContent( el: Extract, @@ -3168,15 +4430,17 @@ function codeDeclarations(process: BpmnProcess): { /** * A `var : any` line per variable the body reads bare and nothing - * types, in first-appearance order. BPMN has no slot for a declaration, so the - * print declares what it writes in a variable position (a condition, a count, - * an `until`, an error mapping's `when`, a conditional trigger, an `in` source, - * a bare collection) minus what the validator's symbol table already holds - * (`DefaultVariableSymbolProvider.collect`: a form field, a catch binding, an - * io parameter name, an element variable, and the loop counters once anything - * repeats); `externalTask` in a mapping and an `out` source are exempt there - * too. The type is `any` because a read says nothing more, and every - * declaration of one name has to agree on the type. + * types, sorted by name: a restructured print reorders statements, so any + * order read off the model would move on the next print, while a name sort is + * the same on every pass by construction. BPMN has no slot for a declaration, + * so the print declares what it writes in a variable position (a condition, a + * count, an `until`, an error mapping's `when`, a conditional trigger, an `in` + * source, a bare collection) minus what the validator's symbol table already + * holds (`DefaultVariableSymbolProvider.collect`: a form field, a catch + * binding, an io parameter name, an element variable, and the loop counters + * once anything repeats); `externalTask` in a mapping and an `out` source are + * exempt there too. The type is `any` because a read says nothing more, and + * every declaration of one name has to agree on the type. */ function variableDecls(process: BpmnProcess): string[] { const reads = new Set(); @@ -3247,6 +4511,7 @@ function variableDecls(process: BpmnProcess): string[] { if (anyRepeats) for (const name of LOOP_VARIABLES) typed.add(name); return [...reads] .filter((name) => !typed.has(name)) + .sort() .map((name) => `${INDENT}var ${name}: any`); } diff --git a/packages/transform/src/synthesize-ids.ts b/packages/transform/src/synthesize-ids.ts index 32fc9ca..1687dcb 100644 --- a/packages/transform/src/synthesize-ids.ts +++ b/packages/transform/src/synthesize-ids.ts @@ -115,7 +115,12 @@ export function makeBoundaryEventId( trigger: string, taken: Set, ): string { - return claimId(`Boundary_${hostId}_${trigger}`, taken); + return claimId(boundaryEventIdBase(hostId, trigger), taken); +} + +/** The id the first boundary on `hostId` with this trigger claims; siblings add `_2`, `_3`, ... */ +export function boundaryEventIdBase(hostId: string, trigger: string): string { + return `Boundary_${hostId}_${trigger}`; } function claimId(base: string, taken: Set): string { diff --git a/packages/transform/test/cfg-analysis.test.ts b/packages/transform/test/cfg-analysis.test.ts index f88b952..d897905 100644 --- a/packages/transform/test/cfg-analysis.test.ts +++ b/packages/transform/test/cfg-analysis.test.ts @@ -33,20 +33,23 @@ const linkCatch = (id: string, linkName: string): FlowElement => typedEvent('intermediateCatchEvent', id, { kind: 'link', linkName }); /** - * A process from its nodes and a whitespace-separated `source>target` list. - * An edge naming an undeclared element is a typo in the fixture, and would - * otherwise be dropped without a trace by the analysis itself. + * A process from its nodes and a whitespace-separated `source>target` list, + * `source>target?` for a conditioned flow. An edge naming an undeclared + * element is a typo in the fixture, and would otherwise be dropped without a + * trace by the analysis itself. */ function graph(flowElements: FlowElement[], edges: string): BpmnProcess { const declared = new Set(flowElements.map((e) => e.id)); return minimalProcess( flowElements, edges.split(/\s+/).map((spec) => { - const [source, target] = spec.split('>'); + const [source, target] = spec.replace(/\?$/, '').split('>'); if (!declared.has(source) || !declared.has(target)) { throw new Error(`edge "${spec}" names an undeclared element`); } - return edge(source, target); + return edge(source, target, { + condition: spec.endsWith('?') ? '${c}' : undefined, + }); }), ); } @@ -82,6 +85,12 @@ function describeCfg(process: BpmnProcess): string[] { ].join(' '), ), `back-edges=${list(cfg.backEdges().map((f) => f.id))}`, + `past-head=${list( + process.sequenceFlows.flatMap((f) => { + const heads = cfg.headsEnteredPast(f.sourceRef, f.targetRef); + return heads.length === 0 ? [] : [`${f.id}(${heads.join(',')})`]; + }), + )}`, ]; } @@ -98,6 +107,7 @@ const DIAMOND = [ 'end in=join out=EXIT idom=join ipdom=EXIT dom=ENTRY,start,split,join,end pdom=end,EXIT', 'EXIT in=end out=- idom=end ipdom=- dom=ENTRY,start,split,join,end,EXIT pdom=EXIT', 'back-edges=-', + 'past-head=-', ]; const INTERMEDIATE_EVENT_EDGES = 'start>task task>node node>end'; @@ -111,6 +121,7 @@ const INTERMEDIATE_EVENT = [ 'end in=node out=EXIT idom=node ipdom=EXIT dom=ENTRY,start,task,node,end pdom=end,EXIT', 'EXIT in=end out=- idom=end ipdom=- dom=ENTRY,start,task,node,end,EXIT pdom=EXIT', 'back-edges=-', + 'past-head=-', ]; const CASES: [title: string, process: BpmnProcess, expected: string[]][] = [ @@ -159,6 +170,7 @@ const CASES: [title: string, process: BpmnProcess, expected: string[]][] = [ 'end in=exit out=EXIT idom=exit ipdom=EXIT dom=ENTRY,start,head,exit,end pdom=end,EXIT', 'EXIT in=end out=- idom=end ipdom=- dom=ENTRY,start,head,exit,end,EXIT pdom=EXIT', 'back-edges=Flow_body_head', + 'past-head=-', ], ], [ @@ -175,22 +187,114 @@ const CASES: [title: string, process: BpmnProcess, expected: string[]][] = [ 'end2 in=split out=EXIT idom=split ipdom=EXIT dom=ENTRY,start,split,end2 pdom=end2,EXIT', 'EXIT in=end1,end2 out=- idom=split ipdom=- dom=ENTRY,start,split,EXIT pdom=EXIT', 'back-edges=-', + 'past-head=-', ], ], + ...(['A', 'B'] as const).map((first): [string, BpmnProcess, string[]] => { + const second = first === 'A' ? 'B' : 'A'; + return [ + `an irreducible cycle of steps the start enters at ${first} first, then at ${second}, closes at A, the entry listed first, and with no test on it no flow enters past a head`, + graph( + [start('start'), task('A'), task('B'), end('end')], + `start>${first} start>${second} A>B B>A A>end B>end`, + ), + [ + 'ENTRY in=- out=start idom=- ipdom=start dom=ENTRY pdom=ENTRY,start,end,EXIT', + `start in=ENTRY out=${first},${second} idom=ENTRY ipdom=end dom=ENTRY,start pdom=start,end,EXIT`, + 'A in=start,B out=B,end idom=start ipdom=end dom=ENTRY,start,A pdom=A,end,EXIT', + 'B in=start,A out=A,end idom=start ipdom=end dom=ENTRY,start,B pdom=B,end,EXIT', + 'end in=A,B out=EXIT idom=start ipdom=EXIT dom=ENTRY,start,end pdom=end,EXIT', + 'EXIT in=end out=- idom=end ipdom=- dom=ENTRY,start,end,EXIT pdom=EXIT', + 'back-edges=Flow_B_A', + 'past-head=-', + ], + ]; + }), [ - 'an irreducible cycle entered from outside at both nodes reports no back-edge, because neither node dominates the other', + // No route here ever reaches an end (join>B loops back into the cycle + // with nothing past it), so `head`, the entered loop's own test, takes + // the post-dominator tree it would have if its untaken exit route led + // out, and the two-ends-diamond shape holds again from there: `head`'s + // own routes and its new one to the exit share no other node. + "a jump after an if, through its merge into a while in a branch, leaves the while's test the head although the merge is listed first, and a merge is entered past no head", graph( - [start('start'), task('A'), task('B'), end('end')], - 'start>A start>B A>B B>A A>end B>end', + [ + start('start'), + gateway('split'), + gateway('join'), + task('Z'), + gateway('head'), + task('A'), + task('B'), + ], + 'start>split split>Z? split>head Z>join head>A? A>B B>head head>join join>B', ), [ - 'ENTRY in=- out=start idom=- ipdom=start dom=ENTRY pdom=ENTRY,start,end,EXIT', - 'start in=ENTRY out=A,B idom=ENTRY ipdom=end dom=ENTRY,start pdom=start,end,EXIT', - 'A in=start,B out=B,end idom=start ipdom=end dom=ENTRY,start,A pdom=A,end,EXIT', - 'B in=start,A out=A,end idom=start ipdom=end dom=ENTRY,start,B pdom=B,end,EXIT', - 'end in=A,B out=EXIT idom=start ipdom=EXIT dom=ENTRY,start,end pdom=end,EXIT', - 'EXIT in=end out=- idom=end ipdom=- dom=ENTRY,start,end,EXIT pdom=EXIT', - 'back-edges=-', + 'ENTRY in=- out=start idom=- ipdom=start dom=ENTRY pdom=ENTRY,start,split,head,EXIT', + 'start in=ENTRY out=split idom=ENTRY ipdom=split dom=ENTRY,start pdom=start,split,head,EXIT', + 'split in=start out=Z,head idom=start ipdom=head dom=ENTRY,start,split pdom=split,head,EXIT', + 'join in=Z,head out=B idom=split ipdom=B dom=ENTRY,start,split,join pdom=join,head,B,EXIT', + 'Z in=split out=join idom=split ipdom=join dom=ENTRY,start,split,Z pdom=join,Z,head,B,EXIT', + 'head in=split,B out=A,join idom=split ipdom=EXIT dom=ENTRY,start,split,head pdom=head,EXIT', + 'A in=head out=B idom=head ipdom=B dom=ENTRY,start,split,head,A pdom=head,A,B,EXIT', + 'B in=A,join out=head idom=split ipdom=head dom=ENTRY,start,split,B pdom=head,B,EXIT', + 'EXIT in=- out=- idom=- ipdom=- dom=- pdom=EXIT', + 'back-edges=Flow_B_head', + 'past-head=-', + ], + ], + [ + 'a jump into a while body closes the cycle at the while test, although the step it lands on is listed first', + graph( + [ + start('start'), + gateway('split'), + task('B'), + task('A'), + gateway('head'), + end('end'), + ], + 'start>split split>B? split>head head>A? A>B B>head head>end', + ), + [ + 'ENTRY in=- out=start idom=- ipdom=start dom=ENTRY pdom=ENTRY,start,split,head,end,EXIT', + 'start in=ENTRY out=split idom=ENTRY ipdom=split dom=ENTRY,start pdom=start,split,head,end,EXIT', + 'split in=start out=B,head idom=start ipdom=head dom=ENTRY,start,split pdom=split,head,end,EXIT', + 'B in=split,A out=head idom=split ipdom=head dom=ENTRY,start,split,B pdom=B,head,end,EXIT', + 'A in=head out=B idom=head ipdom=B dom=ENTRY,start,split,A,head pdom=B,A,head,end,EXIT', + 'head in=split,B out=A,end idom=split ipdom=end dom=ENTRY,start,split,head pdom=head,end,EXIT', + 'end in=head out=EXIT idom=head ipdom=EXIT dom=ENTRY,start,split,head,end pdom=end,EXIT', + 'EXIT in=end out=- idom=end ipdom=- dom=ENTRY,start,split,head,end,EXIT pdom=EXIT', + 'back-edges=Flow_B_head', + 'past-head=Flow_split_B(head)', + ], + ], + [ + 'a jump into a do-while body closes the cycle at the step the test returns to, although the step it lands on is listed first', + graph( + [ + start('start'), + gateway('split'), + task('B'), + gateway('join'), + task('A'), + gateway('test'), + end('end'), + ], + 'start>split split>B? split>join join>A A>B B>test test>A? test>end', + ), + [ + 'ENTRY in=- out=start idom=- ipdom=start dom=ENTRY pdom=ENTRY,start,split,B,test,end,EXIT', + 'start in=ENTRY out=split idom=ENTRY ipdom=split dom=ENTRY,start pdom=start,split,B,test,end,EXIT', + 'split in=start out=B,join idom=start ipdom=B dom=ENTRY,start,split pdom=split,B,test,end,EXIT', + 'B in=split,A out=test idom=split ipdom=test dom=ENTRY,start,split,B pdom=B,test,end,EXIT', + 'join in=split out=A idom=split ipdom=A dom=ENTRY,start,split,join pdom=B,join,A,test,end,EXIT', + 'A in=join,test out=B idom=split ipdom=B dom=ENTRY,start,split,A pdom=B,A,test,end,EXIT', + 'test in=B out=A,end idom=B ipdom=end dom=ENTRY,start,split,B,test pdom=test,end,EXIT', + 'end in=test out=EXIT idom=test ipdom=EXIT dom=ENTRY,start,split,B,test,end pdom=end,EXIT', + 'EXIT in=end out=- idom=end ipdom=- dom=ENTRY,start,split,B,test,end,EXIT pdom=EXIT', + 'back-edges=Flow_test_A', + 'past-head=Flow_split_B(A)', ], ], [ @@ -207,6 +311,7 @@ const CASES: [title: string, process: BpmnProcess, expected: string[]][] = [ 'end in=reachable,orphan out=EXIT idom=reachable ipdom=EXIT dom=ENTRY,start,reachable,end pdom=end,EXIT', 'EXIT in=end out=- idom=end ipdom=- dom=ENTRY,start,reachable,end,EXIT pdom=EXIT', 'back-edges=-', + 'past-head=-', ], ], [ @@ -233,6 +338,7 @@ const CASES: [title: string, process: BpmnProcess, expected: string[]][] = [ 'end in=head2 out=EXIT idom=head2 ipdom=EXIT dom=ENTRY,start,head1,head2,end pdom=end,EXIT', 'EXIT in=end out=- idom=end ipdom=- dom=ENTRY,start,head1,head2,end,EXIT pdom=EXIT', 'back-edges=Flow_body2_head2,Flow_body1_head1', + 'past-head=-', ], ], [ @@ -258,6 +364,7 @@ const CASES: [title: string, process: BpmnProcess, expected: string[]][] = [ 'escapeEnd in=escapeA out=EXIT idom=escapeA ipdom=EXIT dom=ENTRY,Boundary_main_error,escapeA,escapeEnd pdom=escapeEnd,EXIT', 'EXIT in=end,escapeEnd out=- idom=ENTRY ipdom=- dom=ENTRY,EXIT pdom=EXIT', 'back-edges=-', + 'past-head=-', ], ], [ @@ -289,6 +396,7 @@ const CASES: [title: string, process: BpmnProcess, expected: string[]][] = [ 'endB in=joinB out=EXIT idom=joinB ipdom=EXIT dom=ENTRY,Boundary_main_error,splitB,joinB,endB pdom=endB,EXIT', 'EXIT in=end,endB out=- idom=ENTRY ipdom=- dom=ENTRY,EXIT pdom=EXIT', 'back-edges=-', + 'past-head=-', ], ], [ @@ -312,6 +420,7 @@ const CASES: [title: string, process: BpmnProcess, expected: string[]][] = [ 'end in=shared out=EXIT idom=shared ipdom=EXIT dom=ENTRY,shared,end pdom=end,EXIT', 'EXIT in=end out=- idom=end ipdom=- dom=ENTRY,shared,end,EXIT pdom=EXIT', 'back-edges=-', + 'past-head=-', ], ], [ @@ -339,6 +448,7 @@ const CASES: [title: string, process: BpmnProcess, expected: string[]][] = [ 'escEnd in=head2 out=EXIT idom=head2 ipdom=EXIT dom=ENTRY,Boundary_main_error,head2,escEnd pdom=escEnd,EXIT', 'EXIT in=end,escEnd out=- idom=ENTRY ipdom=- dom=ENTRY,EXIT pdom=EXIT', 'back-edges=Flow_body2_head2', + 'past-head=-', ], ], [ @@ -360,6 +470,7 @@ const CASES: [title: string, process: BpmnProcess, expected: string[]][] = [ 'e in=esc out=EXIT idom=esc ipdom=EXIT dom=ENTRY,B,esc,e pdom=e,EXIT', 'EXIT in=main,e out=- idom=ENTRY ipdom=- dom=ENTRY,EXIT pdom=EXIT', 'back-edges=-', + 'past-head=-', ], ], [ @@ -385,6 +496,7 @@ const CASES: [title: string, process: BpmnProcess, expected: string[]][] = [ 'end in=retry out=EXIT idom=retry ipdom=EXIT dom=ENTRY,AtRetry,retry,end pdom=end,EXIT', 'EXIT in=ToRetry,end out=- idom=ENTRY ipdom=- dom=ENTRY,EXIT pdom=EXIT', 'back-edges=-', + 'past-head=-', ], ], [ @@ -431,6 +543,7 @@ const CASES: [title: string, process: BpmnProcess, expected: string[]][] = [ 'e in=m out=EXIT idom=m ipdom=EXIT dom=ENTRY,s,m,e pdom=e,EXIT', 'EXIT in=e out=- idom=e ipdom=- dom=ENTRY,s,m,e,EXIT pdom=EXIT', 'back-edges=-', + 'past-head=-', ], ], ]; @@ -440,6 +553,46 @@ describe('cfg analysis', () => { expect(describeCfg(process)).toEqual(expected); }); + it('a loop whose own exit route re-enters another loop, with nothing after it, gets the post-dominator tree its guarded twin has', () => { + // Two while loops (head1, head2) nested through an if (split): head1's + // own exit route normally continues after the loop, but here it jumps + // straight into head2's body (B), past head2's own test, and nothing + // follows, so no node ever reaches an end. The guarded twin puts that + // same jump behind a gateway (guard) that can also fall through to a + // real end, so its post-dominator tree is what the no-exit variant + // should have too, with `guard` standing in for the virtual exit. + const elements = [ + start('start'), + gateway('head1'), + task('A'), + gateway('split'), + gateway('head2'), + task('B'), + task('C'), + gateway('join'), + task('D'), + ]; + const noExit = graph( + elements, + 'start>head1 head1>A? head1>B A>split split>head2? split>join head2>B? head2>join B>C C>head2 join>D D>head1', + ); + const guarded = graph( + [...elements, gateway('guard'), end('end')], + 'start>head1 head1>A? head1>guard A>split split>head2? split>join head2>B? head2>join B>C C>head2 join>D D>head1 guard>B? guard>end', + ); + const noExitCfg = analyzeCfg(noExit); + const guardedCfg = analyzeCfg(guarded); + // head1's own exit is the jump that never reaches an end, so it takes + // the guard's place; every other node's post-dominator carries over. + const expected = (id: string): string | undefined => { + const viaGuard = guardedCfg.immediatePostDominator(id); + return viaGuard === 'guard' ? VIRTUAL_EXIT : viaGuard; + }; + expect( + elements.map((el) => [el.id, noExitCfg.immediatePostDominator(el.id)]), + ).toEqual(elements.map((el) => [el.id, expected(el.id)])); + }); + it('answers every query for an id no element declares, rather than throwing', () => { const cfg = analyzeCfg(graph([start('s'), task('m'), end('e')], 's>m m>e')); @@ -452,4 +605,154 @@ describe('cfg analysis', () => { expect(cfg.postDominates('nope', 'm')).toBe(false); expect(cfg.postDominates('m', 'nope')).toBe(false); }); + + // A do-while whose body if/if nests two steps deep (A -> if c -> B -> if d + // -> C), closed by an unconditioned goto to C after the loop: the goto + // lands past the loop's own head, on the same statement its own `if d` + // already reaches, so the two form one cycle with the do-while's test. + const LIFDT_POST_C_ELEMENTS = [ + start('start'), + task('A'), + gateway('splitC'), + gateway('joinC'), + task('B'), + gateway('splitD'), + gateway('joinD'), + task('C'), + gateway('loop'), + ]; + const LIFDT_POST_C = graph( + LIFDT_POST_C_ELEMENTS, + 'start>A A>splitC splitC>B? splitC>joinC B>splitD splitD>C? splitD>joinD C>joinD joinD>joinC joinC>loop loop>A? loop>C', + ); + // The same shape with the goto behind a guard (`if (e) { goto C }`) after + // the loop: the guard's own test also closes only at that statement. + const LIFDT_POST_C_EXIT = graph( + [ + ...LIFDT_POST_C_ELEMENTS, + gateway('guard'), + gateway('guardJoin'), + end('end'), + ], + 'start>A A>splitC splitC>B? splitC>joinC B>splitD splitD>C? splitD>joinD C>joinD joinD>joinC joinC>loop loop>A? loop>guard guard>C? guard>guardJoin guardJoin>end', + ); + + it.each([ + [ + "a jump after a do-while lands on the same step its own nested if reaches: the jump closes the loop's exit route, not a nested test, and its head is that step, not the ifs' merge", + LIFDT_POST_C, + ['Flow_loop_A', 'Flow_loop_C'], + true, + ], + [ + "the same jump behind a guard still closes the outer loop's exit route: the guard's own test is a cycle of jumps too, not a nested do-while on the guard", + LIFDT_POST_C_EXIT, + ['Flow_loop_A', 'Flow_guard_C'], + true, + ], + ] as [ + title: string, + process: BpmnProcess, + backEdges: string[], + jumpCycleHeadC: boolean, + ][])('%s', (_title, process, backEdges, jumpCycleHeadC) => { + const cfg = analyzeCfg(process); + expect(cfg.backEdges().map((f) => f.id)).toEqual(backEdges); + expect(cfg.isJumpCycleHead('C')).toBe(jumpCycleHeadC); + }); + + const JUMP_CYCLE_ELEMENTS = [ + start('start'), + gateway('split'), + gateway('join'), + task('A'), + gateway('ifSplit'), + task('B'), + gateway('ifJoin'), + task('C'), + ]; + it.each([ + ['listed in statement order', JUMP_CYCLE_ELEMENTS], + ['listed with the jumped-to step first', JUMP_CYCLE_ELEMENTS.toReversed()], + ])( + 'an if jumping into a cycle of plain flow, the flow before it falling in elsewhere, is no do-while test when %s', + (_title, elements) => { + const cfg = analyzeCfg( + graph( + elements, + 'start>split split>B? split>join join>A A>ifSplit ifSplit>B? ifSplit>ifJoin B>ifJoin ifJoin>C C>A', + ), + ); + expect(cfg.backEdges().map((f) => f.id)).toEqual(['Flow_C_A']); + expect(cfg.ownLoopTest('B')).toBeUndefined(); + }, + ); + + it("a conditioned goto into a do-while body is no natural entry, so the loop's own test keeps its single back edge", () => { + // An `if (e) { goto B }` ahead of a do-while whose body nests + // `if (c) { ...; if (d) {...} }`, in the flow-element and sequence-flow + // order of one shuffled import. A conditioned outside entry into `B` is + // not natural; counted as one, the outer test's body would claim the + // loop's own test and add a second back edge. + const elements = [ + gateway('Gateway_p_1_1_t_1_join'), + task('B'), + gateway('Gateway_p_0_join'), + end('EndEvent_p'), + gateway('Gateway_p_0_split'), + gateway('Gateway_p_1_1_join'), + gateway('Gateway_p_1_1_split'), + task('C'), + gateway('Gateway_p_1_loop'), + task('A'), + gateway('Gateway_p_1_1_t_1_split'), + start('StartEvent_p'), + ]; + const process = graph( + elements, + 'Gateway_p_0_split>B? B>Gateway_p_1_1_t_1_split ' + + 'Gateway_p_1_1_split>Gateway_p_1_1_join ' + + 'Gateway_p_1_1_t_1_join>Gateway_p_1_1_join ' + + 'Gateway_p_1_1_t_1_split>Gateway_p_1_1_t_1_join ' + + 'Gateway_p_1_loop>EndEvent_p Gateway_p_1_loop>A? ' + + 'C>Gateway_p_1_1_t_1_join Gateway_p_1_1_t_1_split>C? ' + + 'Gateway_p_0_join>A StartEvent_p>Gateway_p_0_split ' + + 'A>Gateway_p_1_1_split Gateway_p_1_1_join>Gateway_p_1_loop ' + + 'Gateway_p_1_1_split>B? Gateway_p_0_split>Gateway_p_0_join', + ); + expect( + analyzeCfg(process) + .backEdges() + .map((f) => f.id), + ).toEqual(['Flow_Gateway_p_1_loop_A']); + }); + + it("hasNaturalEntry counts only a flow from outside the cycle: in the element order below, the inner loop's own unconditioned back edge (A>B) would otherwise pass for the outer loop's outside entry and steal its back edge", () => { + const elements = [ + gateway('Gateway_p_1_loop'), + task('B'), + gateway('Gateway_p_1_1_loop'), + gateway('Gateway_p_0_split'), + gateway('Gateway_p_0_join'), + task('A'), + task('C'), + start('StartEvent_p'), + ]; + const process = graph( + elements, + 'Gateway_p_1_loop>A? Gateway_p_1_1_loop>Gateway_p_1_loop ' + + 'Gateway_p_1_1_loop>B? B>C Gateway_p_0_join>A ' + + 'C>Gateway_p_1_1_loop Gateway_p_0_split>Gateway_p_0_join A>B ' + + 'Gateway_p_1_loop>C Gateway_p_0_split>B? StartEvent_p>Gateway_p_0_split', + ); + expect( + analyzeCfg(process) + .backEdges() + .map((f) => f.id), + ).toEqual([ + 'Flow_Gateway_p_1_loop_A', + 'Flow_Gateway_p_1_1_loop_B', + 'Flow_Gateway_p_1_loop_C', + ]); + }); }); diff --git a/packages/transform/test/ir-to-dsl.test.ts b/packages/transform/test/ir-to-dsl.test.ts index 0c6b41b..4daaa6c 100644 --- a/packages/transform/test/ir-to-dsl.test.ts +++ b/packages/transform/test/ir-to-dsl.test.ts @@ -1655,6 +1655,34 @@ describe('irToDsl: link pairs and named catches', () => { expect(dsl).toContain('await message Wait("M")\n await message("M")'); }); + it('of several throws of one link name, none takes the catch along, so no throw strands behind the process end', async () => { + const ir = await reDesugar( + 'process p { var c: any var x: any start S if (c) { if (x) { emit link("L") } else { step Q emit link("L") } await link("L") step B } step Z end Done }', + ); + expect(await printed(ir)).toBe( + [ + 'process p {', + ' var c: any', + ' var x: any', + ' start S', + ' if (c) {', + ' if (x) {', + ' emit link("L")', + ' }', + ' step Q', + ' emit link("L")', + ' }', + ' step Z', + ' end Done', + ' await link("L")', + ' step B', + ' goto Z', + '}', + '', + ].join('\n'), + ); + }); + it('prints a goto into a named await as a jump, not a dropped edge', async () => { const ir = minimalProcess( [ @@ -2331,8 +2359,8 @@ describe('irToDsl: ids the script cannot spell print under a minted name', () => // A synthesized plain end left out anywhere but its block's tail would wire its // predecessor into whatever follows, so it prints under its reserved id and is -// reported. The compiler never mints one where rows 2 and 3 put it, so those -// rows compile an authored end there and rename it afterwards. +// reported. The compiler never mints one where the rows given as source put +// it, so those rows compile an authored end there and rename it afterwards. describe("irToDsl: a synthesized plain end that is not its block's tail", () => { const start = (id: string): FlowElement => ({ kind: 'startEvent', id }); const step = (id: string): FlowElement => ({ kind: 'task', id }); @@ -2368,21 +2396,14 @@ describe("irToDsl: a synthesized plain end that is not its block's tail", () => [], ], [ - // A link catch is an entry of its own, so its chain is never walked - // inside the guard the way a chain the split owns is. - "a plain end whose chain a link catch's chain follows prints before that catch, and its label rides along", - 'process p { start S step A if (x) { emit link L("x") } step C end Done(label: "Order filed") await link M("x") step B end Fin }', + "a plain end whose chain a second start's chain follows prints before that start, and its label rides along", + 'process p { start S step A end Done(label: "Order filed") start T message("M") step B end Fin }', [ 'process p {', - ' var x: any', ' start S', ' step A', - ' if (x) {', - ' emit link L("x")', - ' }', - ' step C', ' end EndEvent_p(label: "Order filed")', - ' await link M("x")', + ' start T message("M")', ' step B', ' end Fin', '}', @@ -2407,7 +2428,26 @@ describe("irToDsl: a synthesized plain end that is not its block's tail", () => [['refusedStatement', 'EndEvent_p']], ], [ - 'of three chains, the synthesized end closing the first prints in place while the authored ends print as any end does', + 'a link catch whose chain never reaches the plain end prints behind its throw, so that end stays the tail', + 'process p { start S step A if (x) { emit link T("L") } step C end Done await link C2("L") step B end Fin }', + [ + 'process p {', + ' var x: any', + ' start S', + ' step A', + ' if (x) {', + ' emit link T("L")', + ' await link C2("L")', + ' step B', + ' end Fin', + ' }', + ' step C', + '}', + ], + [], + ], + [ + 'of three chains, the one whose own end is the synthesized tail prints last so that end stays implicit', // The second and third starts carry a trigger: the validator refuses a // second plain start, as `BpmnParse.selectInitial` does. minimalProcess( @@ -2433,18 +2473,17 @@ describe("irToDsl: a synthesized plain end that is not its block's tail", () => ), [ 'process p {', - ' start S1', - ' step A', - ' end EndEvent_p', ' start S2 message("M2")', ' step B', ' end E2', ' start S3 message("M3")', ' step C', ' end E3', + ' start S1', + ' step A', '}', ], - [['refusedStatement', 'EndEvent_p']], + [], ], ] as const)('%s', async (_title, fixture, source, reports) => { const ir = @@ -2632,8 +2671,8 @@ describe('irToDsl: routes leaving a loop beside the two it is built from', () => 'pre-test', PRE_TEST_IR, 'process p {\n' + - ' var more: any\n' + ' var escalate: any\n' + + ' var more: any\n' + ' start S\n' + ' while (more) {\n' + ' user Work\n' + @@ -2650,8 +2689,8 @@ describe('irToDsl: routes leaving a loop beside the two it is built from', () => 'post-test', POST_TEST_IR, 'process p {\n' + - ' var rework: any\n' + ' var escalate: any\n' + + ' var rework: any\n' + ' start S\n' + ' do {\n' + ' user Review\n' + @@ -2769,7 +2808,7 @@ describe('irToDsl: a split inside a loop body whose every route is conditioned', ['Escalate', '${escalated}'], ]), loopHead( - ' var approved: any\n var escalated: any\n var clarified: any\n', + ' var approved: any\n var clarified: any\n var escalated: any\n', ) + ' if (approved) {\n' + ' goto Pay\n' + @@ -2812,9 +2851,9 @@ describe('irToDsl: a split inside a loop body whose every route is conditioned', }); /** - * A step whose two routes end apart, so each keeps its edge as a jump, and the - * second lands on a one-way gateway `G -> R`: a gateway cannot be named, so - * the jump forwards through it to the real successor. + * A step whose two routes both reach `E`, so each keeps its edge as a jump, + * and the second lands on a one-way gateway `G -> R`: a gateway cannot be + * named, so the jump forwards through it to the real successor. */ const passThroughIr = ( kind: 'exclusiveGateway' | 'inclusiveGateway' | 'eventBasedGateway', @@ -2827,14 +2866,13 @@ const passThroughIr = ( { kind, id }, { kind: 'userTask', id: 'R' }, { kind: 'endEvent', id: 'E' }, - { kind: 'endEvent', id: 'E2' }, ], [ edge('S', 'A'), edge('A', 'E'), edge('A', id), edge(id, 'R'), - edge('R', 'E2'), + edge('R', 'E'), ], ); @@ -3370,7 +3408,7 @@ describe('irToDsl: engine attributes', () => { }); }); -/** A step whose two routes end apart, so the one landing on the end keeps its edge as a jump. */ +/** A step whose two routes both reach the end, so the one landing on it keeps its edge as a jump. */ const jumpToEndIr = ( end: Partial, 'kind'>>, ): BpmnProcess => { @@ -3381,13 +3419,12 @@ const jumpToEndIr = ( { kind: 'userTask', id: 'A' }, { kind: 'userTask', id: 'B' }, { kind: 'endEvent', id, ...attrs }, - { kind: 'endEvent', id: 'E2' }, ], [ { id: 'F1', sourceRef: 'S', targetRef: 'A' }, { id: 'F2', sourceRef: 'A', targetRef: id }, { id: 'F3', sourceRef: 'A', targetRef: 'B' }, - { id: 'F4', sourceRef: 'B', targetRef: 'E2' }, + { id: 'F4', sourceRef: 'B', targetRef: id }, ], ); }; @@ -3445,11 +3482,11 @@ describe('irToDsl: whether a synthesized terminal prints', () => { ], ['nothing on an end', 'end', {}, null, []], [ - 'a label alone on an end, a label not being printable content', + 'a label alone on an end, which nothing else can carry, forces the print', 'end', { name: 'Order Filed' }, - null, - [], + 'end EndEvent_p(label: "Order Filed")', + ['mintedId'], ], [ 'an engine attribute on a start', @@ -3492,6 +3529,31 @@ describe('irToDsl: whether a synthesized terminal prints', () => { } }, ); + + // At the body's tail no jump or second chain forces the print by + // position, so the name alone must. + it('keeps its label at the body tail, the same as one forced to print off the tail', () => { + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'userTask', id: 'A' }, + { kind: 'endEvent', id: 'EndEvent_p', name: 'Order filed' }, + ], + flowChain('S', 'A', 'EndEvent_p'), + ); + const { source, warnings } = printDsl(ir); + expect(source).toBe( + [ + 'process p {', + ' start S', + ' user A', + ' end EndEvent_p(label: "Order filed")', + '}', + '', + ].join('\n'), + ); + expectReports(warnings, ['refusedStatement', 'EndEvent_p']); + }); }); describe('irToDsl: input/output parameters', () => { @@ -4138,7 +4200,7 @@ describe('irToDsl: repeated activities', () => { // binding, an io parameter, an element variable or a repetition's engine // counters already type. describe('irToDsl: the header declares every variable the body reads bare', () => { - it('declares each bare read once, typed any, in the order it appears', async () => { + it('declares each bare read once, typed any, in name order', async () => { const ir = minimalProcess( [ { @@ -4204,7 +4266,7 @@ describe('irToDsl: the header declares every variable the body reads bare', () = const dsl = await printed(ir); expect(dsl.split('\n').filter((line) => line.startsWith(' var '))).toEqual( - [' var count: any', ' var source: any', ' var flag: any'], + [' var count: any', ' var flag: any', ' var source: any'], ); const { diagnostics } = await validate(dsl); expect(diagnostics.map((d) => d.message)).toEqual([]); @@ -4807,22 +4869,68 @@ describe('irToDsl: several starts entering one step', () => { }); }); +/** A parallel fork into `X` and `Y` with no merge, `rest` the flows after them. */ +const unjoinedForkIr = ( + elements: FlowElement[], + rest: SequenceFlow[], +): BpmnProcess => + minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'parallelGateway', id: 'Gateway_p_1_fork' }, + ...elements, + ], + [ + edge('S', 'Gateway_p_1_fork'), + edge('Gateway_p_1_fork', 'X'), + edge('Gateway_p_1_fork', 'Y'), + ...rest, + ], + ); + describe('warnings: a split the script has no form for', () => { - // A fork with nothing to rejoin at: every edge keeps a jump, so nothing is - // dropped and this report is the only trace that the split itself is gone. - it('reports the fork it wrote as jumps, and drops no edge doing it', async () => { - const ir = minimalProcess( + it('prints a fork whose routes each run to an end of their own as a parallel block, and reports nothing', async () => { + const ir = unjoinedForkIr( [ - { kind: 'startEvent', id: 'S' }, - { kind: 'parallelGateway', id: 'Gateway_p_1_fork' }, { kind: 'endEvent', id: 'X' }, - { kind: 'endEvent', id: 'Y' }, + { kind: 'userTask', id: 'Y' }, + { kind: 'endEvent', id: 'Z' }, ], + [edge('Y', 'Z')], + ); + + expect(await expectIdempotent(ir)).toBe( [ - edge('S', 'Gateway_p_1_fork'), - edge('Gateway_p_1_fork', 'X'), - edge('Gateway_p_1_fork', 'Y'), + 'process p {', + ' start S', + ' parallel {', + ' {', + ' end X', + ' }', + ' {', + ' user Y', + ' end Z', + ' }', + ' }', + '}', + '', + ].join('\n'), + ); + expect(printDsl(ir).warnings).toEqual([]); + }); + + // Both routes run into `C` with no merge to rejoin at: every edge keeps a + // jump, so nothing is dropped and this report is the only trace that the + // split itself is gone. + it('reports the fork it wrote as jumps, and drops no edge doing it', async () => { + const ir = unjoinedForkIr( + [ + { kind: 'userTask', id: 'X' }, + { kind: 'userTask', id: 'Y' }, + { kind: 'userTask', id: 'C' }, + { kind: 'endEvent', id: 'E' }, ], + [edge('X', 'C'), edge('Y', 'C'), edge('C', 'E')], ); const { warnings } = printDsl(ir); // Each jump in a branch of its own, so the source still compiles: a second @@ -4838,6 +4946,81 @@ describe('warnings: a split the script has no form for', () => { expect(source).not.toContain('dropped edge'); expectReports(warnings, ['degradedSplit', 'Gateway_p_1_fork']); }); + + // A block branch is entered only through its head, so a message start + // running into `Cont` would jump into a branch from outside it. + it('reports a step split as jumps when another entry runs into one of its routes', async () => { + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + { + kind: 'startEvent', + id: 'S2', + eventDefinition: { kind: 'message', messageName: 'm' }, + }, + { kind: 'userTask', id: 'T1' }, + { kind: 'userTask', id: 'Cont' }, + { kind: 'endEvent', id: 'E' }, + { kind: 'endEvent', id: 'E2' }, + ], + [ + edge('S', 'T1'), + edge('T1', 'Cont'), + edge('Cont', 'E'), + edge('T1', 'E2'), + edge('S2', 'Cont'), + ], + ); + const source = await printed(ir); + + expect(source).not.toContain('parallel {'); + expect(realReachability(await reDesugar(source))).toEqual( + realReachability(ir), + ); + expectReports(printDsl(ir).warnings, ['degradedSplit', 'T1']); + }); + + it('prints a step split whose route loops straight back to the step as a parallel block with a goto', async () => { + const ir = minimalProcess( + [ + { kind: 'startEvent', id: 'S' }, + { kind: 'userTask', id: 'T1' }, + { kind: 'userTask', id: 'Cont' }, + { kind: 'endEvent', id: 'E' }, + ], + [ + edge('S', 'T1'), + edge('T1', 'T1'), + edge('T1', 'Cont'), + edge('Cont', 'E'), + ], + ); + + const { source, warnings } = printDsl(ir); + const again = await reDesugar(await printed(ir)); + + expect(source).toBe( + [ + 'process p {', + ' start S', + ' user T1', + ' parallel {', + ' {', + ' goto T1', + ' }', + ' {', + ' user Cont', + ' end E', + ' }', + ' }', + '}', + '', + ].join('\n'), + ); + expect(warnings).toEqual([]); + expect(realReachability(again)).toEqual(realReachability(ir)); + expect(irToDsl(again)).toBe(source); + }); }); describe('irToDsl: a step whose own routes split', () => { @@ -5823,12 +6006,22 @@ describe('warnings: a condition the script has nowhere to write', () => { }, ); - // A split that names a fallback is never left without a route, so the run - // carries on by another one instead of failing; the loop spends the fallback - // as its closing brace, leaving the weighed route to print as plain flow. - it.each(['exclusiveGateway', 'inclusiveGateway'] as const)( - 'reports a weighed route out of a %s that names a fallback as a run that goes on elsewhere', - async (kind) => { + // A split that names a fallback is never left without a route, and the loop + // spends that fallback as its closing brace. An exclusive split keeps its + // weighed route as an `if` that falls through to the brace; an inclusive + // one runs its weighed route as plain flow instead (emitFork, not the `if` + // form), so the run carrying on by the fallback is reported separately. + it.each([ + ['an exclusive split keeps it as an if', 'exclusiveGateway', true, []], + [ + 'an inclusive split reports a run that goes on elsewhere', + 'inclusiveGateway', + false, + [['divertedRun', 'Split']], + ], + ] as const)( + 'a weighed route beside a fallback that closes a loop: %s', + async (_title, kind, keepsIf, reports) => { const ir = loopIntoSplitIr( { kind, id: 'Split', defaultFlowId: 'Flow_again' }, { id: 'Flow_again' }, @@ -5837,8 +6030,8 @@ describe('warnings: a condition the script has nowhere to write', () => { const { source, warnings } = printDsl(ir); expect(source).toContain('while (again) {'); - expect(bodyOf(source)).not.toContain('settled'); - expectReports(warnings, ['divertedRun', 'Split']); + expect(bodyOf(source).includes('settled')).toBe(keepsIf); + expectReports(warnings, ...reports); await printed(ir); }, ); @@ -7076,8 +7269,8 @@ describe('irToDsl: gateway settings', () => { }), [ 'process p {', - ' var c: any', ' var a: any', + ' var c: any', ' start S', ' while (c) {', ' if (a) (joinAsyncBefore: true) {', diff --git a/packages/transform/test/xml-to-ir.test.ts b/packages/transform/test/xml-to-ir.test.ts index edaaa2d..8e2ea5a 100644 --- a/packages/transform/test/xml-to-ir.test.ts +++ b/packages/transform/test/xml-to-ir.test.ts @@ -109,7 +109,8 @@ const HANDWRITTEN_XML = readFileSync( ); // Only the tests that print DSL (the compensation-rewrite preview, the -// none-throw rewrite) re-parse it; every other test asserts against the IR alone. +// none-throw rewrite, the boundary-escape label) re-parse it; every other +// test asserts against the IR alone. let parse: ReturnType>; beforeAll(() => { @@ -6714,8 +6715,9 @@ describe('xmlToIr: a label on an event the surface gives no label to', () => { }, ); - it('an end named for a boundary escape reports its label even when the boundary is written after it', async () => { - const { warnings } = await xmlToIr(bpmnDoc` + it('an end named for a boundary escape keeps its label in the print, even when the boundary is written after it', async () => { + const { ir, warnings } = + await xmlToIr(bpmnDoc` @@ -6725,15 +6727,19 @@ describe('xmlToIr: a label on an event the surface gives no label to', () => { `); - expect(warnings).toEqual([ - { - elementId: 'EndEvent_Boundary', - category: 'label', - message: expect.stringContaining( - "The label 'Escaped' on 'EndEvent_Boundary' cannot be kept as written", - ), - }, - ]); + expect(warnings).toEqual([]); + const dsl = irToDsl(ir).source; + expect(dsl).toBe( + 'process p {\n' + + ' start S\n' + + ' user Review\n' + + ' end E\n' + + ' on Review: timer("PT1H") {\n' + + ' end EndEvent_Boundary(label: "Escaped")\n' + + ' }\n' + + '}\n', + ); + await expectParses(dsl); }); }); diff --git a/tests/cyclic-loop-performance.test.ts b/tests/cyclic-loop-performance.test.ts new file mode 100644 index 0000000..d1a4849 --- /dev/null +++ b/tests/cyclic-loop-performance.test.ts @@ -0,0 +1,46 @@ +// One dominator tree per test keeps `loopTestRank` near O(T*N) per pass, +// but `loopEntries` still re-runs it once per nested SCC, so a model whose +// gotos tie every loop into one cycle prints in roughly cubic time in the +// flow node count. The budget below guards that known limit from growing. +import { describe, expect, it } from 'vitest'; +import { astToIr, irToDsl } from '@bpmn-script/transform'; +import { parseToAst } from './helpers/pipeline.js'; + +const WHILE_COUNT = 150; +const BUDGET_MS = 60_000; + +// Each `while` closes its own cycle; each `goto` after one ties it into the +// previous while's body, so gotos chain every loop into a single SCC. +function cyclicWhiles(n: number): string { + const lines = ['process p {', ' var x: any', ' var c: any']; + for (let i = 0; i < n; i++) { + lines.push(` while (x) {\n user W${i}\n user V${i}\n }`); + if (i > 0) lines.push(` if (c) {\n goto V${i - 1}\n }`); + } + lines.push('}'); + return lines.join('\n') + '\n'; +} + +describe('CFG analysis performance on tied loops', () => { + it( + 'prints 150 goto-tied whiles in under 60 s', + async () => { + const source = cyclicWhiles(WHILE_COUNT); + const ir = astToIr(await parseToAst(source)); + + const started = performance.now(); + const { source: printed, warnings } = irToDsl(ir); + const elapsed = performance.now() - started; + + expect(elapsed).toBeLessThan(BUDGET_MS); + expect(warnings).toEqual([]); + const whileLines = printed + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('while (')); + expect(whileLines).toEqual(Array(WHILE_COUNT).fill('while (x) {')); + await parseToAst(printed); + }, + BUDGET_MS + 15_000, + ); +}); diff --git a/tests/fuzz-regressions.test.ts b/tests/fuzz-regressions.test.ts new file mode 100644 index 0000000..296f3cd --- /dev/null +++ b/tests/fuzz-regressions.test.ts @@ -0,0 +1,3071 @@ +// Minimized shapes from fuzz finds, one row each: a fuzz run reaches a shape +// only while the generator still draws it, so each is pinned here. + +import { describe, it, expect } from 'vitest'; + +import { xmlToIr, irToDsl } from '@bpmn-script/transform'; +import type { BpmnProcess, FlowContainer } from '@bpmn-script/transform'; + +import { bpmnDoc } from './helpers/bpmn-doc.js'; +import { printDsl, roundTrip, validate } from './helpers/pipeline.js'; +import { compareModels, modelSignature } from './helpers/model-equivalence.js'; +import type { ModelComparison } from './helpers/model-equivalence.js'; + +async function errorMessages(source: string) { + const { diagnostics } = await validate(source); + return diagnostics.filter((d) => d.severity === 1).map((d) => d.message); +} + +// Shapes whose print depends on the order the model lists its elements and +// flows in: a goto into a loop body enters the cycle a second time, past its +// head, and a chain or a start walked first claims what follows it. The third +// element pins the exact class the reversed-order reread compares back to +// (see `compareModels`); a row with none keeps the model identical. +const ORDER_ROWS: [ + title: string, + source: string, + comparison?: ModelComparison, +][] = [ + [ + 'a while inside an if branch whose body a goto after the if also enters prints as a while', + `process order-fulfilment { + if (true) { + while (true) { + emit message Tell4("Quote Received") + } + } + goto Tell4 +}`, + ], + [ + 'a do-while nested in a while whose body a goto after the while also enters keeps its condition', + `process claim-review { + while (true) { + do { + await condition Wait("x\\"y" != "eu") + receive Hold6 + } while (true) + } + goto Hold6 +}`, + ], + [ + 'a while in an else branch whose body a goto after the if also enters prints as a while', + `process p { + var c: any + var x: any + if (c) { + user Z + } else { + while (x) { + user A + user B + } + } + goto B +}`, + ], + [ + 'a while in an else branch beside an empty then branch, entered again by a goto to its first task, prints as a while', + `process p { + var c: any + var x: any + if (c) { + } else { + while (x) { + user A + user B + } + } + goto A +}`, + ], + [ + 'a goto in a then branch into the body of a while in the else branch prints as a goto beside the while', + `process p { + var c: any + var x: any + if (c) { + goto B + } else { + while (x) { + user A + user B + } + } +}`, + ], + [ + 'an else-if branch entering a cycle past a step the then branch already printed keeps its chain, outside the await branch that jumps into it', + `process onboarding { + if (true) { + step Archive + } else if (true) { + step Pack4 + goto Archive + } + await { + timer(every: "R/PT1H") { + } + timer(at: "2027-01-01T00:00:00") { + goto Pack4 + } + } +}`, + 'canonical', + ], + [ + 'a goto after an if back into a step in its nested else-if keeps the step in that branch', + `process invoice_batch { + if (true) { + if (true) { + } else if (true) { + emit message Tell6("Cancelled") + } + } + goto Tell6 +}`, + ], + [ + 'an else-if jumping back into a step in an earlier nested else-if leaves both ifs closing at their own joins', + `process invoice_batch { + if (true) { + if (true) { + } else if (true) { + emit message Tell6("Cancelled") + } + } + if (true) { + } else if (true) { + goto Tell6 + } +}`, + ], + [ + 'a goto listed before a do-while into its body prints valid and stable', + `process p { + var c: any + var x: any + if (c) { + goto B + } + do { + user A + user B + } while (x) +}`, + ], + [ + 'a goto listed before nested whiles into the inner body keeps the outer while as the loop', + `process p { + var x: any + var y: any + var c: any + if (c) { + goto B + } + while (x) { + user A + while (y) { + user B + user C + } + } +}`, + ], + [ + 'a goto after nested whiles into the inner body keeps the inner loop a while', + `process p { + var x: any + var y: any + while (x) { + user A + while (y) { + user B + user C + } + } + goto B +}`, + ], + [ + 'a goto listed before nested do-whiles onto the inner first step keeps the outer do-while as the loop', + `process p { + var x: any + var y: any + var c: any + if (c) { + goto B + } + do { + user A + do { + user B + user C + } while (y) + } while (x) +}`, + ], + [ + 'a goto listed before a do-while into the body of a while nested in it keeps the do-while as the loop', + `process p { + var x: any + var y: any + var c: any + if (c) { + goto B + } + do { + user A + while (y) { + user B + user C + } + } while (x) +}`, + ], + [ + 'a goto listed before a while onto the first step of a do-while nested in it keeps the while as the loop', + `process p { + var x: any + var y: any + var c: any + if (c) { + goto B + } + while (x) { + user A + do { + user B + user C + } while (y) + } +}`, + ], + [ + 'a goto listed before a do-while into an if branch in its body keeps the do-while as the loop', + `process p { + var x: any + var c: any + if (c) { + goto B + } + do { + user A + if (c) { + user B + } + } while (x) +}`, + ], + [ + 'nested do-whiles whose bodies gotos enter from before and after both loops keep the outer do-while', + `process p { + var c: any + var d: any + var e: any + var x: any + var y: any + var z: any + if (c) { + goto B + } + do { + user A + do { + user B + user C + } while (y) + } while (x) + goto C +}`, + ], + [ + 'an if whose goto enters a do-while body past its continue does not continue at the step inside the body', + `process p { + var c: any + var d: any + var x: any + if (d) { + goto B + } + do { + user A + if (c) { + goto A + } + user B + } while (x) + user C +}`, + ], + [ + 'a second start whose chain reaches the implicit end prints after the start ending on its own end', + `process onboarding { + start Start1 + end Done + start Start10 signal("StockLow") +}`, + ], + [ + 'a link throw and its catch opening the body keep the implicit end unwritten', + `process invoice_batch { + emit link("Retry") + await link("Retry") +}`, + ], + [ + 'a link throw and its catch opening a sub-process body keep its implicit end unwritten', + `process invoice_batch { + subprocess Sub { + emit link("Rework") + await link("Rework") + } +}`, + ], + [ + 'a branch chain that jumps back to its first step prints from that step', + `process claim-review { + if (true) { + } else if (true) { + service Release10(expression: order) + await message("Quote Received") + goto Release10 + } + end Done11 +}`, + 'canonical', + ], + [ + 'a branch chain a parallel branch jumps into prints from its first step', + `process onboarding { + if (true) { + } else if (true) { + step Notify10 + throw message("Cancelled") + } else { + parallel { + { + goto Notify10 + } + { + } + } + } + end Done22 +}`, + 'canonical', + ], + [ + 'a branch chain a later branch jumps into prints from its first step', + `process onboarding { + var c: any + if (true) { + } else if (true) { + step Notify10 + throw message("Cancelled") + } else { + user Q + if (c) { + goto Notify10 + } + } + end Done22 +}`, + 'canonical', + ], + [ + 'a link catch two throws reach prints its chain from the catch', + `process p { + var c: any + var x: any + if (c) { + emit link("L") + } else if (x) { + emit link("L") + } else { + end Done + } + end Fin + await link("L") + await { + message("M") { + } + signal("S") { + } + } + goto Fin +}`, + 'restructured', + ], + [ + 'a triggered start whose chain ends in its own authored end prints ahead of a plain start whose chain runs on to the implicit one', + `process p { + start T message("M") + step B + end E2 + start S1 + user A +}`, + ], + [ + 'two empty boundary handlers on the same host and trigger keep the base id on the first one printed', + `process p { + user A + on A: signal("Ready") { + } + on A: signal("Shutdown") { + } +} +`, + ], + [ + 'two boundary handlers with bodies on the same host and trigger keep the base id on the first one printed', + `process p { + user A + on A: signal("Ready") { + user X + } + on A: signal("Shutdown") { + user Y + } +} +`, + ], + [ + 'a while whose own exit jumps unconditioned into a nested while, with nothing after that ever reaches an end, prints the nested while inline', + `process p { + var c: any + var x: any + var y: any + while (x) { + user A + if (c) { + while (y) { + user B + user C + } + } + user D + } + goto B +}`, + ], + [ + 'the same unconditioned jump into a nested while, landing past its first step instead of at it, prints the nested while inline all the same', + `process p { + var c: any + var x: any + var y: any + while (x) { + user A + if (c) { + while (y) { + user B + user C + } + } + user D + } + goto C +}`, + ], + [ + 'a while whose own exit falls through two further tests before jumping back into its own body, with nothing after that ever reaches an end, prints both tests', + `process p { + var x: any + var c: any + var d: any + while (x) { + if (c) { + user B + } + if (d) { + user D + } + user E + } + if (c) { + goto B + } + goto D +}`, + ], + [ + "a goto behind a guard after a do-while into the step its own nested if already reaches closes only the loop's exit route, not a nested do-while on the guard", + `process p { + var c: any + var d: any + var e: any + var x: any + do { + user A + if (c) { + user B + if (d) { + user C + } + } + } while (x) + if (e) { + goto C + } +}`, + ], + [ + "a chain of gotos after three nested do-whiles into each loop's own last step prints as gotos, not further do-whiles nested on their merges", + `process p { + var c: any + var d: any + var x: any + var y: any + var z: any + do { + user A + do { + user B + do { + user C + user D + } while (z) + } while (y) + } while (x) + if (c) { + goto B + } else if (d) { + goto C + } + goto D +}`, + ], + [ + "the same chain with the innermost goto behind a guard still closes only the outer do-while's exit route, not a nested do-while on the guard", + `process p { + var c: any + var d: any + var e: any + var x: any + var y: any + var z: any + do { + user A + do { + user B + do { + user C + user D + } while (z) + } while (y) + } while (x) + if (c) { + goto B + } else if (d) { + goto C + } + if (e) { + goto D + } +}`, + ], + [ + "a goto after a do-while into its own last step, beside the loop's continue back edge, prints as a goto and keeps the do-while's own test", + `process p { + var c: any + var d: any + var x: any + do { + user A + if (c) { + goto A + } + user B + } while (x) + if (d) { + goto B + } +}`, + ], + [ + "the same goto after a do-while, with a further step following it, still prints as a goto and keeps the do-while's own test", + `process p { + var c: any + var d: any + var x: any + do { + user A + if (c) { + goto A + } + user B + } while (x) + if (d) { + goto B + } + user C +}`, + ], + [ + "a goto before a do-while into its continue's target keeps the do-while's own test and the elided end implicit", + `process p { + var c: any + var d: any + var x: any + if (d) { + goto B + } + do { + user A + if (c) { + goto A + } + user B + } while (x) +}`, + ], + [ + "the same goto before a do-while, with a further step inside the body after the second entry, still keeps the do-while's own test and the elided end implicit", + `process p { + var c: any + var d: any + var x: any + if (d) { + goto B + } + do { + user A + if (c) { + goto A + } + user B + user Q + } while (x) +}`, + ], + [ + "a do-while opening its body on a sibling while, with a goto after it into the while's body, keeps its own test", + `process p { + var x: any + var y: any + do { + while (y) { + user B + } + user C + } while (x) + goto B +}`, + ], + [ + "a nested if's true route that skips its own merge and jumps into the outer if's first branch keeps that branch's edge into the outer join", + `process order-fulfilment { + if (true) { + decide Approve(decision: "approve-claim") + } else if (true) { + if (true) { + emit signal("Shutdown") + goto Approve + } + } + await { + signal("Ready") { + } + condition(true) { + } + } + end Done22 +}`, + 'canonical', + ], + [ + 'a do-while opening an if branch whose body a later goto enters mid-way stays valid and stable when the model is reversed', + `process p { + var c: any + var x: any + if (c) { + do { + user A + user B + } while (x) + throw signal("S") + } + if (x) { + goto B + } + end Done +}`, + ], + [ + 'a do-while opening an if branch whose body an earlier goto enters mid-way stays valid and stable when the model is reversed', + `process p { + var c: any + var d: any + var x: any + if (d) { + goto B + } + if (c) { + do { + user A + user B + } while (x) + throw signal("S") + } + end Done +}`, + ], + [ + 'an if branch that only a later goto jumps back to prints inside the if, not after the implicit end', + `process p { + var c: any + if (c) { + user A + goto Z + } else if (c) { + user B + goto Z + } + user K + if (c) { + goto A + } + user Z +}`, + ], + [ + 'a do-while opening an if branch that a later goto jumps back into keeps the step after it a goto', + `process p { + var c: any + var x: any + if (c) { + do { + user A + } while (x) + goto Z + } else if (c) { + user B + goto Z + } + user K + if (c) { + goto A + } + user Z +}`, + ], + [ + 'a do-while around a nested do-while that gotos enter from before and after keeps the outer loop', + `process p { + var d: any + var e: any + var x: any + var y: any + if (d) { + goto C + } + do { + user A + do { + user B + user C + } while (y) + } while (x) + if (e) { + goto B + } +}`, + 'restructured', + ], + [ + 'a do-while around a while that gotos enter from before and after keeps both loops', + `process p { + var d: any + var e: any + var x: any + var y: any + if (d) { + goto C + } + do { + user A + while (y) { + user B + user C + } + } while (x) + if (e) { + goto B + } +}`, + ], + [ + 'a do-while around a while that gotos enter at its body from before and after keeps both loops', + `process p { + var d: any + var e: any + var x: any + var y: any + if (d) { + goto B + } + do { + user A + while (y) { + user B + user C + } + } while (x) + if (e) { + goto B + } +}`, + ], + [ + 'a do-while around a while opening an if branch keeps the outer loop when the else branch jumps into the inner body', + `process p { + var d: any + var x: any + var y: any + if (d) { + do { + user A + while (y) { + user B + user C + } + } while (x) + } else { + user Z + goto B + } +}`, + ], + [ + 'a goto from a do-while back to the first step of the enclosing while body keeps the inner loop test as a condition', + `process p { + var c: any + var x: any + var y: any + while (x) { + user A + do { + user B + if (c) { + goto A + } + user C + } while (y) + } +}`, + 'restructured', + ], + [ + 'a nested if branch that only a later goto jumps back to prints inside its if, not after the implicit end', + `process p { + var c: any + var d: any + if (c) { + user A0 + if (d) { + user A + goto Z + } else { + user B + } + user M + } else { + user C + } + user K + if (c) { + goto A + } + user Z +}`, + ], + [ + 'an if branch jumping to the first step of a while after it leaves that step in the loop', + `process p { + var d: any + var e: any + var x: any + var y: any + if (d) { + goto A + } + while (x) { + user A + while (y) { + user B + user C + } + } + if (e) { + goto B + } +}`, + ], + [ + 'an if whose routes both reach a do-while head keeps its own merge when a goto after the loop has one too', + `process p { + var d: any + var e: any + var x: any + var y: any + if (d) { + goto A + } + do { + user A + do { + user B + user C + } while (y) + } while (x) + if (e) { + goto B + } +}`, + 'restructured', + ], + [ + 'an if around a do-while keeps its own merge when a goto after the loop inside it has one too', + `process p { + var d: any + var e: any + var x: any + var y: any + if (d) { + do { + user A + do { + user B + user C + } while (y) + } while (x) + if (e) { + goto B + } + } +}`, + 'restructured', + ], + [ + 'a goto from a nested if into a while head after the if keeps the loop when a later goto enters the inner while', + `process p { + var c: any + var d: any + var e: any + var x: any + var y: any + if (c) { + user Q + if (d) { + goto A + } + user R + } else { + user S + } + while (x) { + user A + while (y) { + user B + user C + } + } + if (e) { + goto B + } +}`, + ], + [ + 'a goto from a nested if into a while head after the if keeps the loop when a later goto enters the inner while past its first step', + `process p { + var c: any + var d: any + var e: any + var x: any + var y: any + if (c) { + user Q + if (d) { + goto A + } + user R + } else { + user S + } + while (x) { + user A + while (y) { + user B + user C + } + } + if (e) { + goto C + } +}`, + ], + [ + 'a goto from a nested if into a while head after the if keeps the loop when a later goto enters the inner do-while', + `process p { + var c: any + var d: any + var e: any + var x: any + var y: any + if (c) { + user Q + if (d) { + goto A + } + user R + } else { + user S + } + while (x) { + user A + do { + user B + user C + } while (y) + } + if (e) { + goto B + } +}`, + ], + [ + 'a goto from a nested if into a while head after the if keeps the loop when a later goto enters the inner do-while past its first step', + `process p { + var c: any + var d: any + var e: any + var x: any + var y: any + if (c) { + user Q + if (d) { + goto A + } + user R + } else { + user S + } + while (x) { + user A + do { + user B + user C + } while (y) + } + if (e) { + goto C + } +}`, + ], + [ + 'a nested branch chain that every route out of the enclosing if runs into prints after that if', + `process p { + var c: any + var d: any + var e: any + var x: any + if (c) { + user A0 + if (d) { + user A + user N + goto Z + } else { + user B + } + user M + } else { + user C + } + user K + goto A + user Z +}`, + ], +]; + +const ROWS: [title: string, source: string, comparison?: ModelComparison][] = [ + [ + 'an else branch a boundary handler also jumps into stays owned by the branch, not hoisted behind the implicit end', + `process claim-review { + if (true) { + call Call5(process: "invoice-approval") + } else { + service Release10(topic: "shipping") + end Done + } + on Call5: escalation { + goto Release10 + } +}`, + ], + [ + 'a link pair inside an if branch prints inside the branch, and the implicit end stays unwritten', + `process claim-review { + if (true) { + emit link("Skip") + await link("Skip") + } +}`, + ], + [ + "a link pair inside a subprocess's else branch prints inside it, and the subprocess end stays unwritten", + `process order-fulfilment { + subprocess Sub { + if (true) { + } else { + emit link("Skip") + await link("Skip") + } + } +}`, + ], + [ + "a link pair inside a transaction's branch prints inside it, beside a second link pair after the transaction", + `process onboarding { + attempt Try4 { + if (true) { + emit link("Rework") + await link("Rework") + } + } + emit link("Retry") + await link("Retry") +}`, + ], + [ + "a link pair inside an event handler's branch prints inside the handler", + `process claim-review { + start Start + on signal("Ready") { + if (true) { + emit link("Rework") + await link("Rework") + } + } +}`, + ], + [ + 'a link pair inside a boundary handler body prints inside the handler, not after the process end', + `process p { + var order: any + step Host + on Host: condition(order.paid) { + emit link("L") + await link("L") + step A + } +}`, + ], + [ + 'a link pair inside a boundary handler body prints inside the handler when the process ends on an authored end', + `process p { + var order: any + step Host + end Fin + on Host: condition(order.paid) { + emit link("L") + await link("L") + step A + } +}`, + ], + [ + 'a link pair in the only branch that reaches the join prints inside that branch', + `process p { + var c: any + if (c) { + emit link("L") + await link("L") + step B + } else { + end Stop + } +}`, + ], + [ + 'a link pair whose catch runs through an await before the join of a branch beside an ending one prints inside that branch', + `process order-fulfilment { + if (true) { + emit link("Retry") + await link("Retry") + await { + message("OrderReceived") { + } + condition(urgent && order == null) { + } + } + } else { + end Done + } +}`, + ], + [ + 'a link pair whose catch runs through a while before the join of a branch beside an ending one prints inside that branch', + `process p { + var c: any + var x: any + if (c) { + emit link("L") + await link("L") + while (x) { + user A + } + } else { + end Done + } +}`, + ], + [ + 'a link pair whose catch runs through a do-while before the join of a branch beside an ending one prints inside that branch', + `process p { + var c: any + var x: any + if (c) { + emit link("L") + await link("L") + do { + user A + } while (x) + } else { + end Done + } +}`, + ], + [ + 'a link pair whose catch opens an if of its own prints inside the enclosing branch, whose join no goto can name', + `process p { + var c: any + var x: any + var y: any + if (c) { + emit link("L") + await link("L") + if (x) { + step A + } else { + step B + } + } + if (y) { + step Z + } + end Done +}`, + ], + [ + 'a goto back into a step whose only route to its test crosses a link pair prints in its goto form, not as a do-while', + `process p { + var c: any + var x: any + user Q + if (c) { + emit link("L") + await link("L") + if (x) { + goto Q + } + user A + } else { + end Done + } +}`, + ], + [ + 'a parallel whose every branch ends prints as a parallel, not as if (true) jumps', + `process invoice_batch { + parallel { + { + end Done6 + } + { + await signal("Ready") + end Done7 + } + } +}`, + ], + [ + 'a parallel whose ending branch holds a parallel of its own keeps the join the other branch runs into', + `process claim-review { + parallel { + if (true) { + step Release + } + else { + parallel { + if (true) { + } + { + } + } + end Declined + } + } + call Call(process: "payment-run") + end Done +}`, + ], + [ + "a do-while nested in a do-while keeps the inner loop's back edge", + `process invoice_batch { + do { + do { + await message("OrderReceived") + } while (true) + } while (true) + throw signal("StockLow") +}`, + ], + [ + 'a parallel branch jumping to a step an earlier if branch owns prints the goto without entering that branch from outside', + `process invoice_batch { + if (true) { + call Call(process: "payment-run") + emit link("Rework") + await link("Rework") + } + parallel { + if (true) { + call Call11(process: "invoice-approval") + } + { + goto Call + } + } + end Done16 +}`, + ], + [ + 'a conditional start after a parallel whose branches jump back or throw prints with no flow into the start', + `process invoice_batch { + error OUT_OF_STOCK(message: "Out of stock") + + call Call4(process: "payment-run") + parallel { + if (true) { + goto Call4 + } + else { + throw error(OUT_OF_STOCK) + } + } + start Start16 condition("\${retries > 4}") +}`, + ], + [ + 'the var block keeps its order when a branch that reads a variable is printed elsewhere on the second pass', + `process claim-review { + if (true) { + step Charge5 for each line in items + end Done6 + } else if (true) { + send Pack for each item in lines sequentially(type: "mail") { + field to = "ops@example.com" + field text = "Disk usage high" + } + } + end Done16 +}`, + ], + [ + "an empty if under a race's condition branch stays in that branch on the second pass", + `process invoice_batch { + emit link("Rework") + await link("Rework") + await { + condition(order.total < -retries) { + if (true) { + } + end Done11 + } + signal("StockLow") { + } + } + end Done13 +}`, + ], + [ + 'a loop inside a race branch that ends prints without a hand-repair comment on the second pass', + `process onboarding { + await { + signal("Ready") { + } + condition(amount / 1.41 == (score)) { + do { + emit message("PaymentDone") + } while (true) + end Done3 + } + } + end Done +}`, + ], + [ + 'an if whose branch step is jumped to after the block prints the same on the second pass (no else turning into a goto)', + `process onboarding { + if (true) { + receive check-stock9 + } + goto check-stock9 +}`, + ], + [ + 'two gotos into the true convergence print no empty else blocks, and the second pass matches the first', + `process p { + var x: any + var y: any + if (x) { + goto B + } + step C + if (y) { + goto B + } + step B + end Fin +}`, + ], + [ + 'a join setting on an if with an empty branch applies on every route and survives the second print', + `process onboarding { + if (true) (joinAsyncAfter: true) { + } else { + await timer Wait6(at: "2027-01-01T00:00:00") + } + receive pay-out8 + goto pay-out8 +}`, + ], + [ + 'a race branch whose else-if jumps back before the race prints the same on the second pass', + `process order-fulfilment { + user Archive1 + await { + timer("PT2H30M") { + } + signal("Shutdown") { + if (true) { + } else if (true) { + send pay-out(type: "mail") { + field to = "ops@example.com" + field text = "Disk usage high" + } + goto Archive1 + } + } + } + end Done18 +}`, + ], + [ + 'a nested if chain jumping to its own first branch prints no empty else on the second pass', + `process onboarding { + if (true) { + } else if (true) { + if (true) { + receive Review13 + } else if (true) { + service Check(class: "org.acme.Audit") + } + goto Review13 + } + emit link("Retry") + await link("Retry") + end Done19 +}`, + ], + [ + 'a goto past a parallel join keeps its goto, so the empty branch does not enter the join', + `process p { + var c: any + parallel { + { + if (c) { + step A + } else { + goto X + } + } + { + step B + } + } + step X + end E +}`, + ], + [ + 'a goto past an inclusive join keeps its goto, so the empty branch does not enter the join', + `process p { + var c: any + var d: any + parallel { + if (d) { + if (c) { + step A + } else { + goto X + } + } + { + step B + } + } + step X + end E +}`, + ], + [ + 'an ending race branch holding a nested race keeps its end on the second pass, the nested join is no continuation', + `process claim-review { + await { + condition(6.69 - 4.26 <= amount) { + await { + condition(950 >= 9.22 ? approved : (order.paid)) { + } + condition(order == null) { + } + } + end Done16 + } + signal("Ready") { + } + } + emit link("Retry") + await link("Retry") +}`, + ], + [ + 'an else branch reaching the step after the if through a merge another branch printed falls through with no goto', + `process onboarding { + if (true) { + receive check-stock9 + end Done + } else if (true) { + } else { + service Rate12(class: "org.acme.Audit") + } + goto check-stock9 +}`, + ], + [ + 'an if whose branch holds a nested if keeps its join settings and its branch step inside when the tail loops on itself', + `process p { + var c: any + var d: any + if (c) (joinAsyncBefore: true) { + step A + if (d) { + end X1 + } + } + receive B + goto B +}`, + ], + [ + 'two sibling branches each jumping back to the same step print as gotos, not as nested loops', + `process p { + var x: any + var y: any + var z: any + step A + if (x) { + step B + if (y) { + goto A + } + } else { + step C + if (z) { + goto A + } + } + step After +}`, + ], + [ + 'a jump back on a route the split tests second keeps its place in the if chain instead of becoming a loop test', + `process p { + var a: any + var b: any + user A + if (a) { + } else if (b) { + goto A + } +}`, + ], + [ + 'a boundary handler jumping into a while body leaves the loop printed as a while', + `process p { + var u: boolean + while (u) { + user A + user B + } + user C + on A: signal("R") { + goto B + } +}`, + ], + [ + 'a do-while branch passing through two link pairs keeps the loop and its back edge', + `process p { + var a: boolean + var c: boolean + do { + if (a) { + emit link("R1") + await link("R1") + step X + emit link("R2") + await link("R2") + step Y + } else { + step W + } + } while (c) + end Done +}`, + ], + [ + 'a link emitted inside a nested do-while inner body keeps both loops and leaves the outer link catch reachable', + `process p { + var u: any + var v: any + var a: boolean + do { + do { + user A + if (a) { + emit link("R") + } + user B + } while (v) + user C + } while (u) + end Done + await link("R") + goto C +}`, + ], + [ + 'an if whose else branch loops back keeps its join setting when its then branch is a nested if', + `process p { + var a: boolean + var b: boolean + user S + if (a) (joinAsyncBefore: true) { + if (b) { + user B + } + } else { + user W + goto S + } +}`, + ], + [ + 'a nested if whose then branch ends keeps its join setting when the outer else jumps into that branch', + `process p { + var a: boolean + var b: boolean + user S + if (a) { + if (b) (joinAsyncAfter: true) { + user R + end Stop + } else { + user T + } + user U + } else { + goto R + } +}`, + ], + [ + 'an if at the tail of a while body keeps its join setting when its else jumps back to the body start', + `process p { + var a: boolean + var b: boolean + var c: boolean + while (c) { + user S + if (a) (joinAsyncBefore: true) { + if (b) { + user B + } + user Q + } else { + user W + goto S + } + } +}`, + ], + [ + 'an if/else inside an await branch keeps its join setting on its own join rather than the await join', + `process p { + var a: boolean + await { + condition(a) { + if (a) (joinAsyncBefore: true) { + } else { + emit message("OrderReceived") + } + } + signal("Shutdown") { + end Done + } + } +}`, + ], + [ + 'an if with an empty branch and no else inside an await branch keeps its join setting on its own join rather than the await join', + `process p { + var a: boolean + await { + condition(a) { + if (a) (joinAsyncBefore: true) { + } + } + signal("Shutdown") { + end Done + } + } +}`, + ], + [ + 'an if whose other branch throws continues at its one-in join rather than inside the live branch', + `process claim-review { + escalation NEEDS_REVIEW + + if (true) { + receive Rate + } else { + decide Hold6(expression: "#{order.go()}") + throw escalation(NEEDS_REVIEW) + } + throw message("Quote Received") +}`, + ], + [ + 'an else-if jumping back above the enclosing parallel leaves the statement after the if outside the if', + `process invoice_batch { + call Call5(process: "invoice-approval") + parallel { + { + if (true) { + } else if (true) { + goto Call5 + } + await condition(false && !order['le']) + } + { + } + } +}`, + ], + [ + 'a parallel whose other branch ends continues at its own one-in join after a nested parallel in the live branch', + `process order-fulfilment { + parallel { + if (true) { + parallel { + { + } + { + end Done + } + } + } + else { + service Hold9(class: "org.acme.Audit") + end Done10 + } + } + end Done11 +}`, + ], + [ + 'a goto from an outer else into a nested else-if leaves the join setting on the outer if', + `process claim-review { + if (true) (joinAsyncAfter: true) { + if (true) { + } else if (true) { + user check-stock6(formRef: "payout-approval", binding: deployment) + } + } else { + goto check-stock6 + } +}`, + ], + [ + 'a nested else-if entered by a goto from the outer else keeps its own join when its other branch ends', + `process p { + if (true) (joinAsyncAfter: true) { + if (true) { + user F + end Done + } else if (true) { + user U + } + } else { + goto U + } + user C +}`, + ], + [ + 'an if whose branch a goto after the if re-enters prints no else on the first pass', + `process p { + if (true) { + step P + parallel { + if (true) { + } + if (true) { + } + } + } + goto P +}`, + ], + [ + 'a parallel whose branch jumps back above it does not take the nested parallel in its other branch as its join', + `process p { + user A + parallel { + { + parallel { + { + user B + } + { + user C + } + } + end E + } + { + user X + goto A + } + } +}`, + ], + [ + 'a link throw ending a nested branch keeps its catch after the nested if when the catch runs on to the outer join', + `process p { + var c: any + var d: any + if (c) { + if (d) { + user A + } else { + user R + emit link("Retry") + } + goto A + await link At("Retry") + user T + } else { + user S + } +}`, + ], + [ + 'a nested branch jumping back above the block prints inside the branch, leaving the implicit end at the tail', + `process p { + var x: any + await condition Wait(x) + if (true) { + if (true) { + step Rate + goto Wait + } else if (true) { + } + await { + timer("PT1H") { + } + message("Cancelled") { + } + } + } +}`, + ], + [ + 'a do-while opening a branch that ends on a throw prints inside the branch', + `process invoice_batch { + if (true) { + do { + service Release2(delegate: "\${shipDelegate}") + } while (true) + throw signal("Shutdown") + } + end Done12 +}`, + ], + [ + 'a goto from an if into the do-while right after it, with the process ending on the implicit end, keeps that end implicit', + `process p { + var c: any + var x: any + if (c) { + goto A + } + do { + user A + user B + } while (x) +}`, + ], + [ + 'a goto from an if into a cycle a later if also jumps back into prints the same on the next pass', + `process p { + var c: any + var d: any + if (d) { + goto B + } + user A + if (c) { + user B + } + user C + goto A +}`, + ], + [ + 'a step an await branch jumps to, in an else-if branch before the await, prints outside the await', + `process onboarding { + if (true) { + } else if (true) { + step Notify10 + throw message("Cancelled") + } else { + await { + signal("Ready") { + goto Notify10 + } + message("OrderReceived") { + } + } + } + end Done22 +}`, + ], + [ + 'a step a parallel branch jumps to prints in the nested else-if branch that also enters it', + `process claim-review { + if (true) { + if (true) { + } else if (true) { + decide Charge4(decision: "approve-claim") + end Done5 + } + } else { + parallel { + if (true) { + goto Charge4 + } + { + } + } + } +}`, + ], + [ + "a step a nested if's branch jumps to prints in the outer else branch that jumps to it and ends on its own", + `process p { + if (true) { + if (true) { + user Review + end Done + } + } else { + user Approve + goto Review + } +}`, + ], + [ + 'a step in a while body that a later parallel branch jumps to prints in the while body', + `process invoice_batch { + while (true) { + if (true) { + script Record3 \`\`\`feel + if (a) { b } else { c } + \`\`\` + goto Record3 + } + } + parallel { + if (true) { + } + if (true) { + goto Record3 + } + } +}`, + ], + [ + 'a step an await branch in the next else-if jumps to prints outside the await', + `process order-fulfilment { + if (true) { + send Ship4(expression: "\${bean.run(execution)}") + end Done13 + } else if (true) { + await { + signal("Shutdown") { + } + condition(("\${fn(\\"a\\")}") == score / score) { + goto Ship4 + } + } + } + end Done27 +}`, + ], + [ + 'a goto from an await branch into the body of a while after the await prints as a goto beside the while', + `process p { + var x: any + await { + timer(at: "2027-01-01T00:00:00") { + goto B + } + message("M") { + user Z + } + } + while (x) { + user A + user B + } +}`, + ], + [ + 'a goto from an await branch into the body of a do-while after the await prints as a goto beside the do-while', + `process p { + var x: any + await { + timer(at: "2027-01-01T00:00:00") { + goto B + } + message("M") { + user Z + } + } + do { + user A + user B + } while (x) +}`, + ], + [ + 'a goto from an await branch into a do-while nested in a later do-while prints as a goto beside the loops', + `process p { + var c: any + var d: any + var e: any + var x: any + var y: any + var z: any + await { + timer(at: "2027-01-01T00:00:00") { + goto C + } + message("M") { + user Z + } + } + do { + user A + do { + user B + user C + } while (y) + } while (x) +}`, + ], + [ + 'a start after an if that runs into the implicit end prints at the tail and keeps that end implicit', + `process onboarding { + var order: any + if (true) { + emit link("Rework") + await link("Rework") + } else { + throw signal("Ready") + } + start Start18 condition(order.paid) +}`, + ], + [ + 'a start after an if, entering a step that runs into the implicit end, prints before that step and keeps that end implicit', + `process claim-review { + var order: any + if (true) { + emit link("Retry") + await link("Retry") + } else { + emit link("Rework") + await link("Rework") + } + start Start19 condition(order.paid) + service Review20(class: "com.example.Delegate") +}`, + ], + [ + 'a start entering a step the chain before it still runs on to prints after a goto to that step', + `process p { + var c: any + user A + goto T + start B condition(c) + user T +}`, + ], + [ + "a nested if's own goto out of a parallel branch that ends on it stays a goto, not a walk into the step it names", + `process p { + var c: any + parallel { + { + if (c) { + user X + goto N + } + } + { + user Q + } + } + if (c) { + user M + user N + end E1 + } + user T +}`, + ], + [ + "a nested if's own goto out of an await branch that ends on it stays a goto, not a walk into the step it names", + `process p { + var c: any + await { + signal("Ready") { + if (c) { + user X + goto N + } + } + condition(c) { + user Q + } + } + if (c) { + user M + user N + end E1 + } + user T +}`, + ], + [ + 'a step after an if that a nested if branch jumps to prints after the if, not inside the nested branch', + `process p { + var c: any + if (c) { + if (c) { + user A + goto G + } else if (c) { + user B + goto A + } + user K + } else { + user L + } + user G +}`, + ], + [ + 'a while opening an if branch whose body an earlier goto enters mid-way prints inside the branch', + `process p { + var c: any + var d: any + var x: any + if (d) { + goto B + } + if (c) { + while (x) { + user A + user B + } + throw signal("S") + } + end Done +}`, + ], + [ + 'an if branch a sibling jumps into prints inside the if, not after the implicit end', + `process p { + var c: any + if (c) { + user A + goto G + } else if (c) { + user B + goto A + } + user K + user G +}`, + ], + [ + "a nested if branch a sibling jumps into prints inside it when the surrounding loop's back edge reaches the outer join", + `process p { + var c: any + while (c) { + user H + if (c) { + if (c) { + user A + goto H + } else if (c) { + user B + goto A + } + user K + } else { + user L + } + user N + } +}`, + ], + [ + "a nested if branch in a loop that a sibling jumps into prints inside it when it jumps straight to the outer if's join", + `process p { + var c: any + while (c) { + if (c) { + if (c) { + user A + goto G + } else if (c) { + user B + goto A + } + user K + } else { + user L + } + user G + } + user Z +}`, + ], + [ + 'a step in an if branch that one later parallel branch jumps back to prints in the if branch', + `process invoice_batch { + if (true) { + await message("OrderReceived") + service Fetch5(type: "shell") { + field command = "df" + } + end Done6 + } + parallel { + if (true) { + } + if (true) { + goto Fetch5 + } + } +}`, + ], + [ + "a step after an if that a do-while in one branch runs back into prints after the if, not inside the loop's branch", + `process p { + var c: any + var x: any + if (c) { + do { + user A + user D + } while (x) + end E1 + } else if (c) { + user B + goto D + } + user K + goto D +}`, + ], + ...ORDER_ROWS, +]; + +describe('every regression input is accepted by the validator', () => { + it.each(ROWS.map(([title, source]) => [title, source] as const))( + '%s', + async (_title, source) => { + expect(await errorMessages(source)).toEqual([]); + }, + ); +}); + +describe('round-trip regressions from minimized fuzz finds', () => { + for (const [title, source] of ROWS) { + it(title, async () => { + const first = await roundTrip(source); + expect(await errorMessages(first.dsl)).toEqual([]); + expect(modelSignature(first.ir3)).toEqual(modelSignature(first.ir1)); + + const second = await roundTrip(first.dsl); + expect(second.dsl).toBe(first.dsl); + }); + } +}); + +// A model from another tool lists its elements and flows in any order, so +// which chain, start or loop entry the printer takes first must not depend on +// it, nor move once the print is read back in statement order. +function reversed(c: C): C { + return { + ...c, + flowElements: c.flowElements + .map((el) => (el.kind === 'subProcess' ? reversed(el) : el)) + .reverse(), + sequenceFlows: c.sequenceFlows.toReversed(), + }; +} + +describe('an order-dependent shape prints the same model, stable on the next pass, when the model lists its elements in reverse', () => { + for (const [title, source, comparison = 'same'] of ORDER_ROWS) { + it(title, async () => { + const { ir1, ir2 } = await roundTrip(source); + const dsl = printDsl(reversed(ir2)); + expect(await errorMessages(dsl)).toEqual([]); + const { ir1: reread, dsl: next } = await roundTrip(dsl); + expect(compareModels(ir1, reread)).toBe(comparison); + expect(next).toBe(dsl); + }); + } +}); + +// A plain reprint of the round-tripped IR, and the same with element and flow +// order reversed (standing in for a model authored by another tool); a row +// picks whichever mechanism its regression needs. +async function plain(source: string): Promise { + return (await roundTrip(source)).ir2; +} +async function reversedOrder(source: string): Promise { + return reversed((await roundTrip(source)).ir2); +} + +// The printer writes no boundary id, so an authored one is minted afresh on +// the next compile: printed first, it would take its minted sibling's base +// id. No script source produces that id collision, so the XML is mutated +// after a first round trip instead. +async function mintedSiblingOrder(source: string): Promise { + const { xml } = await roundTrip(source); + const { ir } = await xmlToIr(xml.replaceAll('Boundary_A_signal_2', 'MyB')); + return reversed(ir); +} + +// Two authored ids share no minted rank to order by, so nothing but the +// model's own listing order can settle a print between them. +async function modelListingOrder(source: string): Promise { + const { xml } = await roundTrip(source); + const { ir } = await xmlToIr( + xml + .replaceAll('Boundary_A_signal_2', 'MyC') + .replaceAll('Boundary_A_signal', 'MyB'), + ); + return ir; +} + +// Every row's print keeps every path the source keeps too, so a check that +// only compares reachable steps and conditions (as `ROWS`'s round-trip test +// does) would pass a misplaced print as readily as the right one; only the +// literal print, revalidated through the compiler, pins the regression. +const PRINTS_AS_WRITTEN: [ + title: string, + source: string, + produce: (source: string) => Promise, +][] = [ + // The join spelled as an extra `else { goto After }` reads the same paths + // reversed too. + [ + 'two sibling branches that each jump back before their if keep the join after the if in reverse order', + `process p { + var x: any + var y: any + var z: any + user A + if (x) { + user B + if (y) { + goto A + } + } else { + user C + if (z) { + goto A + } + } + user After +} +`, + reversedOrder, + ], + // The jump is the split's first conditioned route, the one a `do` test + // needs, but the split sits inside a block opened after the jump target, + // so the `while` would close the loop inside that block. + [ + 'a jump back above the enclosing parallel, tested first, stays a goto inside its branch', + `process p { + user Call5 + parallel { + { + if (true) { + goto Call5 + } else if (true) { + } + } + { + } + } +} +`, + plain, + ], + [ + 'a jump back above the enclosing if, tested first, stays a goto inside its branch', + `process p { + var a: any + var t: any + user C + if (a) { + if (t) { + goto C + } else if (t) { + } + } else { + user B + } +} +`, + plain, + ], + [ + 'a goto after a do-while back into a loop nested in its body keeps the do-while', + `process p { + var x: any + var y: any + do { + user A + while (y) { + user B + user C + } + } while (x) + goto C +} +`, + plain, + ], + [ + 'a boundary handler with an authored id prints after its minted sibling on the same host and trigger', + `process p { + user A + on A: signal("Ready") { + user X + } + on A: signal("Shutdown") { + user Y + } +} +`, + mintedSiblingOrder, + ], + [ + 'two boundary handlers with authored ids on the same host and trigger keep their model order', + `process p { + user A + on A: signal("Ready") { + user X + } + on A: signal("Shutdown") { + user Y + } +} +`, + modelListingOrder, + ], + // `Fail` and `X` are both authored, so inlining `step X throw error Fail(E)` + // re-derives the same ids either way: the authored-tail rule's reason to + // hoist a chain behind the block does not apply here. + [ + 'a hoisted chain of named steps stays in its branch', + `process p { + error E + var c: any + if (c) { + step X + throw error Fail(E) + } else if (c) { + goto X + } + end Done +} +`, + plain, + ], + // `B` is also entered by the ordinary flow after the outer `if`, through + // the nested `if (c)`, so inlining `goto B`'s target there would print `B` + // before the steps that flow into it the ordinary way. + [ + 'a goto stays a goto when the flow after its block also reaches the same target', + `process p { + var c: any + var d: any + if (d) { + goto B + } + user A + if (c) { + user B + } + user C + goto A +} +`, + plain, + ], + // A step ahead of the boundary handler's own `goto` moves the re-entering + // predecessor from the boundary event itself to a step it dominates; the + // walk must still trace that step back to the boundary rather than treating + // it as ordinary flow. + [ + "a boundary handler's own goto does not push a branch entry out of the branch when a step comes before it", + `process claim-review { + if (true) { + call Call5(process: "invoice-approval") + } else { + service Release10(topic: "shipping") + end Done + } + on Call5: escalation { + step H + goto Release10 + } +} +`, + plain, + ], +]; + +describe('a printed regression re-parses and revalidates as written', () => { + it.each(PRINTS_AS_WRITTEN)('%s', async (_title, source, produce) => { + const printed = irToDsl(await produce(source)); + expect(printed.warnings).toEqual([]); + expect(printed.source).toBe(source); + expect(await errorMessages(printed.source)).toEqual([]); + }); +}); + +// Printed as a jump back, this loop reads the same paths, so the row contract +// alone would pass it: the print itself is the regression. +it('a boundary handler jumping into a do-while body leaves the loop printed as a do-while', async () => { + const source = `process p { + var u: any + do { + user A + user B + } while (u) + user C + on A: signal("R") { + goto B + } +} +`; + const { dsl } = await roundTrip(source); + expect(dsl).toBe(source); + expect(await errorMessages(dsl)).toEqual([]); +}); + +// The source itself is refused (the validator counts the `emit link` as +// ending the branch even with its `await link` right behind it, so `user +// After` reads as unreachable there), so it is compiled without validation; +// only the print, which sinks `user After` +// into the branch that carries the live route, is checked. +it('an if whose only live branch crosses a link pair sinks the step after it into that branch', async () => { + const source = `process p { + var c: any + var x: any + if (c) { + if (x) { + emit link("L") + await link("L") + await { + message("M") { + } + signal("S") { + } + } + } else { + end E + } + user After + } else { + end Done + } +}`; + const { dsl } = await roundTrip(source); + expect(await errorMessages(dsl)).toEqual([]); + const second = await roundTrip(dsl); + expect(second.dsl).toBe(dsl); +}); + +// `goto X` after the unreachable `user Z` (dead code behind `end E`) is +// itself unreachable, so it must not count as a predecessor entering `X` +// from outside the branch. The source is refused for that dead code, not +// for the branch placement under test, so only the print is checked here. +it('an unreachable predecessor of a branch entry does not push its chain out of the branch', async () => { + const source = `process p { + var c: any + if (c) { + user X + if (c) { + end E1 + } + } else { + user W + } + user Y + end E + user Z + goto X +} +`; + const { dsl } = await roundTrip(source); + expect(dsl).toBe(source); +}); + +// `wallStaysOff`'s `postDominates(entry, join)` branch keeps a nested if +// branch's entry from walling itself off when the entry is also every route +// out of the join: without it, a later `goto A` from past the outer if hoists +// `A` behind the implicit end instead of leaving it inside the nested if. +it('a nested if branch a later goto jumps back into stays inside the if, not hoisted behind the implicit end', async () => { + const source = `process p { + var c: any + var d: any + if (c) { + if (d) { + goto A + } + } + user K + user A + user N + user Z +} +`; + const { dsl } = await roundTrip(source); + expect(dsl).toBe(source); + expect(await errorMessages(dsl)).toEqual([]); +}); + +// A nested do-while has two back-edge candidates sharing its head (the inner +// do's own test and the outer if's `goto B`), so `ownLoopTest` cannot name a +// single owner for the cycle; gating `headsEnteredPast` on that owner instead +// of the CFG's rank would drop the cycle there and let the walk pick the +// other if branch's flow to hold the loop instead. +it('an if whose branches both close on the same nested do-while keeps the loop in the else branch', async () => { + const source = `process p { + var d: any + var e: any + var x: any + var y: any + if (d) { + do { + user A + do { + user B + user C + } while (y) + } while (x) + } else { + user Z + goto B + } + if (e) { + goto B + } +} +`; + const { dsl } = await roundTrip(source); + expect(dsl).toBe( + 'process p {\n' + + ' var d: any\n' + + ' var e: any\n' + + ' var x: any\n' + + ' var y: any\n' + + ' if (d) {\n' + + ' goto A\n' + + ' } else {\n' + + ' user Z\n' + + ' do {\n' + + ' do {\n' + + ' user B\n' + + ' user C\n' + + ' } while (y)\n' + + ' if (x) {\n' + + ' user A\n' + + ' goto B\n' + + ' }\n' + + ' } while (e)\n' + + ' }\n' + + '}\n', + ); + expect(await errorMessages(dsl)).toEqual([]); +}); + +// Both loops read the same paths whether nested or flattened to if/goto, so +// the row contract alone would pass this too: the print itself is the +// regression. The handler re-enters the outer body past the inner loop's +// test gateway, which must not stop that gateway from dominating the outer +// one's. +it('a boundary handler re-entering the outer body past a nested do-while leaves both loops printed as do-while', async () => { + const source = `process p { + var u: any + var v: any + do { + do { + user A + user B + } while (v) + user C + } while (u) + user E + on E: signal("R") { + goto C + } +} +`; + const { dsl } = await roundTrip(source); + expect(dsl).toBe(source); + expect(await errorMessages(dsl)).toEqual([]); +}); + +// The generator cannot draw one gateway that both merges and splits, so this +// shape comes in as modeled XML: J joins two of F's routes and forks again. +// The script has no mixed gateway, so J prints as a join and a fork, which is +// why the path signature, counting gateways per route, is not compared. +const MIXED_JOIN = bpmnDoc( + [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ...[ + ['S', 'F'], + ['F', 'A'], + ['A', 'EA'], + ['F', 'B'], + ['F', 'C'], + ['B', 'J'], + ['C', 'J'], + ['J', 'D'], + ['J', 'E'], + ['D', 'ED'], + ['E', 'EE'], + ].map( + ([from, to]) => + ``, + ), + ].join('\n'), +); + +it('a gateway that merges a fork with an ending branch and splits again prints as the parallel join followed by a parallel', async () => { + const { ir } = await xmlToIr(MIXED_JOIN); + const dsl1 = printDsl(ir); + expect(dsl1).toBe(`process p { + start S + parallel { + { + user A + end EA + } + { + user B + } + { + user C + } + } + parallel { + { + user D + end ED + } + { + user E + end EE + } + } +} +`); + expect(await errorMessages(dsl1)).toEqual([]); + const second = await roundTrip(dsl1); + expect(second.dsl).toBe(dsl1); +}); + +// No script source lowers to this: the implicit start and a conditional start +// both enter T, which runs into the implicit end. A body opening with `start B` +// would have no implicit start, so the print must jump to T first. +it('an implicit start and a conditional start entering the same step keep both starts and the implicit end', async () => { + const xml = bpmnDoc( + [ + '', + '' + + '${c}' + + '', + '', + '', + '', + '', + '', + ].join('\n'), + ); + const { ir } = await xmlToIr(xml); + const dsl = printDsl(ir); + expect(dsl).toBe(`process p { + var c: any + goto T + start B condition(c) + user T +} +`); + expect(await errorMessages(dsl)).toEqual([]); + const { ir1: reread, dsl: next } = await roundTrip(dsl); + expect(compareModels(ir, reread)).toBe('restructured'); + expect(next).toBe(dsl); +}); + +// A link throw's catch prints right behind it with no `goto` between them +// (`emit link` / `await link`), so the flow the throw carried still runs on +// through the catch's own routes. `start C` enters the merge that chain +// reaches and `start B` enters the step past it, so both need their own +// `goto` closing that still-live flow first, the same as a start entering a +// step an ordinary fall-through chain already reaches. +it('a start entering a step reached only through a printed link hop keeps the flow closed with a goto', async () => { + const xml = bpmnDoc( + [ + '', + '' + + '${c}' + + '', + '' + + '${d}' + + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ].join('\n'), + ); + const { ir } = await xmlToIr(xml); + const dsl = printDsl(ir); + expect(dsl).toBe(`process p { + var c: any + var d: any + user A + emit link Th("L") + await link Ca("L") + goto T + start C condition(d) + goto T + start B condition(c) + user T +} +`); + expect(await errorMessages(dsl)).toEqual([]); + const { ir1: reread, dsl: dsl2 } = await roundTrip(dsl); + expect(compareModels(ir, reread)).toBe('restructured'); + // The round trip compiles the merge gateway away: three plain flows land on + // `T` directly, so `Ca`'s own chain now reaches the elided end on its own. + // The tail rule still has to treat that end as displaced, since `B` and `C` + // reach it too, or this second print pushes `end EndEvent_p` off the tail + // and prints it as a name the compiler refuses. + expect(await errorMessages(dsl2)).toEqual([]); + expect(dsl2).toBe(`process p { + var c: any + var d: any + user A + emit link Th("L") + await link Ca("L") + goto T + start C condition(d) + start B condition(c) + user T +} +`); + const { ir1: rereadAgain, dsl: dsl3 } = await roundTrip(dsl2); + expect(compareModels(ir, rereadAgain)).toBe('restructured'); + expect(dsl3).toBe(dsl2); +}); + +// The elided end's own branch does nothing once the walk reaches the end +// itself: pushing a `goto` there instead would name a target the deferred +// `end` print has not reached yet, so the leftover edge into it degrades to +// the unstructured-region marker rather than a wrong jump. The deferred `end` +// still lands ahead of `start B` in the printed order. +it('a conditional start entering the elided end keeps the end printed before the start', async () => { + const xml = bpmnDoc( + [ + '', + '' + + '${c}' + + '', + '', + '', + '', + '', + '', + ].join('\n'), + ); + const { ir } = await xmlToIr(xml); + const printed = irToDsl(ir); + expect(printed.source).toBe(`process p { + var c: any + user A + end EndEvent_p + start B condition(c) + // unstructured region: hand-repair required (dropped edge into EndEvent_p) +} +`); + expect(printed.warnings).toEqual([ + { + elementId: 'EndEvent_p', + category: 'droppedEdge', + message: + 'The script has an unstructured region: a route the model takes has no ' + + 'form here and was left out. The marker comment where it belonged ' + + 'names the step it led to and is where hand-repair starts.', + }, + { + elementId: 'EndEvent_p', + category: 'refusedStatement', + message: + 'The name this step carries in the model is one the script keeps for ' + + 'the names it derives itself, so it draws an error when the source is ' + + 'read back. Rename the step in the model and print it again.', + }, + ]); +}); + +// Minimized from a fuzz find: the `else if` branch's own `service Release19 +// goto Wait14` chain is walked while deciding whether the first branch's own +// chain (`InFirst`, `Wait14`, the `parallel`, `Fail18`) can print inline. That +// walk must stop at every node the first branch's entry does not +// loop-dominate, or it follows the `parallel`'s `goto Charge7` exit past the +// nodes the branch owns and reads `Release19` as part of it. The row contract +// alone would pass the misplaced print too, since the model stays equivalent +// either way; the print itself is the regression. +it("a goto between an if-chain's branches does not pull the target branch's chain into the other branch's inline check", async () => { + const source = `process p { + var order: any + var urgent: any + var approved: any + user Charge7 + if (order['div']) { + user InFirst + await message Wait14("OrderReceived") { + } + parallel { + if ((order == null)) { + call Call15(process: "invoice-approval") + emit signal("Ready") + } + if (urgent) { + service check-stock16(class: "org.acme.Audit") + goto Charge7 + } + } + throw signal Fail18("StockLow") + } else if (approved) { + service Release19(class: "com.example.orders.Ship") + goto Wait14 + } else { + service Charge(class: "com.example.Delegate") + } + end Done21 +} +`; + const { dsl } = await roundTrip(source); + expect(await errorMessages(dsl)).toEqual([]); + expect(dsl).toBe(`process p { + var approved: any + var order: any + var urgent: any + user Charge7 + if (order["div"]) { + user InFirst + await message Wait14("OrderReceived") + parallel { + if ((order == null)) { + call Call15(process: "invoice-approval") + emit signal("Ready") + } + if (urgent) { + service check-stock16(class: "org.acme.Audit") + goto Charge7 + } + } + throw signal Fail18("StockLow") + } else if (approved) { + service Release19(class: "com.example.orders.Ship") + goto Wait14 + } else { + service Charge(class: "com.example.Delegate") + } + end Done21 +} +`); +}); diff --git a/tests/fuzz.test.ts b/tests/fuzz.test.ts new file mode 100644 index 0000000..83241f2 --- /dev/null +++ b/tests/fuzz.test.ts @@ -0,0 +1,1041 @@ +// Round-trip fuzz harness over generated programs. +// +// Per seed: generate a program, validate it, then push its source through +// parse -> astToIr -> irToXml -> xmlToIr -> irToDsl -> validate -> astToIr, +// each stage inside a try, with the outcome classified (`runPipeline`, +// `symptomOf`). A validator-clean seed fails the oracle when its class is +// `crash`, `timeout`, `import-refused`, `dsl-invalid`, `silent-change` or +// `warned-restructuring`, when its first print carries any print warning, or +// when its second print differs from its first (`failingSeeds`, one +// `toEqual([])` assertion over every one of those). A seed counts as clean +// when `compareModels` (`./helpers/model-equivalence.ts`) reports `same`, +// `canonical` (differs only by JUEL quote style or a minted coordinate id) +// or `restructured` (a documented hoist, backward `goto` or branch sinking +// that keeps every step's content and the routes between them); only +// `changed` can fail. +// +// Environment: FUZZ_N (programs, default 200), FUZZ_SEED (first seed, +// default 1; program i uses FUZZ_SEED + i), FUZZ_OUT (output directory; +// when set, writes `results.jsonl`, `summary.json` and minimized programs +// under `min/` and runs minimization; when unset, writes nothing and skips +// minimization), FUZZ_SHUFFLE (`reverse`, or a number that seeds a +// permutation; unset runs in compiler order). The same FUZZ_SEED and FUZZ_N +// reproduce the same corpus on any tree; minimization re-applies the same +// FUZZ_SHUFFLE mode to every candidate. +// +// FUZZ_SHUFFLE reorders the imported model's `flowElements` and +// `sequenceFlows`, in the process and every nested container, before the +// first print: as another BPMN tool might list them. A gateway's own +// outgoing flows keep their relative order within that reordering, because +// Operaton evaluates a gateway's conditioned routes in document order, so +// shuffling them would change which route the model actually takes, not +// just how the printer reads it. Keeping route order fixed removes that +// artifact instead of asking the comparison to reconstruct +// engine-observable order semantics. +// +// Another tool can still list a gateway's routes in any order, so a seed that +// passes in that mode is printed a second time with every flow reordered, +// routes included (`runSeed`). That print is a different model, so it skips +// the comparison and fails only on the other conditions: a crash or refusal, +// output the validator refuses, a print warning, or a second pass that +// differs. Its symptoms carry a `routes|` prefix. +// +// Full run: FUZZ_N=2000 FUZZ_OUT= npm test --workspace tests -- fuzz.test.ts + +import { describe, it, expect } from 'vitest'; +import { appendFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { execSync } from 'node:child_process'; + +import { + astToIr, + irToDsl, + irToXml, + xmlToIr, + LayoutError, + UnsupportedConstructError, +} from '@bpmn-script/transform'; +import type { + BpmnProcess, + FlowContainer, + FlowElement, +} from '@bpmn-script/transform'; +import type { Model } from '@bpmn-script/language'; + +import { validate } from './helpers/pipeline.js'; +import { normalizeIr } from './helpers/normalize-ir.js'; +import { + compareModels, + type ModelComparison, +} from './helpers/model-equivalence.js'; +import { + generateProgram, + minimize, + renderProgram, + PROCESS_NAMES, + type Program, +} from './helpers/fuzz-generator.js'; + +const N = Number(process.env.FUZZ_N ?? 200); +const SEED = Number(process.env.FUZZ_SEED ?? 1); +const OUT = process.env.FUZZ_OUT; +const TIMEOUT_MS = 10_000; +const MIN_BUDGET_MS = 180_000; + +type ShuffleMode = 'reverse' | number; + +function parseShuffleMode(raw: string | undefined): ShuffleMode | undefined { + if (raw === undefined) return undefined; + if (raw === 'reverse') return 'reverse'; + const n = Number(raw); + if (!Number.isFinite(n)) { + throw new Error( + `FUZZ_SHUFFLE must be "reverse" or a number, got ${JSON.stringify(raw)}`, + ); + } + return n; +} + +const SHUFFLE = parseShuffleMode(process.env.FUZZ_SHUFFLE); + +// A seeded LCG, so (programSeed, mode) always draws the same permutation. +function permutationRng(programSeed: number, mode: number): () => number { + let state = Math.abs(programSeed * 2654435761 + mode) % 2147483647 || 1; + return () => { + state = (state * 48271) % 2147483647; + return state / 2147483647; + }; +} + +function permuted(items: readonly T[], rand: () => number): T[] { + const a = [...items]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +// Reorders a flow list the way another BPMN tool might list it, keeping each +// source's own outgoing flows in their original relative order: Operaton +// evaluates a gateway's conditioned routes in document order, so shuffling +// them changes which route the model takes, not just how the printer reads +// it (see the header comment). Only the order of the (source, group) blocks +// shuffles. +function reorderFlows( + flows: readonly F[], + mode: ShuffleMode, + rand: () => number, +): F[] { + const bySource = new Map(); + const sourceOrder: string[] = []; + for (const f of flows) { + let group = bySource.get(f.sourceRef); + if (group === undefined) { + group = []; + bySource.set(f.sourceRef, group); + sourceOrder.push(f.sourceRef); + } + group.push(f); + } + const reorderedSources = + mode === 'reverse' ? sourceOrder.toReversed() : permuted(sourceOrder, rand); + return reorderedSources.flatMap((s) => bySource.get(s)!); +} + +// Reorders `flowElements` and `sequenceFlows` of `container` and every +// nested sub-process body before the first print, a gateway's own routes +// too when `routes` is set. One `rand` stream is shared across the whole +// walk, so nested containers of the same size still draw distinct +// permutations. +function reorderContainer( + container: T, + mode: ShuffleMode, + rand: () => number, + routes: boolean, +): T { + const reorderElements = (items: readonly FlowElement[]): FlowElement[] => + mode === 'reverse' ? items.toReversed() : permuted(items, rand); + const walk = (c: FlowContainer): FlowContainer => { + const flowElements = reorderElements(c.flowElements).map((el) => + el.kind === 'subProcess' + ? (walk(el as unknown as FlowContainer) as unknown as FlowElement) + : el, + ); + return { + ...c, + flowElements, + sequenceFlows: routes + ? mode === 'reverse' + ? c.sequenceFlows.toReversed() + : permuted(c.sequenceFlows, rand) + : reorderFlows(c.sequenceFlows, mode, rand), + }; + }; + return walk(container) as T; +} + +type Cls = + | 'crash' + | 'timeout' + | 'import-refused' + | 'dsl-invalid' + | 'silent-change' + | 'warned-restructuring' + | 'clean'; + +const CLASS_ORDER: Cls[] = [ + 'crash', + 'timeout', + 'import-refused', + 'dsl-invalid', + 'silent-change', + 'warned-restructuring', + 'clean', +]; + +// A validator-clean seed fails on one of these classes regardless of the +// second pass, when its first print carries any print warning (which +// includes every `warned-restructuring` seed, since that class requires at +// least one), or when the second print differs from the first. +const FAIL_CLASSES = new Set([ + 'crash', + 'timeout', + 'import-refused', + 'dsl-invalid', + 'silent-change', + 'warned-restructuring', +]); + +interface Outcome { + cls: Cls; + stage: string; + message: string; + errorName?: string; + symptom: string; + comparison?: ModelComparison; + importWarnings?: number; + importWarningCategories: string[]; + importWarningMessages: string[]; + unstableLines?: { + kind: 'digits-only' | 'text'; + before: string; + after: string; + }; + printWarningCategories: string[]; + layoutFallback: boolean; + secondPassStable?: boolean; + routesShuffled: boolean; + dsl1?: string; + diff?: string[]; + diffEntries?: DiffEntry[]; + ms: number; +} + +class TimeoutError extends Error { + constructor(stage: string) { + super(`stage ${stage} exceeded ${TIMEOUT_MS} ms`); + this.name = 'TimeoutError'; + } +} + +// Bounds an async stage. A stage that never yields to the event loop cannot be +// interrupted from inside the process; the bound fires as soon as it does. +async function bounded(stage: string, fn: () => Promise): Promise { + let timer: ReturnType | undefined; + const clock = new Promise((_, reject) => { + timer = setTimeout(() => reject(new TimeoutError(stage)), TIMEOUT_MS); + }); + try { + return await Promise.race([fn(), clock]); + } finally { + clearTimeout(timer); + } +} + +function isRefusal(e: unknown): e is UnsupportedConstructError { + return e instanceof UnsupportedConstructError; +} + +// `toEqual` semantics on a string: sorted keys, `undefined` dropped. +function stable(x: unknown): string { + return JSON.stringify(x, (_k, v) => { + if (v && typeof v === 'object' && !Array.isArray(v)) { + const o = v as Record; + return Object.fromEntries( + Object.keys(o) + .sort() + .filter((k) => o[k] !== undefined) + .map((k) => [k, o[k]]), + ); + } + return v; + }); +} + +// Element lists keyed by id so a diff names elements, not positions. +function keyed(v: unknown): unknown { + if (Array.isArray(v)) { + if ( + v.length > 0 && + v.every((e) => e && typeof e === 'object' && 'id' in e) + ) { + const o: Record = {}; + for (const e of v) o[(e as { id: string }).id] = keyed(e); + return o; + } + return v.map(keyed); + } + if (v && typeof v === 'object') { + const o = v as Record; + return Object.fromEntries( + Object.keys(o) + .filter((k) => o[k] !== undefined) + .map((k) => [k, keyed(o[k])]), + ); + } + return v; +} + +interface DiffEntry { + path: string; + kind: 'only-before' | 'only-after' | 'changed'; + before?: unknown; + after?: unknown; +} + +function diffPaths( + a: unknown, + b: unknown, + path = '', + out: DiffEntry[] = [], +): DiffEntry[] { + if (out.length >= 40) return out; + if (stable(a) === stable(b)) return out; + const isObj = (x: unknown): x is Record => + x !== null && typeof x === 'object'; + if (isObj(a) && isObj(b)) { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const k of [...keys].sort()) { + const p = path ? `${path}.${k}` : k; + if (!(k in a)) out.push({ path: p, kind: 'only-after', after: b[k] }); + else if (!(k in b)) + out.push({ path: p, kind: 'only-before', before: a[k] }); + else diffPaths(a[k], b[k], p, out); + } + return out; + } + out.push({ path, kind: 'changed', before: a, after: b }); + return out; +} + +function diffText(d: DiffEntry): string { + if (d.kind === 'only-after') + return `${d.path}: only after = ${short(d.after)}`; + if (d.kind === 'only-before') + return `${d.path}: only before = ${short(d.before)}`; + return `${d.path}: ${short(d.before)} -> ${short(d.after)}`; +} + +function short(x: unknown): string { + const s = stable(x) ?? 'undefined'; + return s.length > 160 ? s.slice(0, 157) + '...' : s; +} + +// The generator's four process names and every counter, so one symptom is one +// key whatever process and statement numbers a seed drew. +const PROCESS_NAME_PATTERN = new RegExp(PROCESS_NAMES.join('|'), 'g'); + +function normalizeMessage(m: string): string { + return m + .replace(PROCESS_NAME_PATTERN, '') + .replace(/\d+/g, '#') + .replace(/\s+/g, ' ') + .slice(0, 200); +} + +// The leaves a structural diff touched, digits and process names blanked so a +// moved coordinate or a different statement count does not split the group. +function diffShape(diff: DiffEntry[]): string { + const leaf = (path: string): string => { + const segs = path.replace(/^\(positional\) /, '').split('.'); + return segs[segs.length - 1] + .replace(PROCESS_NAME_PATTERN, '') + .replace(/\d+/g, '#'); + }; + const leaves = [...new Set(diff.map((d) => leaf(d.path)))].sort(); + return leaves.slice(0, 4).join(','); +} + +function firstLineDifference( + a: string, + b: string, +): { kind: 'digits-only' | 'text'; before: string; after: string } { + const la = a.split('\n'); + const lb = b.split('\n'); + let kind: 'digits-only' | 'text' = 'digits-only'; + let first: { before: string; after: string } | undefined; + for (let i = 0; i < Math.max(la.length, lb.length); i++) { + const x = la[i] ?? ''; + const y = lb[i] ?? ''; + if (x === y) continue; + first ??= { before: x.trim(), after: y.trim() }; + if (x.replace(/\d+/g, '#') !== y.replace(/\d+/g, '#')) kind = 'text'; + } + return { kind, ...(first ?? { before: '', after: '' }) }; +} + +async function parseModel(text: string): Promise<{ + model: Model; + errors: string[]; + warnings: string[]; + dispose: () => Promise; +}> { + const result = await validate(text); + const messageOf = (m: string | { value: string }): string => + typeof m === 'string' ? m : m.value; + const errors = result.diagnostics + .filter((d) => d.severity === 1) + .map((d) => messageOf(d.message)); + const warnings = result.diagnostics + .filter((d) => d.severity === 2) + .map((d) => messageOf(d.message)); + const dispose = + (result as { dispose?: () => Promise }).dispose ?? (async () => {}); + return { + model: result.document.parseResult.value, + errors, + warnings, + dispose, + }; +} + +async function compile(text: string): Promise { + const { model, errors, dispose } = await parseModel(text); + try { + if (errors.length > 0) + throw new Error('parse/validation errors: ' + errors[0]); + return astToIr(model); + } finally { + await dispose(); + } +} + +function fails(o: Outcome): boolean { + return ( + FAIL_CLASSES.has(o.cls) || + o.printWarningCategories.length > 0 || + o.secondPassStable === false + ); +} + +// The route-order-preserving run, and in FUZZ_SHUFFLE mode the fully +// reordered one when the first passes (see the header comment). +async function runSeed(text: string, programSeed: number): Promise { + const o = await runPipeline(text, programSeed, false); + if (SHUFFLE === undefined || fails(o)) return o; + const routes = await runPipeline(text, programSeed, true); + return fails(routes) ? routes : o; +} + +async function runPipeline( + text: string, + programSeed: number, + routesShuffled: boolean, +): Promise { + const started = Date.now(); + const o: Outcome = { + cls: 'clean', + stage: '', + message: '', + symptom: '', + importWarningCategories: [], + importWarningMessages: [], + printWarningCategories: [], + layoutFallback: false, + routesShuffled, + ms: 0, + }; + const finish = (partial: Partial): Outcome => { + Object.assign(o, partial); + o.ms = Date.now() - started; + o.symptom = (routesShuffled ? 'routes|' : '') + symptomOf(o); + delete o.diffEntries; + return o; + }; + let stage = 'compile'; + try { + const ir1 = await compile(text); + + stage = 'serialize'; + let xml1: string; + try { + xml1 = await bounded(stage, () => irToXml(ir1)); + } catch (e) { + if (!(e instanceof LayoutError)) throw e; + xml1 = e.xml; + o.layoutFallback = true; + } + + stage = 'import'; + let imported: Awaited>; + try { + imported = await bounded(stage, () => xmlToIr(xml1)); + } catch (e) { + if (isRefusal(e)) { + return finish({ + cls: 'import-refused', + stage, + message: e.message, + errorName: e.name, + }); + } + throw e; + } + o.importWarnings = imported.warnings.length; + o.importWarningCategories = [ + ...new Set(imported.warnings.map((w) => w.category)), + ].sort(); + o.importWarningMessages = [ + ...new Set(imported.warnings.map((w) => normalizeMessage(w.message))), + ].slice(0, 3); + + stage = 'print'; + const forPrint = + SHUFFLE === undefined + ? imported.ir + : reorderContainer( + imported.ir, + SHUFFLE, + typeof SHUFFLE === 'number' + ? permutationRng(programSeed, SHUFFLE) + : () => 0, + routesShuffled, + ); + const printed = irToDsl(forPrint); + const dsl1 = printed.source; + const pw = printed.warnings ?? []; + o.printWarningCategories = [...new Set(pw.map((w) => w.category))].sort(); + o.dsl1 = dsl1; + + stage = 'revalidate'; + const { model, errors, dispose } = await parseModel(dsl1); + if (errors.length > 0) { + await dispose(); + return finish({ cls: 'dsl-invalid', stage, message: errors[0] }); + } + + stage = 'recompile'; + let ir3: BpmnProcess; + try { + ir3 = astToIr(model); + } finally { + await dispose(); + } + const n1 = normalizeIr(ir1); + const n3 = normalizeIr(ir3); + + stage = 'serialize2'; + let xml2: string; + try { + xml2 = await bounded(stage, () => irToXml(ir3)); + } catch (e) { + if (!(e instanceof LayoutError)) throw e; + xml2 = e.xml; + o.layoutFallback = true; + } + + stage = 'import2'; + let imported2: Awaited>; + try { + imported2 = await bounded(stage, () => xmlToIr(xml2)); + } catch (e) { + if (isRefusal(e)) { + return finish({ + cls: 'import-refused', + stage, + message: e.message, + errorName: e.name, + }); + } + throw e; + } + + stage = 'print2'; + const dsl2 = irToDsl(imported2.ir).source; + o.secondPassStable = dsl2 === dsl1; + if (!o.secondPassStable) o.unstableLines = firstLineDifference(dsl1, dsl2); + + if (routesShuffled) return finish({ cls: 'clean', stage, message: '' }); + + stage = 'compare'; + const comparison = compareModels(ir1, ir3); + if (comparison === 'changed') { + // Two flows can share a canonical key after normalization (a conditioned + // and a fallback flow between one fork and join), which `keyed` collapses; + // the positional diff then names the difference. + let entries = diffPaths(keyed(n1), keyed(n3)); + if (entries.length === 0) { + entries = diffPaths(n1, n3).map((d) => ({ + ...d, + path: `(positional) ${d.path}`, + })); + } + const diff = entries.map(diffText); + o.diffEntries = entries; + return finish({ + cls: pw.length > 0 ? 'warned-restructuring' : 'silent-change', + comparison, + stage: 'compare', + message: diff.slice(0, 3).join('; '), + diff, + }); + } + return finish({ cls: 'clean', comparison, stage: 'compare', message: '' }); + } catch (e) { + if (e instanceof TimeoutError) { + return finish({ cls: 'timeout', stage, message: e.message }); + } + const err = e as Error; + return finish({ + cls: 'crash', + stage, + errorName: err?.name ?? typeof e, + message: `${err?.message ?? String(e)}\n${(err?.stack ?? '').split('\n').slice(1, 4).join('\n')}`, + }); + } +} + +function symptomOf(o: Outcome): string { + switch (o.cls) { + case 'crash': + return `crash|${o.stage}|${o.errorName}|${normalizeMessage(o.message.split('\n')[0])}`; + case 'timeout': + return `timeout|${o.stage}`; + case 'import-refused': + return `import-refused|${o.stage}|${o.errorName}|${normalizeMessage(o.message)}`; + case 'dsl-invalid': + return `dsl-invalid|${normalizeMessage(o.message)}`; + case 'silent-change': + return `silent-change|${diffShape(o.diffEntries ?? [])}`; + case 'warned-restructuring': + return `warned-restructuring|${o.printWarningCategories.join(',')}`; + case 'clean': + // A clean comparison can still carry a print warning (the model did + // not change, but the print warned about it); that keeps the seed out + // of the ordinary `clean` bucket so it is minimized and reported. + if (o.printWarningCategories.length > 0) + return `clean-warned|${o.printWarningCategories.join(',')}`; + return o.secondPassStable === false ? 'clean-unstable' : 'clean'; + } +} + +// Whether a minimization candidate still shows the failure being reduced. +function sameFailure(original: Outcome, candidate: Outcome): boolean { + if ( + candidate.cls !== original.cls || + candidate.routesShuffled !== original.routesShuffled + ) + return false; + switch (original.cls) { + case 'dsl-invalid': + return ( + normalizeMessage(candidate.message) === + normalizeMessage(original.message) + ); + case 'crash': + case 'import-refused': + return ( + candidate.errorName === original.errorName && + candidate.stage === original.stage + ); + case 'clean': + return candidate.symptom === original.symptom; + default: + return true; + } +} + +interface SeedRow { + seed: number; + valid: boolean; + errors?: string[]; + warnings?: number; + warningMessages?: string[]; + statements?: number; + outcome?: Outcome; +} + +function countStatements(p: Program): number { + let n = 0; + const walk = (body: Program['body']): void => { + for (const s of body) { + n++; + if ('body' in s) walk(s.body); + if (s.k === 'if') { + walk(s.then); + for (const e of s.elseIfs) walk(e.body); + if (s.else) walk(s.else); + } + if (s.k === 'parallel' || s.k === 'race') + for (const b of s.branches) walk(b.body); + } + }; + walk(p.body); + return n; +} + +function gitHead(): string { + try { + return execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim(); + } catch { + return 'unknown'; + } +} + +describe('round-trip fuzz', () => { + it( + `round-trip fuzz: generated programs ${SEED}..${SEED + N - 1} round-trip clean and warning-free${SHUFFLE === undefined ? '' : ` (shuffle: ${SHUFFLE})`}`, + { timeout: 6 * 60 * 60 * 1000 }, + async () => { + if (OUT !== undefined) { + // A stale minimized program from an earlier run would misattribute a symptom. + rmSync(join(OUT, 'min'), { recursive: true, force: true }); + mkdirSync(join(OUT, 'min'), { recursive: true }); + writeFileSync(join(OUT, 'results.jsonl'), ''); + } + const rows: SeedRow[] = []; + const programs = new Map(); + const t0 = Date.now(); + + for (let i = 0; i < N; i++) { + const seed = SEED + i; + const program = generateProgram(seed); + const text = renderProgram(program); + const { errors, warnings, dispose } = await parseModel(text); + await dispose(); + const row: SeedRow = { + seed, + valid: errors.length === 0, + statements: countStatements(program), + warnings: warnings.length, + warningMessages: warnings.map((w) => w.slice(0, 120)), + }; + if (!row.valid) { + row.errors = errors.slice(0, 3); + } else { + row.outcome = await runSeed(text, seed); + programs.set(seed, program); + } + rows.push(row); + if (OUT !== undefined) { + appendFileSync( + join(OUT, 'results.jsonl'), + JSON.stringify(row) + '\n', + ); + } + if ((i + 1) % 100 === 0) { + console.log( + `[fuzz] ${i + 1}/${N} in ${((Date.now() - t0) / 1000).toFixed(0)}s`, + ); + } + } + + // ---- aggregate ---- + const valid = rows.filter((r) => r.valid); + const classes = Object.fromEntries( + CLASS_ORDER.map((c) => [c, 0]), + ) as Record; + const seedsByClass = Object.fromEntries( + CLASS_ORDER.map((c) => [c, [] as number[]]), + ) as Record; + let cleanCanonical = 0; + let cleanRestructured = 0; + const failingSeeds: number[] = []; + const side = { + importWarningsEmpty: 0, + importWarningsNonEmpty: 0, + secondPassStable: 0, + secondPassUnstable: 0, + secondPassNotReached: 0, + layoutFallback: 0, + }; + const layoutFallbackSeeds: number[] = []; + const cleanButUnstable: number[] = []; + const importWarningCategories: Record< + string, + { count: number; exampleSeed: number } + > = {}; + const importWarningMessages: Record< + string, + { count: number; exampleSeed: number } + > = {}; + const unstable: { + seed: number; + cls: Cls; + kind: string; + before: string; + after: string; + }[] = []; + const printWarningCategories: Record< + string, + { count: number; exampleSeed: number } + > = {}; + const invalidMessages: Record< + string, + { count: number; exampleSeed: number } + > = {}; + const warningMessages: Record< + string, + { count: number; exampleSeed: number } + > = {}; + for (const r of rows) { + for (const w of r.warningMessages ?? []) { + const m = normalizeMessage(w); + warningMessages[m] ??= { count: 0, exampleSeed: r.seed }; + warningMessages[m].count++; + } + if (!r.valid) { + const m = normalizeMessage(r.errors![0]); + invalidMessages[m] ??= { count: 0, exampleSeed: r.seed }; + invalidMessages[m].count++; + continue; + } + const o = r.outcome!; + classes[o.cls]++; + seedsByClass[o.cls].push(r.seed); + if (o.importWarnings !== undefined) { + if (o.importWarnings === 0) side.importWarningsEmpty++; + else side.importWarningsNonEmpty++; + } + for (const c of o.importWarningCategories) { + importWarningCategories[c] ??= { count: 0, exampleSeed: r.seed }; + importWarningCategories[c].count++; + } + for (const m of o.importWarningMessages) { + importWarningMessages[m] ??= { count: 0, exampleSeed: r.seed }; + importWarningMessages[m].count++; + } + if (o.unstableLines) + unstable.push({ seed: r.seed, cls: o.cls, ...o.unstableLines }); + for (const c of o.printWarningCategories) { + printWarningCategories[c] ??= { count: 0, exampleSeed: r.seed }; + printWarningCategories[c].count++; + } + if (o.secondPassStable === undefined) side.secondPassNotReached++; + else if (o.secondPassStable) side.secondPassStable++; + else side.secondPassUnstable++; + if (o.layoutFallback) { + side.layoutFallback++; + layoutFallbackSeeds.push(r.seed); + } + if (o.cls === 'clean') { + if (o.comparison === 'canonical') cleanCanonical++; + else if (o.comparison === 'restructured') cleanRestructured++; + } + if (o.cls === 'clean' && o.secondPassStable === false) + cleanButUnstable.push(r.seed); + if (fails(o)) failingSeeds.push(r.seed); + } + + // ---- symptoms ---- + interface Symptom { + key: string; + cls: Cls; + stage: string; + message: string; + seeds: number[]; + minimized?: { + seed: number; + file: string; + statements: number; + before: number; + complete: boolean; + candidates: number; + ms: number; + }; + } + const symptoms = new Map(); + for (const r of valid) { + const o = r.outcome!; + // Every failing outcome gets a symptom entry, a warned or unstable + // clean seed included, so it is minimized and reported like any + // other failure. + if (!fails(o)) continue; + const s = symptoms.get(o.symptom) ?? { + key: o.symptom, + cls: o.cls, + stage: o.stage, + message: o.message, + seeds: [], + }; + s.seeds.push(r.seed); + symptoms.set(o.symptom, s); + } + const warned = new Map< + string, + { categories: string[]; seeds: number[]; example: string } + >(); + const warnedCategoryCounts: Record< + string, + { count: number; exampleSeed: number } + > = {}; + for (const r of valid) { + const o = r.outcome!; + if (o.cls !== 'warned-restructuring') continue; + const key = o.printWarningCategories.join(','); + const w = warned.get(key) ?? { + categories: o.printWarningCategories, + seeds: [], + example: o.message, + }; + w.seeds.push(r.seed); + warned.set(key, w); + for (const c of o.printWarningCategories) { + warnedCategoryCounts[c] ??= { count: 0, exampleSeed: r.seed }; + warnedCategoryCounts[c].count++; + } + } + + // ---- minimize one seed per symptom ---- + if (OUT !== undefined) { + const outDir = OUT; + let k = 0; + for (const s of symptoms.values()) { + k++; + // the smallest failing seed of the group is the cheapest to reduce + const seed = s.seeds + .map((sd) => ({ + sd, + n: rows.find((r) => r.seed === sd)!.statements!, + })) + .sort((a, b) => a.n - b.n || a.sd - b.sd)[0].sd; + const original = programs.get(seed)!; + const originalOutcome = rows.find((r) => r.seed === seed)!.outcome!; + const started = Date.now(); + let candidates = 0; + let complete = true; + const keep = async (candidate: Program): Promise => { + if (Date.now() - started > MIN_BUDGET_MS) { + complete = false; + return false; + } + candidates++; + const text = renderProgram(candidate); + const { errors, dispose } = await parseModel(text); + await dispose(); + if (errors.length > 0) return false; + const outcome = await runSeed(text, seed); + return sameFailure(originalOutcome, outcome); + }; + console.log( + `[fuzz] minimizing ${k}/${symptoms.size}: ${s.cls} seed ${seed} (${s.seeds.length} seeds)`, + ); + const reduced = await minimize(original, keep); + const text = renderProgram(reduced); + const outcome = await runSeed(text, seed); + const base = `${s.cls}-${seed}`; + writeFileSync(join(outDir, 'min', `${base}.bpmnscript`), text); + const symptomText = [ + `class: ${outcome.cls}`, + `stage: ${outcome.stage}`, + `symptom: ${s.key}`, + `seeds: ${s.seeds.join(', ')}`, + `minimized from seed ${seed}: ${countStatements(original)} -> ${countStatements(reduced)} statements, ${candidates} candidates, ${complete ? 'complete' : 'budget exhausted'}`, + '', + 'message:', + outcome.message, + ...(outcome.diff + ? ['', 'diff (normalized ir1 -> ir3):', ...outcome.diff] + : []), + ...(outcome.dsl1 !== undefined + ? ['', 'printed dsl1:', outcome.dsl1] + : []), + '', + ].join('\n'); + writeFileSync(join(outDir, 'min', `${base}.txt`), symptomText); + s.minimized = { + seed, + file: `min/${base}.bpmnscript`, + statements: countStatements(reduced), + before: countStatements(original), + complete, + candidates, + ms: Date.now() - started, + }; + } + } + + const summary = { + invocation: { + seed: SEED, + n: N, + shuffle: SHUFFLE ?? null, + minimize: OUT !== undefined, + timeoutMs: TIMEOUT_MS, + tree: gitHead(), + cwd: process.cwd(), + date: new Date().toISOString(), + totalMs: Date.now() - t0, + }, + generator: { + generated: rows.length, + valid: valid.length, + keepRate: valid.length / rows.length, + validWithWarnings: valid.filter((r) => (r.warnings ?? 0) > 0).length, + statementsMin: Math.min(...rows.map((r) => r.statements!)), + statementsMax: Math.max(...rows.map((r) => r.statements!)), + invalidMessages: Object.entries(invalidMessages) + .sort((a, b) => b[1].count - a[1].count) + .map(([message, v]) => ({ message, ...v })), + warningMessages: Object.entries(warningMessages) + .sort((a, b) => b[1].count - a[1].count) + .map(([message, v]) => ({ message, ...v })), + }, + classes, + cleanCanonical, + cleanRestructured, + failingSeeds, + seedsByClass, + side, + layoutFallbackSeeds, + cleanButUnstable, + importWarningCategories, + importWarningMessages, + secondPassUnstable: { + digitsOnly: unstable.filter((u) => u.kind === 'digits-only').length, + text: unstable.filter((u) => u.kind === 'text').length, + byClass: Object.fromEntries( + CLASS_ORDER.map((c) => [ + c, + unstable.filter((u) => u.cls === c).length, + ]), + ), + examples: unstable.slice(0, 12), + cleanSeeds: unstable.filter((u) => u.cls === 'clean'), + }, + printWarningCategories, + warnedCategoryCounts, + warnedRestructuring: [...warned.values()].sort( + (a, b) => b.seeds.length - a.seeds.length, + ), + symptoms: [...symptoms.values()].sort( + (a, b) => + CLASS_ORDER.indexOf(a.cls) - CLASS_ORDER.indexOf(b.cls) || + b.seeds.length - a.seeds.length, + ), + }; + if (OUT !== undefined) { + writeFileSync( + join(OUT, 'summary.json'), + JSON.stringify(summary, null, 2), + ); + } + console.log( + `[fuzz] done: ${JSON.stringify({ keepRate: summary.generator.keepRate, classes })}`, + ); + expect(rows.length).toBe(N); + expect(failingSeeds).toEqual([]); + }, + ); +}); diff --git a/tests/gateway-settings.round-trip.test.ts b/tests/gateway-settings.round-trip.test.ts index 8c5d88d..bc574da 100644 --- a/tests/gateway-settings.round-trip.test.ts +++ b/tests/gateway-settings.round-trip.test.ts @@ -14,6 +14,8 @@ import { bpmnDoc } from './helpers/bpmn-doc.js'; import { describeDiContainment } from './helpers/di-bounds.js'; import { allElements } from './helpers/ir-query.js'; import { describeImportFirst } from './helpers/import-first.js'; +import { roundTrip, validate } from './helpers/pipeline.js'; +import { modelSignature } from './helpers/model-equivalence.js'; const rt = roundTripFixture('gateway-settings', { dslPrimeFrom: 'frozen', @@ -217,4 +219,25 @@ describeImportFirst( }, ); +// The self-loop leaves no post-dominators, so only forward reachability sees +// the branch step running back into the join; jumped to from the branch, that +// step would leave the join's settings off the weighed route. +it('a branch step that loops back through a join with settings prints inside the branch, so every route passes the join', async () => { + const source = [ + 'process p {', + ' var c: any', + ' if (c) (joinAsyncBefore: true) {', + ' receive X', + ' }', + ' goto X', + '}', + '', + ].join('\n'); + const first = await roundTrip(source); + expect(first.dsl).toBe(source); + const { diagnostics } = await validate(first.dsl); + expect(diagnostics.filter((d) => d.severity === 1)).toEqual([]); + expect(modelSignature(first.ir3)).toEqual(modelSignature(first.ir1)); +}); + describeDiContainment(rt, ['CollectReferences']); diff --git a/tests/goto-fallback.round-trip.test.ts b/tests/goto-fallback.round-trip.test.ts index 936fcb1..effbe53 100644 --- a/tests/goto-fallback.round-trip.test.ts +++ b/tests/goto-fallback.round-trip.test.ts @@ -245,8 +245,8 @@ const SURPLUS_EDGE_ON_TASK: BpmnProcess = { describe('surplus out-edge on a plain node keeps the fall-through', () => { // A bare `goto` beside the fall-through would end the chain and leave // everything after it unreachable, so both routes head a branch. The back - // edge names `T1`, the one step the join leads to, and the marker records - // that the fork closes on no merge. + // edge names `T1`, the one step the join leads to, and each route ends in + // its own branch, so the fork needs no merge to print as a block. it('gives both routes a branch rather than a jump beside the fall-through, and keeps the rest reachable', async () => { const { imported, dsl } = await emit(SURPLUS_EDGE_ON_TASK); expect(dsl).toBe( @@ -254,15 +254,16 @@ describe('surplus out-edge on a plain node keeps the fall-through', () => { 'process SurplusEdgeOnTask {', ' start Start_1', ' user T1', - ` ${UNSTRUCTURED_MARKER} (split T1 degraded to jumps; was parallel)`, - ' if (true) {', - ' goto Cont', - ' } else {', - ' goto T1', + ' parallel {', + ' {', + ' user Cont', + ' user Later', + ' end End_1', + ' }', + ' {', + ' goto T1', + ' }', ' }', - ' user Cont', - ' user Later', - ' end End_1', '}', '', ].join('\n'), diff --git a/tests/helpers/fuzz-generator.ts b/tests/helpers/fuzz-generator.ts new file mode 100644 index 0000000..01b6d64 --- /dev/null +++ b/tests/helpers/fuzz-generator.ts @@ -0,0 +1,2402 @@ +// Grammar-driven program generator for the round-trip fuzz harness, plus the +// printer and the delta-debugging reducer over the same program model. +// +// The word lists come from the language package so a construct the language +// grows or drops changes the generator with it. Every generated program is a +// candidate only: the harness validates it and keeps the validator-clean ones, +// so the rules encoded below are the generator's best effort at staying inside +// the validator, not a second copy of the validator. + +import { + DECISION_RESULT_MAPPINGS, + EMIT_TRIGGERS, + ENGINE_KEYS, + EXECUTION_LISTENER_EVENTS, + FORM_FIELD_TYPES, + JUEL_RESERVED_WORDS, + LISTENER_BINDING_KEYS, + RACE_TRIGGERS, + SCRIPT_FORMAT_ALIASES, + SERVICE_TASK_BINDING_KEYS, + START_TRIGGERS, + TASK_LISTENER_EVENTS, + THROW_TRIGGERS, + TIMER_PARTICLE_BY_KIND, + TYPE_BINDING_VALUES, +} from '@bpmn-script/language'; + +const JUEL_WORDS: readonly string[] = JUEL_RESERVED_WORDS; + +// --------------------------------------------------------------------------- +// Seeded randomness +// --------------------------------------------------------------------------- + +export class Rng { + private state: number; + constructor(seed: number) { + // Seed folding is inherently bitwise. + // eslint-disable-next-line no-bitwise + this.state = seed >>> 0 || 1; + } + next(): number { + // mulberry32's 32-bit state mixing is inherently bitwise. + /* eslint-disable no-bitwise */ + let t = (this.state += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + /* eslint-enable no-bitwise */ + } + int(min: number, max: number): number { + return min + Math.floor(this.next() * (max - min + 1)); + } + chance(p: number): boolean { + return this.next() < p; + } + pick(items: readonly T[]): T { + return items[Math.floor(this.next() * items.length)]; + } +} + +// --------------------------------------------------------------------------- +// Program model +// --------------------------------------------------------------------------- + +export type ExprType = 'boolean' | 'number' | 'string' | 'unknown'; + +export type Expr = + | { t: 'leaf'; text: string; type: ExprType } + | { t: 'bin'; op: string; l: Expr; r: Expr; type: ExprType } + | { t: 'un'; op: string; e: Expr; type: ExprType } + | { t: 'paren'; e: Expr; type: ExprType } + | { t: 'tern'; c: Expr; a: Expr; b: Expr; type: ExprType }; + +export interface Item { + key?: string; + value: string; + flag?: true; +} + +export interface FormFieldSpec { + id: string; + type: string; + label?: string; + defaultValue?: string; + items: Item[]; + values: { id: string; label?: string }[]; + props: { name: string; value: string }[]; +} + +export type Member = + | { m: 'io'; dir: string; name: string; value: string } + | { + m: 'listener'; + event: string; + timer?: { particle: string; time: string }; + items: Item[]; + script?: string; + fields: { name: string; value: string }[]; + } + | { m: 'form'; fields: FormFieldSpec[] } + | { + m: 'mapping'; + dir: 'in' | 'out'; + local?: true; + all?: true; + target?: string; + source?: string; + } + | { m: 'errorMapping'; code: string; condition: Expr }; + +export interface Repeat { + cardinality?: string; + element?: string; + collection?: string; + sequential?: true; + until?: Expr; +} + +export type Stmt = + | { + k: 'start'; + name: string; + trigger?: string; + items: Item[]; + members: Member[]; + } + | { + k: 'end'; + name: string; + trigger?: string; + items: Item[]; + members: Member[]; + } + | { + k: 'task'; + kw: + 'user' | 'service' | 'step' | 'send' | 'receive' | 'decide' | 'script'; + name: string; + repeat?: Repeat; + items: Item[]; + members: Member[]; + script?: string; + } + | { + k: 'call'; + name: string; + repeat?: Repeat; + items: Item[]; + members: Member[]; + } + | { + k: 'sub'; + kw: 'subprocess' | 'attempt'; + name: string; + repeat?: Repeat; + items: Item[]; + members: Member[]; + body: Stmt[]; + } + | { + k: 'if'; + cond: Expr; + items: Item[]; + then: Stmt[]; + elseIfs: { cond: Expr; body: Stmt[] }[]; + else?: Stmt[]; + } + | { k: 'while'; cond: Expr; items: Item[]; body: Stmt[] } + | { k: 'do'; cond: Expr; items: Item[]; body: Stmt[] } + | { + k: 'parallel'; + items: Item[]; + branches: { cond?: Expr; otherwise?: true; body: Stmt[] }[]; + } + | { + k: 'await'; + trigger: string; + name?: string; + items: Item[]; + members: Member[]; + } + | { + k: 'race'; + items: Item[]; + branches: { + trigger: string; + items: Item[]; + members: Member[]; + body: Stmt[]; + }[]; + } + | { + k: 'throw' | 'emit'; + trigger: string; + name?: string; + items: Item[]; + members: Member[]; + } + | { k: 'goto'; target: string } + | { + k: 'on'; + host?: string; + trigger: string; + items: Item[]; + members: Member[]; + body: Stmt[]; + }; + +export interface CodeDecl { + kind: 'error' | 'escalation'; + name: string; + items: Item[]; +} + +export interface Program { + name: string; + header: Item[]; + vars: { name: string; type: string }[]; + codes: CodeDecl[]; + body: Stmt[]; +} + +// --------------------------------------------------------------------------- +// Printer +// --------------------------------------------------------------------------- + +// A ternary arm and a binary operand are one precedence level down in the +// grammar, so a ternary nested there is parenthesized; everything else is +// printed as built. +export function renderExpr(e: Expr): string { + const operand = (x: Expr): string => + x.t === 'tern' ? `(${renderExpr(x)})` : renderExpr(x); + switch (e.t) { + case 'leaf': + return e.text; + case 'bin': + return `${operand(e.l)} ${e.op} ${operand(e.r)}`; + case 'un': + return `${e.op}${e.e.t === 'bin' || e.e.t === 'tern' ? `(${renderExpr(e.e)})` : renderExpr(e.e)}`; + case 'paren': + return `(${renderExpr(e.e)})`; + case 'tern': + return `${operand(e.c)} ? ${operand(e.a)} : ${operand(e.b)}`; + } +} + +function renderItems(items: Item[]): string { + if (items.length === 0) return ''; + return ( + '(' + + items + .map((it) => + it.flag ? it.value : it.key ? `${it.key}: ${it.value}` : it.value, + ) + .join(', ') + + ')' + ); +} + +function indent(lines: string[], depth: number): string[] { + const pad = ' '.repeat(depth); + return lines.map((l) => (l === '' ? l : pad + l)); +} + +function renderFence(script: string): string[] { + // A fenced script keeps its own newlines; the tag line and the closing fence + // are part of the token. + return script.split('\n'); +} + +function renderMembers(members: Member[]): string[] { + const out: string[] = []; + for (const m of members) { + switch (m.m) { + case 'io': { + const valueLines = m.value.split('\n'); + out.push( + `${m.dir} ${m.name} = ${valueLines[0]}`, + ...valueLines.slice(1), + ); + break; + } + case 'listener': { + let head = `on ${m.event}`; + if (m.timer) head += ` ${m.timer.particle} ${m.timer.time}`; + head += renderItems(m.items); + if (m.script) { + const fence = renderFence(m.script); + out.push(`${head} ${fence[0]}`, ...fence.slice(1)); + } else if (m.fields.length > 0) { + out.push(`${head} {`); + out.push( + ...indent( + m.fields.map((f) => `field ${f.name} = ${f.value}`), + 1, + ), + ); + out.push('}'); + } else { + out.push(head); + } + break; + } + case 'form': { + out.push('form {'); + for (const f of m.fields) { + let line = `${f.id}: ${f.type}`; + if (f.label !== undefined) line += ` ${f.label}`; + if (f.defaultValue !== undefined) line += ` = ${f.defaultValue}`; + line += renderItems(f.items); + if (f.values.length > 0 || f.props.length > 0) { + out.push(` ${line} {`); + for (const v of f.values) { + out.push( + ` ${v.id}${v.label !== undefined ? ' ' + v.label : ''}`, + ); + } + for (const p of f.props) { + out.push(` property ${p.name} = ${p.value}`); + } + out.push(' }'); + } else { + out.push(` ${line}`); + } + } + out.push('}'); + break; + } + case 'mapping': { + let line = m.dir; + if (m.local) line += ' local'; + if (m.all) line += ' *'; + else { + line += ` ${m.target}`; + if (m.source !== undefined) line += ` = ${m.source}`; + } + out.push(line); + break; + } + case 'errorMapping': + out.push(`error ${m.code} when ${renderExpr(m.condition)}`); + break; + } + } + return out; +} + +function renderRepeat(r: Repeat | undefined): string { + if (!r) return ''; + let s = ' for'; + if (r.cardinality !== undefined) s += ` ${r.cardinality}`; + if (r.collection !== undefined) { + s += ` each${r.element ? ' ' + r.element : ''} in ${r.collection}`; + } + if (r.sequential) s += ' sequentially'; + if (r.until) s += ` until (${renderExpr(r.until)})`; + return s; +} + +function renderBlock(body: Stmt[]): string[] { + return ['{', ...indent(renderStmts(body), 1), '}']; +} + +function withBlock(head: string, lines: string[]): string[] { + // `head {` on one line, the rest indented, closing brace alone. + return [`${head} ${lines[0]}`, ...lines.slice(1)]; +} + +function renderMemberBlock(members: Member[]): string[] | undefined { + if (members.length === 0) return undefined; + return ['{', ...indent(renderMembers(members), 1), '}']; +} + +export function renderStmt(s: Stmt): string[] { + switch (s.k) { + case 'start': + case 'end': { + let head = `${s.k} ${s.name}`; + if (s.trigger) head += ` ${s.trigger}`; + head += renderItems(s.items); + const mb = renderMemberBlock(s.members); + return mb ? withBlock(head, mb) : [head]; + } + case 'task': { + const head = `${s.kw} ${s.name}${renderRepeat(s.repeat)}${renderItems(s.items)}`; + const mb = renderMemberBlock(s.members); + const lines = mb ? withBlock(head, mb) : [head]; + if (s.script !== undefined) { + const fence = renderFence(s.script); + const last = lines.length - 1; + lines[last] = `${lines[last]} ${fence[0]}`; + lines.push(...fence.slice(1)); + } + return lines; + } + case 'call': { + const head = `call ${s.name}${renderRepeat(s.repeat)}${renderItems(s.items)}`; + const mb = renderMemberBlock(s.members); + return mb ? withBlock(head, mb) : [head]; + } + case 'sub': { + const head = `${s.kw} ${s.name}${renderRepeat(s.repeat)}${renderItems(s.items)}`; + const mb = renderMemberBlock(s.members); + const headLines = mb ? withBlock(head, mb) : [head]; + const last = headLines.length - 1; + const block = renderBlock(s.body); + headLines[last] = `${headLines[last]} ${block[0]}`; + return [...headLines, ...block.slice(1)]; + } + case 'if': { + const lines = withBlock( + `if (${renderExpr(s.cond)})${s.items.length ? ' ' + renderItems(s.items) : ''}`, + renderBlock(s.then), + ); + for (const ei of s.elseIfs) { + const b = renderBlock(ei.body); + lines[lines.length - 1] = `} else if (${renderExpr(ei.cond)}) ${b[0]}`; + lines.push(...b.slice(1)); + } + if (s.else) { + const b = renderBlock(s.else); + lines[lines.length - 1] = `} else ${b[0]}`; + lines.push(...b.slice(1)); + } + return lines; + } + case 'while': + return withBlock( + `while (${renderExpr(s.cond)})${s.items.length ? ' ' + renderItems(s.items) : ''}`, + renderBlock(s.body), + ); + case 'do': { + const lines = withBlock('do', renderBlock(s.body)); + lines[lines.length - 1] = + `} while (${renderExpr(s.cond)})${s.items.length ? ' ' + renderItems(s.items) : ''}`; + return lines; + } + case 'parallel': { + const out = [ + `parallel${s.items.length ? ' ' + renderItems(s.items) : ''} {`, + ]; + for (const b of s.branches) { + const head = b.otherwise + ? 'else ' + : b.cond + ? `if (${renderExpr(b.cond)}) ` + : ''; + const block = renderBlock(b.body); + out.push(...indent([`${head}${block[0]}`, ...block.slice(1)], 1)); + } + out.push('}'); + return out; + } + case 'await': { + const head = `await ${s.trigger}${s.name ? ' ' + s.name : ''}${renderItems(s.items)}`; + const mb = renderMemberBlock(s.members); + return mb ? withBlock(head, mb) : [head]; + } + case 'race': { + const out = [ + `await${s.items.length ? ' ' + renderItems(s.items) : ''} {`, + ]; + for (const b of s.branches) { + const head = `${b.trigger}${renderItems(b.items)}`; + const mb = renderMemberBlock(b.members); + const headLines = mb ? withBlock(head, mb) : [head]; + const block = renderBlock(b.body); + headLines[headLines.length - 1] += ` ${block[0]}`; + out.push(...indent([...headLines, ...block.slice(1)], 1)); + } + out.push('}'); + return out; + } + case 'throw': + case 'emit': { + const head = `${s.k} ${s.trigger}${s.name ? ' ' + s.name : ''}${renderItems(s.items)}`; + const mb = renderMemberBlock(s.members); + return mb ? withBlock(head, mb) : [head]; + } + case 'goto': + return [`goto ${s.target}`]; + case 'on': { + const head = `on ${s.host ? s.host + ': ' : ''}${s.trigger}${renderItems(s.items)}`; + const mb = renderMemberBlock(s.members); + const headLines = mb ? withBlock(head, mb) : [head]; + const block = renderBlock(s.body); + headLines[headLines.length - 1] += ` ${block[0]}`; + return [...headLines, ...block.slice(1)]; + } + } +} + +function renderStmts(stmts: Stmt[]): string[] { + const out: string[] = []; + for (const s of stmts) out.push(...renderStmt(s)); + return out; +} + +export function renderProgram(p: Program): string { + const out: string[] = []; + const header = p.header.length ? renderItems(p.header) : ''; + out.push(`process ${p.name}${header} {`); + for (const v of p.vars) out.push(` var ${v.name}: ${v.type}`); + for (const c of p.codes) + out.push(` ${c.kind} ${c.name}${renderItems(c.items)}`); + if (p.vars.length + p.codes.length > 0) out.push(''); + out.push(...indent(renderStmts(p.body), 1)); + out.push('}', ''); + return out.join('\n'); +} + +// --------------------------------------------------------------------------- +// Generator +// --------------------------------------------------------------------------- + +export const PROCESS_NAMES = [ + 'order-fulfilment', + 'claim-review', + 'onboarding', + 'invoice_batch', +] as const; + +type VarType = 'string' | 'number' | 'boolean' | 'date' | 'json' | 'any'; + +interface Variable { + name: string; + type: VarType; +} + +const VAR_POOL: readonly Variable[] = [ + { name: 'amount', type: 'number' }, + { name: 'retries', type: 'number' }, + { name: 'score', type: 'number' }, + { name: 'status', type: 'string' }, + { name: 'region', type: 'string' }, + { name: 'approved', type: 'boolean' }, + { name: 'urgent', type: 'boolean' }, + { name: 'due', type: 'date' }, + { name: 'order', type: 'json' }, + { name: 'items', type: 'any' }, + { name: 'lines', type: 'any' }, +]; + +const STEP_WORDS = [ + 'Review', + 'Pack', + 'Ship', + 'Notify', + 'Check', + 'Approve', + 'Archive', + 'Charge', + 'Fetch', + 'Audit', + 'Rate', + 'Hold', + 'Release', + 'Escalate', + 'Record', +]; +const HYPHEN_WORDS = ['check-stock', 'send-mail', 'close-case', 'pay-out']; + +const MESSAGE_NAMES = [ + 'OrderReceived', + 'PaymentDone', + 'Cancelled', + 'Quote Received', +]; +const SIGNAL_NAMES = ['StockLow', 'Ready', 'Shutdown']; +const LINK_NAMES = ['Rework', 'Retry', 'Skip']; +const CLASS_NAMES = [ + 'com.example.Delegate', + 'com.example.orders.Ship', + 'org.acme.Audit', +]; +const DURATIONS = ['PT1H', 'P3D', 'PT30M', 'PT2H30M']; +const DATES = ['2026-08-01T09:00:00', '2027-01-01T00:00:00']; +const CYCLES = ['R3/PT10M', 'R/PT1H', '0 0 9 * * ?']; +const RETRY = ['R3/PT10M', 'R5/PT5M']; + +interface Scope { + // the container (process, subprocess, attempt, host-less handler) this block belongs to + container: 'process' | 'subprocess' | 'attempt' | 'handler'; + // names of statements in this container (goto targets) + targets: string[]; + // names of activity statements in this container usable as handler hosts, with kind + hosts: { name: string; kind: string }[]; + // statements at this container level that end a chain (for `await link` placement) + linkCatchesWanted: string[]; // link names emitted in this container without a catch yet + usedHandlers: Set; // trigger|code per subscription scope + // nesting depth of blocks + depth: number; + // whether we are inside a parallel/race branch (goto/link into it refused) + inBranch: boolean; + // whether a hosted handler body: no start allowed, statements shared with host container + hostedBody: boolean; +} + +export class Generator { + private readonly rng: Rng; + private names = new Set(); + private labels = new Set(); + private counter = 0; + private vars = new Map(); + private codes: CodeDecl[] = []; + private linkCatches = new Set(); + private messageStarts = new Set(); + private plainStartUsed = false; + private statementBudget = 0; + private stmtCount = 0; + private formFieldIds = new Map(); + + constructor(seed: number) { + this.rng = new Rng(seed); + } + + generate(): Program { + const r = this.rng; + this.statementBudget = r.int(5, 20); + const name = r.pick(PROCESS_NAMES); + this.names.add(name); + const header = this.header(); + this.codes = []; + if (r.chance(0.6)) { + this.codes.push({ + kind: 'error', + name: 'OUT_OF_STOCK', + items: r.chance(0.5) + ? [{ key: 'message', value: '"Out of stock"' }] + : [], + }); + } + if (r.chance(0.4)) + this.codes.push({ kind: 'escalation', name: 'NEEDS_REVIEW', items: [] }); + if (r.chance(0.25)) { + this.codes.push({ + kind: 'error', + name: 'DECLINED', + items: [{ key: 'code', value: '"card-declined"' }], + }); + } + this.names.add('OUT_OF_STOCK'); + this.names.add('NEEDS_REVIEW'); + this.names.add('DECLINED'); + + const scope: Scope = { + container: 'process', + targets: [], + hosts: [], + linkCatchesWanted: [], + usedHandlers: new Set(), + depth: 0, + inBranch: false, + hostedBody: false, + }; + const body: Stmt[] = []; + // starts + if (r.chance(0.8)) { + body.push(this.startEvent(true)); + if (r.chance(0.25)) body.push(this.startEvent(false)); + } + body.push(...this.chain(scope, true)); + this.flushLinks(scope, body); + body.push(...this.handlers(scope, body)); + + const vars = [...this.vars.values()].map((v) => ({ + name: v.name, + type: v.type, + })); + return { name, header, vars, codes: this.codes, body }; + } + + // ----- names ----- + + private fresh(hint?: string): string { + const r = this.rng; + for (;;) { + const word = + hint ?? (r.chance(0.08) ? r.pick(HYPHEN_WORDS) : r.pick(STEP_WORDS)); + const n = ++this.counter; + const name = + r.chance(0.3) && !this.names.has(word) ? word : `${word}${n}`; + if (!this.names.has(name)) { + this.names.add(name); + return name; + } + } + } + + private label(): Item[] { + const r = this.rng; + if (!r.chance(0.3)) return []; + const text = `"${r.pick(['Review the order', 'Pack it', 'Ship the parcel', 'Say \\"hi\\"', 'Line\\nbreak', 'Tab\\there'])} ${++this.counter}"`; + if (this.labels.has(text)) return []; + this.labels.add(text); + return [{ key: 'label', value: text }]; + } + + private documentation(): Item[] { + return this.rng.chance(0.15) + ? [ + { + key: 'documentation', + value: `"Escalate above ${this.rng.int(1, 9)}000."`, + }, + ] + : []; + } + + // ----- variables and expressions ----- + + private variable(type?: VarType): Variable { + const r = this.rng; + const pool = type ? VAR_POOL.filter((v) => v.type === type) : VAR_POOL; + const v = r.pick(pool); + this.vars.set(v.name, v); + return v; + } + + private leaf(text: string, type: ExprType): Expr { + return { t: 'leaf', text, type }; + } + + private rawTemplate(type: ExprType): Expr { + const r = this.rng; + const v = this.variable('number'); + const s = this.variable('string'); + const j = this.variable('json'); + const choices: [string, ExprType][] = [ + [`"\${${v.name} > ${r.int(1, 9)}}"`, 'boolean'], + [`"\${${s.name} == \\"a\\"}"`, 'boolean'], + [`"\${${s.name} == \\"a\\\\\\\\b\\"}"`, 'boolean'], + [`"#{${v.name} + 1}"`, 'number'], + [`"\${${j.name}['${r.pick(JUEL_WORDS)}']}"`, 'unknown'], + [`"\${fn(\\"a\\")}"`, 'unknown'], + [`'\${${v.name} * 2}'`, 'number'], + ]; + const fitting = choices.filter(([, t]) => t === type || t === 'unknown'); + const [text, t] = r.pick(fitting.length ? fitting : choices); + return this.leaf(text, t === 'unknown' ? type : t); + } + + numExpr(depth = 0): Expr { + const r = this.rng; + if (depth > 1 || r.chance(0.4)) { + const c = r.int(0, 5); + if (c === 0) return this.leaf(String(r.int(0, 1000)), 'number'); + if (c === 1) return this.leaf(`${r.int(0, 9)}.${r.int(0, 99)}`, 'number'); + if (c === 2) + return this.leaf(`${this.variable('json').name}.total`, 'number'); + if (c === 3 && depth > 0) return this.rawTemplate('number'); + return this.leaf(this.variable('number').name, 'number'); + } + const c = r.int(0, 3); + if (c === 0) + return { t: 'un', op: '-', e: this.numExpr(depth + 1), type: 'number' }; + if (c === 1) + return { t: 'paren', e: this.numExpr(depth + 1), type: 'number' }; + return { + t: 'bin', + op: r.pick(['+', '-', '*', '/', '%']), + l: this.numExpr(depth + 1), + r: this.numExpr(depth + 1), + type: 'number', + }; + } + + strExpr(): Expr { + const r = this.rng; + const c = r.int(0, 3); + if (c === 0) + return this.leaf( + `"${r.pick(['open', 'eu', 'x\\"y', 'a\\\\b'])}"`, + 'string', + ); + if (c === 1) + return this.leaf(`${this.variable('json').name}.name`, 'string'); + if (c === 2) return this.leaf(`${this.variable('any').name}[0]`, 'string'); + return this.leaf(this.variable('string').name, 'string'); + } + + boolExpr(depth = 0): Expr { + const r = this.rng; + if (depth > 1 || r.chance(0.3)) { + const c = r.int(0, 4); + if (c === 0) return this.leaf(r.pick(['true', 'false']), 'boolean'); + if (c === 1) return this.rawTemplate('boolean'); + if (c === 2) + return this.leaf(`${this.variable('json').name}.paid`, 'boolean'); + if (c === 3) + return this.leaf( + `${this.variable('json').name}['${r.pick(JUEL_WORDS)}']`, + 'boolean', + ); + return this.leaf(this.variable('boolean').name, 'boolean'); + } + const c = r.int(0, 7); + if (c === 0) + return { t: 'un', op: '!', e: this.boolExpr(depth + 1), type: 'boolean' }; + if (c === 1) + return { t: 'paren', e: this.boolExpr(depth + 1), type: 'boolean' }; + if (c === 2) + return { + t: 'bin', + op: r.pick(['&&', '||']), + l: this.boolExpr(depth + 1), + r: this.boolExpr(depth + 1), + type: 'boolean', + }; + if (c === 3) + return { + t: 'bin', + op: r.pick(['==', '!=']), + l: this.strExpr(), + r: this.strExpr(), + type: 'boolean', + }; + if (c === 4) + return { + t: 'bin', + op: '==', + l: this.leaf(this.variable('json').name, 'unknown'), + r: this.leaf('null', 'unknown'), + type: 'boolean', + }; + if (c === 5) { + // A ternary arm is a LogicalOr in the grammar, so a nested ternary needs parens. + return { + t: 'tern', + c: this.boolExpr(depth + 1), + a: this.boolExpr(depth + 1), + b: this.boolExpr(depth + 1), + type: 'boolean', + }; + } + return { + t: 'bin', + op: r.pick(['<', '<=', '>', '>=', '==', '!=']), + l: this.numExpr(depth + 1), + r: this.numExpr(depth + 1), + type: 'boolean', + }; + } + + // A value in one of the four forms: quoted string, bare variable, template, integer. + private valueForm(allowInt = true, allowBare = true): string { + const r = this.rng; + const c = r.int(0, allowInt ? 3 : 2); + if (c === 0) + return `"${r.pick(['sepa', 'east-coast', 'say \\"hi\\"', 'a\\\\b', 'two\\nlines'])}"`; + if (c === 1) + return allowBare ? this.variable().name : `"${r.pick(['x', 'y'])}"`; + if (c === 2) return renderExpr(this.rawTemplate('unknown')); + return String(r.int(0, 99)); + } + + private ioValue(depth = 0): string { + const r = this.rng; + const c = r.int(0, depth > 0 ? 3 : 5); + if (c === 0) return this.valueForm(); + if (c === 1) return renderExpr(this.boolExpr(1)); + if (c === 2) return renderExpr(this.numExpr(1)); + if (c === 3) return this.strLiteral(); + if (c === 4) { + const n = r.int(0, 3); + const items: string[] = []; + for (let i = 0; i < n; i++) items.push(this.ioValue(depth + 1)); + return `[${items.join(', ')}]`; + } + const n = r.int(0, 3); + const entries: string[] = []; + for (let i = 0; i < n; i++) { + const key = r.chance(0.3) + ? `"${r.pick(['other key', 'x-y', 'a\\"b'])}"` + : r.pick(['street', 'city', 'zip', 'mod']); + entries.push(`${key}: ${this.ioValue(depth + 1)}`); + } + return `{ ${entries.join(', ')} }`; + } + + private strLiteral(): string { + return `"${this.rng.pick(['open', 'eu', 'x\\"y', 'a\\\\b', 'line\\nbreak', 'tab\\t'])}"`; + } + + private fence(): string { + const r = this.rng; + const tag = r.pick(Object.keys(SCRIPT_FORMAT_ALIASES)); + const body = r.pick([ + 'execution.setVariable("x", 1)', + 'if (a) { b } else { c }', + 'def s = "quote \\" inside"\nlog.info(s)', + '${amount > 1}', + ]); + return `\`\`\`${tag}\n${body}\n\`\`\``; + } + + // ----- settings ----- + + private engineSettings( + opts: { + join?: boolean; + run?: boolean; + noAsyncAfter?: boolean; + timer?: boolean; + } = {}, + ): Item[] { + const r = this.rng; + const items: Item[] = []; + const prefixes: string[] = ['']; + if (opts.join) prefixes.push('join'); + if (opts.run) prefixes.push('run'); + for (const prefix of prefixes) { + if (!r.chance(prefix === '' ? 0.35 : 0.2)) continue; + const spell = (key: string): string => + prefix === '' ? key : prefix + key[0].toUpperCase() + key.slice(1); + const asyncKeys = ENGINE_KEYS.filter( + (k) => + k.startsWith('async') && + !(opts.noAsyncAfter && k === 'asyncAfter' && prefix === ''), + ); + const others = ENGINE_KEYS.filter( + (k) => + !k.startsWith('async') && !(prefix === 'run' && k === 'jobPriority'), + ); + const withAsync = r.chance(0.8) || opts.timer; + if (withAsync && !opts.timer) + items.push({ key: spell(r.pick(asyncKeys)), value: 'true' }); + if (r.chance(0.5)) { + const key = r.pick(others); + const value = + key === 'exclusive' + ? r.pick(['true', 'false']) + : key === 'jobPriority' + ? String(r.int(0, 100)) + : `"${r.pick(RETRY)}"`; + items.push({ key: spell(key), value }); + } + } + return items; + } + + private header(): Item[] { + const r = this.rng; + const items: Item[] = []; + if (r.chance(0.3)) + items.push({ key: 'label', value: '"Motor claim settlement"' }); + if (r.chance(0.2)) + items.push({ key: 'documentation', value: '"Handles a \\"claim\\"."' }); + if (r.chance(0.3)) + items.push({ key: 'versionTag', value: `"${r.int(1, 9)}.0"` }); + if (r.chance(0.3)) + items.push({ + key: 'historyTimeToLive', + value: r.pick(['"P90D"', '"30"', '"180"']), + }); + if (r.chance(0.2)) + items.push({ key: 'candidateStarterUsers', value: '"demo,manager"' }); + if (r.chance(0.2)) + items.push({ key: 'candidateStarterGroups', value: '"adjusters"' }); + return items; + } + + // ----- members ----- + + private ioParams(dirs: readonly string[], allowOutput: boolean): Member[] { + const r = this.rng; + const out: Member[] = []; + const used = new Map>(); + const n = r.int(0, 3); + for (let i = 0; i < n; i++) { + let dir = r.pick(dirs); + if (dir === 'output' && !allowOutput) dir = 'input'; + const name = r.pick([ + 'address', + 'labels', + 'tracking', + 'team', + 'note', + 'hint', + ]); + const set = used.get(dir) ?? new Set(); + if (set.has(name)) continue; + set.add(name); + used.set(dir, set); + const value = + dir === 'field' || dir === 'property' + ? r.chance(0.7) + ? this.strLiteral() + : renderExpr(this.rawTemplate('unknown')) + : r.chance(0.15) + ? this.fence() + : this.ioValue(); + out.push({ m: 'io', dir, name, value }); + } + return out; + } + + private listeners(taskListeners: boolean): Member[] { + const r = this.rng; + const out: Member[] = []; + if (!r.chance(0.35)) return out; + const events = taskListeners + ? [...EXECUTION_LISTENER_EVENTS, ...TASK_LISTENER_EVENTS] + : [...EXECUTION_LISTENER_EVENTS]; + const used = new Set(); + const n = r.int(1, 2); + for (let i = 0; i < n; i++) { + const event = r.pick(events); + if (used.has(event)) continue; + used.add(event); + const m: Member = { m: 'listener', event, items: [], fields: [] }; + if (event === 'timeout') { + const [particle, time] = this.timerClause(); + m.timer = { particle, time }; + } + if (r.chance(0.3)) { + m.script = this.fence(); + } else { + const key = r.pick(LISTENER_BINDING_KEYS); + m.items.push({ key, value: this.bindingValue(key) }); + if (key !== 'expression' && r.chance(0.4)) { + m.fields.push({ + name: 'auditTag', + value: r.chance(0.5) + ? '"claim-intake"' + : `"\${${this.variable('string').name}}"`, + }); + } + } + out.push(m); + } + return out; + } + + private timerClause(): [string, string] { + const r = this.rng; + const kind = r.pick(['duration', 'date', 'cycle'] as const); + const particle = TIMER_PARTICLE_BY_KIND[kind]; + const time = + kind === 'duration' + ? r.pick(DURATIONS) + : kind === 'date' + ? r.pick(DATES) + : r.pick(CYCLES); + return [ + particle, + r.chance(0.15) ? `"\${${this.variable('string').name}}"` : `"${time}"`, + ]; + } + + private bindingValue(key: string): string { + const r = this.rng; + switch (key) { + case 'class': + return `"${r.pick(CLASS_NAMES)}"`; + case 'expression': + return r.chance(0.5) + ? `"\${${r.pick(['svc', 'bean'])}.run(execution)}"` + : r.chance(0.5) + ? this.variable('json').name + : `"#{${this.variable('json').name}.go()}"`; + case 'delegate': + return r.chance(0.6) + ? `"\${${r.pick(['shipDelegate', 'auditBean'])}}"` + : this.variable('json').name; + case 'topic': + return `"${r.pick(['shipping', 'charge-card', 'audit'])}"`; + case 'type': + return `"${r.pick(TYPE_BINDING_VALUES)}"`; + default: + return '"x"'; + } + } + + private form(): Member { + const r = this.rng; + const fields: FormFieldSpec[] = []; + const n = r.int(1, 4); + const used = new Set(); + for (let i = 0; i < n; i++) { + let type: string = r.pick(FORM_FIELD_TYPES); + const id = r.pick([ + 'fullName', + 'birthDate', + 'plan', + 'newsletter', + 'qty', + 'iban', + 'amount', + 'approved', + ]); + if (used.has(id)) continue; + // a form field with a `var`'s name must agree with its type + const pooled = VAR_POOL.find((v) => v.name === id); + if (pooled) { + if (!(FORM_FIELD_TYPES as readonly string[]).includes(pooled.type)) + continue; + type = pooled.type; + } + const earlier = this.formFieldIds.get(id); + if (earlier !== undefined && earlier !== type) continue; + this.formFieldIds.set(id, type); + used.add(id); + const f: FormFieldSpec = { id, type, items: [], values: [], props: [] }; + if (r.chance(0.6)) + f.label = `"${r.pick(['Full name', 'Amount in \\"euros\\"', 'Plan'])}"`; + if (type === 'enum') { + f.values.push({ + id: 'basic', + label: r.chance(0.5) ? '"Basic"' : undefined, + }); + f.values.push({ id: 'plus' }); + if (r.chance(0.4)) f.defaultValue = '"basic"'; + } else if (r.chance(0.4)) { + f.defaultValue = + type === 'string' + ? '"n/a"' + : type === 'number' + ? String(r.int(0, 9)) + : type === 'boolean' + ? r.pick(['true', 'false']) + : `"\${${this.variable('date').name}}"`; + } + if (r.chance(0.5)) + f.items.push({ key: r.pick(['required', 'readonly']), value: 'true' }); + if (type === 'number' && r.chance(0.5)) + f.items.push({ + key: r.pick(['min', 'max']), + value: r.pick(['0', '"-5"', '5000']), + }); + if (type === 'string' && r.chance(0.5)) + f.items.push({ + key: r.pick(['minlength', 'maxlength']), + value: String(r.int(1, 80)), + }); + if (type === 'date' && r.chance(0.6)) + f.items.push({ key: 'pattern', value: '"dd/MM/yyyy"' }); + if (r.chance(0.2)) + f.items.push({ + key: 'validator', + value: r.chance(0.5) + ? '"com.example.IbanValidator"' + : '"${validator.ok(fullName)}"', + }); + if (r.chance(0.25)) + f.props.push({ + name: 'placeholder', + value: r.chance(0.7) ? '"Filled in by accounting"' : '"${hint}"', + }); + fields.push(f); + } + if (fields.length === 0) + fields.push({ + id: 'note', + type: 'string', + items: [], + values: [], + props: [], + }); + return { m: 'form', fields }; + } + + private repeat(): Repeat | undefined { + const r = this.rng; + if (!r.chance(0.2)) return undefined; + const rep: Repeat = {}; + const c = r.int(0, 3); + if (c === 0 || c === 3) + rep.cardinality = r.pick([ + '3', + '"2"', + this.variable('number').name, + '"${order.size}"', + ]); + if (c !== 0) { + rep.collection = r.chance(0.7) + ? this.variable('any').name + : `"\${${this.variable('json').name}.lines}"`; + if (r.chance(0.7)) rep.element = r.pick(['line', 'item']); + } + if (r.chance(0.3)) rep.sequential = true; + if (r.chance(0.3)) { + rep.until = { + t: 'bin', + op: '>=', + l: this.leaf( + r.pick([ + 'nrOfCompletedInstances', + 'nrOfInstances', + 'nrOfActiveInstances', + ]), + 'number', + ), + r: this.leaf(String(r.int(1, 5)), 'number'), + type: 'boolean', + }; + } + return rep; + } + + // ----- statements ----- + + private startEvent(first: boolean): Stmt { + const r = this.rng; + const s: Stmt = { + k: 'start', + name: this.fresh('Start'), + items: [...this.label()], + members: [], + }; + const wantTrigger = first ? r.chance(0.35) : true; + if (wantTrigger) { + const trigger = r.pick(START_TRIGGERS); + if (trigger === 'message' || trigger === 'signal') { + const pool = ( + trigger === 'message' ? MESSAGE_NAMES : SIGNAL_NAMES + ).filter((n) => !this.messageStarts.has(`${trigger}:${n}`)); + const name = pool[0]; + this.messageStarts.add(`${trigger}:${name}`); + s.trigger = trigger; + s.items.unshift({ value: `"${name}"` }); + } else if (trigger === 'timer' && !this.plainStartUsed) { + this.plainStartUsed = true; + s.trigger = 'timer'; + s.items.unshift(...this.timerPayload()); + } else { + s.trigger = 'condition'; + s.items.unshift({ value: renderExpr(this.boolExpr()) }); + } + } else { + this.plainStartUsed = true; + } + if (r.chance(0.2)) + s.items.push({ + key: 'initiator', + value: r.chance(0.5) ? '"starter"' : 'starter', + }); + if (r.chance(0.3)) + s.items.push(...this.engineSettings({ timer: s.trigger === 'timer' })); + if (r.chance(0.3)) s.members.push(this.form()); + s.members.push(...this.listeners(false)); + return s; + } + + private timerPayload(): Item[] { + const r = this.rng; + const kind = r.pick(['duration', 'date', 'cycle'] as const); + const time = + kind === 'duration' + ? r.pick(DURATIONS) + : kind === 'date' + ? r.pick(DATES) + : r.pick(CYCLES); + if (kind === 'duration') return [{ value: `"${time}"` }]; + return [{ key: TIMER_PARTICLE_BY_KIND[kind], value: `"${time}"` }]; + } + + private triggerPayload(trigger: string): Item[] { + const r = this.rng; + switch (trigger) { + case 'message': + return [{ value: `"${r.pick(MESSAGE_NAMES)}"` }]; + case 'signal': + return [{ value: `"${r.pick(SIGNAL_NAMES)}"` }]; + case 'timer': + return this.timerPayload(); + case 'condition': + return [{ value: renderExpr(this.boolExpr()) }]; + case 'link': + return [{ value: `"${r.pick(LINK_NAMES)}"` }]; + case 'error': { + const code = this.codes.find((c) => c.kind === 'error'); + return code ? [{ value: code.name }] : []; + } + case 'escalation': { + const code = this.codes.find((c) => c.kind === 'escalation'); + return code ? [{ value: code.name }] : []; + } + default: + return []; + } + } + + private task(scope: Scope): Stmt { + const r = this.rng; + const kw = r.pick([ + 'user', + 'service', + 'step', + 'send', + 'receive', + 'decide', + 'script', + ] as const); + const name = this.fresh(); + const items: Item[] = [...this.label(), ...this.documentation()]; + const members: Member[] = []; + const repeat = this.repeat(); + let binding: string | undefined; + const dirs: string[] = ['input', 'output']; + switch (kw) { + case 'user': { + if (r.chance(0.7)) + items.push({ + key: 'assignee', + value: r.pick(['"demo"', 'demo', '"${who}"']), + }); + if (r.chance(0.2)) + items.push({ key: 'candidateGroups', value: '"adjusters"' }); + if (r.chance(0.2)) + items.push({ key: 'candidateUsers', value: '"demo,manager"' }); + if (r.chance(0.2)) + items.push({ + key: 'dueDate', + value: r.pick(['"P2D"', '"2026-01-01T09:00:00"', '"${due}"']), + }); + if (r.chance(0.15)) + items.push({ key: 'followUpDate', value: '"PT4H"' }); + if (r.chance(0.2)) + items.push({ key: 'priority', value: r.pick(['75', '"${amount}"']) }); + if (r.chance(0.2)) { + items.push({ + key: 'formKey', + value: '"embedded:app:forms/triage.html"', + }); + } else { + // Same-looking condition as the branch above, but each `chance` + // call draws independently, so this is not a dead branch. + if (r.chance(0.2)) { + items.push({ key: 'formRef', value: '"payout-approval"' }); + items.push( + r.chance(0.5) + ? { key: 'binding', value: r.pick(['latest', 'deployment']) } + : { key: 'version', value: r.pick(['2', '"3"']) }, + ); + } + } + if (r.chance(0.25)) members.push(this.form()); + break; + } + case 'service': + case 'send': { + binding = r.pick(SERVICE_TASK_BINDING_KEYS); + items.push({ key: binding, value: this.bindingValue(binding) }); + if (binding === 'expression' && r.chance(0.5)) + items.push({ key: 'resultVariable', value: '"result"' }); + break; + } + case 'decide': { + if (r.chance(0.6)) { + binding = 'decision'; + items.push({ key: 'decision', value: '"approve-claim"' }); + if (r.chance(0.5)) + items.push( + r.chance(0.5) + ? { key: 'binding', value: r.pick(['latest', 'deployment']) } + : { key: 'version', value: '2' }, + ); + if (r.chance(0.5)) { + items.push({ key: 'resultVariable', value: '"verdict"' }); + if (r.chance(0.6)) + items.push({ + key: 'mapDecisionResult', + value: r.pick(DECISION_RESULT_MAPPINGS), + }); + } + } else { + binding = r.pick(SERVICE_TASK_BINDING_KEYS); + items.push({ key: binding, value: this.bindingValue(binding) }); + } + break; + } + case 'receive': + if (r.chance(0.7)) + items.push({ key: 'message', value: `"${r.pick(MESSAGE_NAMES)}"` }); + break; + case 'script': + if (r.chance(0.3)) + items.push({ key: 'resultVariable', value: '"grade"' }); + break; + default: + break; + } + if (binding === 'class' || binding === 'delegate') dirs.push('field'); + if (binding === 'type') { + const type = items.find((i) => i.key === 'type')!.value; + if (type === '"mail"') { + members.push({ + m: 'io', + dir: 'field', + name: 'to', + value: '"ops@example.com"', + }); + members.push({ + m: 'io', + dir: 'field', + name: r.pick(['text', 'html']), + value: r.chance(0.7) ? '"Disk usage high"' : '"${body}"', + }); + if (r.chance(0.3)) + members.push({ + m: 'io', + dir: 'field', + name: 'subject', + value: '"Alert"', + }); + } else { + members.push({ m: 'io', dir: 'field', name: 'command', value: '"df"' }); + if (r.chance(0.5)) + members.push({ m: 'io', dir: 'field', name: 'arg1', value: '"-h"' }); + if (r.chance(0.4)) + members.push({ + m: 'io', + dir: 'field', + name: r.pick(['wait', 'redirectError', 'cleanEnv']), + value: r.pick(['"true"', '"false"']), + }); + if (r.chance(0.3)) + members.push({ + m: 'io', + dir: 'field', + name: 'outputVariable', + value: '"diskReport"', + }); + } + } + if (binding === 'topic') { + if (r.chance(0.4)) + items.push({ + key: 'taskPriority', + value: r.pick([ + '42', + '"-5"', + this.variable('number').name, + '"${amount}"', + ]), + }); + dirs.push('property'); + const code = this.codes.find((c) => c.kind === 'error'); + if (code && r.chance(0.4)) { + members.push({ + m: 'errorMapping', + code: code.name, + condition: r.chance(0.5) + ? { + t: 'bin', + op: '==', + l: this.leaf('externalTask.errorMessage', 'string'), + r: this.leaf('"declined"', 'string'), + type: 'boolean', + } + : this.boolExpr(), + }); + } + } + members.push(...this.ioParams(dirs, repeat === undefined)); + members.push(...this.listeners(kw === 'user')); + if (r.chance(0.4)) + items.push(...this.engineSettings({ run: repeat !== undefined })); + const s: Stmt = { k: 'task', kw, name, repeat, items, members }; + if (kw === 'script') s.script = this.fence(); + if (!scope.inBranch) { + scope.targets.push(name); + scope.hosts.push({ name, kind: kw }); + } + return s; + } + + private call(scope: Scope): Stmt { + const r = this.rng; + const name = this.fresh('Call'); + const items: Item[] = [ + ...this.label(), + { + key: 'process', + value: `"${r.pick(['invoice-approval', 'payment-run'])}"`, + }, + ]; + if (r.chance(0.4)) + items.push( + r.chance(0.5) + ? { key: 'binding', value: r.pick(['latest', 'deployment']) } + : { key: 'version', value: r.pick(['1', '"2"']) }, + ); + if (r.chance(0.3)) + items.push({ + key: 'businessKey', + value: r.pick(['"abc"', '"${order.id}"', this.variable('string').name]), + }); + if (r.chance(0.3)) + items.push( + r.chance(0.5) + ? { key: 'mapper', value: '"com.example.Mapper"' } + : { key: 'mapperDelegate', value: '"${mapperBean}"' }, + ); + const repeat = this.repeat(); + if (r.chance(0.4)) + items.push(...this.engineSettings({ run: repeat !== undefined })); + const members: Member[] = []; + const n = r.int(0, 3); + const usedIn = new Set(); + const usedOut = new Set(); + for (let i = 0; i < n; i++) { + const dir = r.pick(['in', 'out'] as const); + const used = dir === 'in' ? usedIn : usedOut; + if (r.chance(0.15)) { + if (used.has('*')) continue; + used.add('*'); + members.push({ + m: 'mapping', + dir, + all: true, + local: r.chance(0.3) ? true : undefined, + }); + continue; + } + const target = r.pick(['amount', 'approved', 'claimTotal', 'y']); + if (used.has(target)) continue; + used.add(target); + const m: Member = { m: 'mapping', dir, target }; + if (r.chance(0.3)) m.local = true; + if (r.chance(0.6)) + m.source = + dir === 'in' + ? r.pick([ + this.variable().name, + '"u"', + renderExpr(this.numExpr(1)), + '"${order.total}"', + ]) + : r.pick(['result', '"${result}"']); + members.push(m); + } + members.push(...this.ioParams(['input', 'output'], repeat === undefined)); + members.push(...this.listeners(false)); + if (!scope.inBranch) { + scope.targets.push(name); + scope.hosts.push({ name, kind: 'call' }); + } + return { k: 'call', name, repeat, items, members }; + } + + private sub(scope: Scope): Stmt { + const r = this.rng; + const kw = r.chance(0.35) ? 'attempt' : 'subprocess'; + const name = this.fresh(kw === 'attempt' ? 'Try' : 'Sub'); + const items: Item[] = [...this.label()]; + const repeat = this.repeat(); + if (r.chance(0.3)) + items.push(...this.engineSettings({ run: repeat !== undefined })); + const members: Member[] = [ + ...this.ioParams(['input', 'output'], repeat === undefined), + ...this.listeners(false), + ]; + const inner: Scope = { + container: kw, + targets: [], + hosts: [], + linkCatchesWanted: [], + usedHandlers: new Set(), + depth: scope.depth + 1, + inBranch: false, + hostedBody: false, + }; + const body: Stmt[] = []; + let explicitEnd = false; + if (r.chance(0.4)) + body.push({ + k: 'start', + name: this.fresh('Start'), + items: [], + members: [], + }); + body.push(...this.chain(inner, false)); + if (r.chance(0.4) && !this.terminates(body[body.length - 1])) { + body.push({ k: 'end', name: this.fresh('Done'), items: [], members: [] }); + explicitEnd = true; + } + void explicitEnd; + this.flushLinks(inner, body); + if (r.chance(0.3) && this.stmtCount < this.statementBudget) { + const undo: Scope = { + ...inner, + container: 'handler', + targets: [], + hosts: [], + inBranch: true, + }; + body.push({ + k: 'on', + trigger: 'compensation', + items: [], + members: [], + body: [this.simpleTask(undo)], + }); + } + body.push(...this.handlers(inner, body)); + if (!scope.inBranch) { + scope.targets.push(name); + scope.hosts.push({ name, kind: kw }); + } + return { k: 'sub', kw, name, repeat, items, members, body }; + } + + private simpleTask(scope: Scope): Stmt { + const name = this.fresh(); + if (!scope.inBranch) { + scope.targets.push(name); + scope.hosts.push({ name, kind: 'service' }); + } + return { + k: 'task', + kw: 'service', + name, + items: [{ key: 'class', value: `"${this.rng.pick(CLASS_NAMES)}"` }], + members: [], + }; + } + + private ifStmt(scope: Scope): Stmt { + const r = this.rng; + const inner = { ...scope, depth: scope.depth + 1 }; + const s: Stmt = { + k: 'if', + cond: this.boolExpr(), + items: r.chance(0.25) ? this.engineSettings({ join: true }) : [], + then: this.chain(inner, false), + elseIfs: [], + }; + const n = r.int(0, 2); + for (let i = 0; i < n; i++) + s.elseIfs.push({ cond: this.boolExpr(), body: this.chain(inner, false) }); + if (r.chance(0.5)) s.else = this.chain(inner, false); + return s; + } + + private loop(scope: Scope): Stmt { + const r = this.rng; + const inner = { ...scope, depth: scope.depth + 1 }; + const items = r.chance(0.25) ? this.engineSettings() : []; + const body = this.chainNonTerminating(inner); + return r.chance(0.6) + ? { k: 'while', cond: this.boolExpr(), items, body } + : { k: 'do', cond: this.boolExpr(), items, body }; + } + + private parallel(scope: Scope): Stmt { + const r = this.rng; + const inner = { ...scope, depth: scope.depth + 1, inBranch: true }; + const items = r.chance(0.25) ? this.engineSettings({ join: true }) : []; + const mode = r.int(0, 3); // 0 plain, 1 all conditioned, 2 conditioned + else, 3 mixed + const n = r.int(2, 3); + const branches: { cond?: Expr; otherwise?: true; body: Stmt[] }[] = []; + for (let i = 0; i < n; i++) { + const body = this.chain(inner, false); + if (mode === 0) branches.push({ body }); + else if (mode === 1) branches.push({ cond: this.boolExpr(), body }); + else if (mode === 2) + branches.push( + i === n - 1 + ? { otherwise: true, body } + : { cond: this.boolExpr(), body }, + ); + else branches.push(i === 0 ? { cond: this.boolExpr(), body } : { body }); + } + return { k: 'parallel', items, branches }; + } + + private race(scope: Scope): Stmt { + const r = this.rng; + const inner = { ...scope, depth: scope.depth + 1, inBranch: true }; + const items = r.chance(0.25) + ? this.engineSettings({ join: true, noAsyncAfter: true }) + : []; + const n = r.int(2, 3); + const branches: { + trigger: string; + items: Item[]; + members: Member[]; + body: Stmt[]; + }[] = []; + const used = new Set(); + for (let i = 0; i < n; i++) { + const trigger = r.pick(RACE_TRIGGERS); + const payload = this.triggerPayload(trigger); + const key = `${trigger}:${payload[0]?.value}`; + if (used.has(key)) continue; + used.add(key); + const bItems = [...payload]; + if (r.chance(0.2)) + bItems.push(...this.engineSettings({ timer: trigger === 'timer' })); + branches.push({ + trigger, + items: bItems, + members: this.listeners(false), + body: this.chain(inner, false), + }); + } + while (branches.length < 2) { + branches.push({ + trigger: 'timer', + items: this.timerPayload(), + members: [], + body: this.chain(inner, false), + }); + } + return { k: 'race', items, branches }; + } + + private awaitStmt(scope: Scope): Stmt { + const r = this.rng; + const trigger = r.pick(RACE_TRIGGERS); + const items = this.triggerPayload(trigger); + if (r.chance(0.25)) + items.push(...this.engineSettings({ timer: trigger === 'timer' })); + const s: Stmt = { + k: 'await', + trigger, + items, + members: this.listeners(false), + }; + if (r.chance(0.4)) { + s.name = this.fresh('Wait'); + if (!scope.inBranch) scope.targets.push(s.name); + } + return s; + } + + private raise(scope: Scope, kind: 'throw' | 'emit'): Stmt | undefined { + const r = this.rng; + const pool = + kind === 'throw' + ? THROW_TRIGGERS + : EMIT_TRIGGERS.filter((t) => t !== 'link'); + let trigger = r.pick(pool); + if (trigger === 'compensation' && scope.container === 'process') + trigger = 'message'; + const items = this.triggerPayload(trigger); + if ((trigger === 'error' || trigger === 'escalation') && items.length === 0) + return undefined; + if (trigger === 'message' && r.chance(0.5)) { + const key = r.pick(['class', 'expression', 'delegate', 'topic']); + items.push({ key, value: this.bindingValue(key) }); + } + if (r.chance(0.2)) items.push(...this.engineSettings()); + const s: Stmt = { k: kind, trigger, items, members: this.listeners(false) }; + if (r.chance(0.5)) { + s.name = this.fresh(kind === 'throw' ? 'Fail' : 'Tell'); + if (!scope.inBranch) scope.targets.push(s.name); + } + return s; + } + + private endStmt(scope: Scope): Stmt { + const r = this.rng; + const s: Stmt = { + k: 'end', + name: this.fresh('Done'), + items: [...this.label()], + members: [], + }; + if (r.chance(0.3)) s.trigger = 'terminate'; + else if (scope.container === 'attempt' && r.chance(0.6)) + s.trigger = 'cancel'; + if (r.chance(0.2)) s.items.push(...this.engineSettings()); + s.members.push(...this.listeners(false)); + return s; + } + + private terminates(s: Stmt | undefined): boolean { + if (!s) return false; + switch (s.k) { + case 'end': + case 'goto': + case 'throw': + return true; + case 'emit': + return s.trigger === 'link'; + case 'do': + return this.blockTerminates(s.body); + case 'if': + return ( + s.else !== undefined && + this.blockTerminates(s.then) && + s.elseIfs.every((e) => this.blockTerminates(e.body)) && + this.blockTerminates(s.else) + ); + case 'parallel': + if (s.branches.every((b) => b.cond !== undefined)) return false; + return s.branches.every((b) => this.blockTerminates(b.body)); + case 'race': + return s.branches.every((b) => this.blockTerminates(b.body)); + default: + return false; + } + } + + private blockTerminates(body: Stmt[]): boolean { + return body.some((s) => this.terminates(s)); + } + + // A run of statements whose flow stays live except possibly at the very end. + private chain(scope: Scope, processLevel: boolean): Stmt[] { + const r = this.rng; + const out: Stmt[] = []; + const n = scope.depth === 0 ? r.int(2, 6) : r.int(1, 3); + for (let i = 0; i < n && this.stmtCount < this.statementBudget; i++) { + const s = this.statement(scope); + if (!s) continue; + out.push(s); + this.stmtCount++; + if (this.terminates(s)) break; + } + if (out.length === 0) { + out.push(this.simpleTask(scope)); + this.stmtCount++; + } + // A terminal statement at the end of a chain, sometimes. + if ( + !this.terminates(out[out.length - 1]) && + r.chance(scope.depth === 0 ? 0.5 : 0.2) + ) { + const term = this.terminal(scope); + if (term) { + out.push(term); + this.stmtCount++; + } + } + // A second chain after a terminal statement: a `start` at process level or an `await link` catch. + if ( + this.terminates(out[out.length - 1]) && + !scope.inBranch && + !scope.hostedBody + ) { + if (scope.linkCatchesWanted.length > 0 && r.chance(0.8)) { + const link = scope.linkCatchesWanted.shift()!; + this.linkCatches.add(link); + const s: Stmt = { + k: 'await', + trigger: 'link', + items: [{ value: `"${link}"` }], + members: [], + }; + if (r.chance(0.5)) s.name = this.fresh('At'); + out.push(s, ...this.chain(scope, processLevel)); + } else if (processLevel && r.chance(0.2)) { + out.push(this.startEvent(false), ...this.chain(scope, processLevel)); + } + } + return out; + } + + private chainNonTerminating(scope: Scope): Stmt[] { + const out: Stmt[] = []; + const n = this.rng.int(1, 3); + for (let i = 0; i < n && this.stmtCount < this.statementBudget; i++) { + const s = this.statement(scope, true); + if (!s || this.terminates(s)) continue; + out.push(s); + this.stmtCount++; + } + if (out.length === 0) out.push(this.simpleTask(scope)); + return out; + } + + private terminal(scope: Scope): Stmt | undefined { + const r = this.rng; + const c = r.int(0, 4); + if (c === 0 || c === 1) return this.endStmt(scope); + if (c === 2) return this.raise(scope, 'throw'); + if (c === 3 && scope.targets.length > 0 && scope.depth > 0) { + return { k: 'goto', target: r.pick(scope.targets) }; + } + if (c === 4 && !scope.inBranch && !scope.hostedBody) { + const link = r.pick( + LINK_NAMES.filter( + (l) => + !this.linkCatches.has(l) && !scope.linkCatchesWanted.includes(l), + ), + ); + if (link === undefined) return undefined; + scope.linkCatchesWanted.push(link); + const s: Stmt = { + k: 'emit', + trigger: 'link', + items: [{ value: `"${link}"` }], + members: [], + }; + if (r.chance(0.5)) s.name = this.fresh('To'); + return s; + } + return undefined; + } + + private statement(scope: Scope, nonTerminating = false): Stmt | undefined { + const r = this.rng; + const deep = scope.depth >= 2; + const c = r.int(0, deep ? 5 : 11); + switch (c) { + case 0: + case 1: + case 2: + return this.task(scope); + case 3: + return this.call(scope); + case 4: + return this.awaitStmt(scope); + case 5: + return this.raise(scope, 'emit'); + case 6: + return this.ifStmt(scope); + case 7: + return this.loop(scope); + case 8: + return this.parallel(scope); + case 9: + return this.race(scope); + case 10: + return this.sub(scope); + default: + return nonTerminating ? this.task(scope) : this.task(scope); + } + } + + // Trailing handlers of a container, hosted and host-less. + private handlers(scope: Scope, body: Stmt[]): Stmt[] { + const r = this.rng; + const out: Stmt[] = []; + if (scope.hostedBody || this.stmtCount >= this.statementBudget) return out; + const n = r.int(0, 2); + const hostsInBody = scope.hosts.filter((h) => + body.some((s) => this.contains(s, h.name)), + ); + for (let i = 0; i < n; i++) { + const hosted = hostsInBody.length > 0 && r.chance(0.5); + let trigger: string; + let host: { name: string; kind: string } | undefined; + if (hosted) { + host = r.pick(hostsInBody); + const legal = ['error', 'message', 'signal', 'timer', 'condition']; + if (['subprocess', 'attempt', 'call', 'user'].includes(host.kind)) + legal.push('escalation'); + if (host.kind === 'attempt') legal.push('cancel'); + trigger = r.pick(legal); + } else { + trigger = r.pick([ + 'error', + 'escalation', + 'message', + 'signal', + 'timer', + 'condition', + ]); + } + const items = this.triggerPayload(trigger); + if ( + (trigger === 'error' || trigger === 'escalation') && + items.length === 0 && + r.chance(0.5) + ) { + // catch-all form + } else if ( + (trigger === 'error' || trigger === 'escalation') && + items.length === 0 + ) { + continue; + } + const scopeKey = host ? `host:${host.name}` : `self`; + const dupKey = `${scopeKey}|${trigger}|${items[0]?.value ?? ''}`; + if (scope.usedHandlers.has(dupKey)) continue; + // a catch-all beside a coded catch of the same kind is refused + const catchAllKey = `${scopeKey}|${trigger}|`; + if ( + items.length === 0 && + [...scope.usedHandlers].some((k) => + k.startsWith(`${scopeKey}|${trigger}|`), + ) + ) + continue; + if (items.length > 0 && scope.usedHandlers.has(catchAllKey)) continue; + scope.usedHandlers.add(dupKey); + if (trigger === 'error' && items.length > 0 && r.chance(0.3)) { + items.push({ key: 'code', value: 'c' }); + if (r.chance(0.5)) items.push({ key: 'message', value: 'm' }); + } + if (trigger === 'escalation' && items.length > 0 && r.chance(0.3)) + items.push({ key: 'code', value: 'c' }); + if (r.chance(0.25)) + items.push(...this.engineSettings({ timer: trigger === 'timer' })); + if ( + ['escalation', 'message', 'signal', 'timer', 'condition'].includes( + trigger, + ) && + r.chance(0.4) + ) { + items.push({ value: 'alongside', flag: true }); + } + const inner: Scope = host + ? { + ...scope, + depth: scope.depth + 1, + hostedBody: true, + inBranch: false, + } + : { + container: 'handler', + targets: [], + hosts: [], + linkCatchesWanted: [], + usedHandlers: new Set(), + depth: scope.depth + 1, + inBranch: false, + hostedBody: false, + }; + const hBody: Stmt[] = []; + if (!host && r.chance(0.4)) + hBody.push({ + k: 'start', + name: this.fresh('Start'), + items: [], + members: [], + }); + hBody.push(...this.chain(inner, false)); + if (!host) this.flushLinks(inner, hBody); + if ( + host && + r.chance(0.3) && + !this.terminates(hBody[hBody.length - 1]) && + scope.targets.length > 0 + ) { + hBody.push({ k: 'goto', target: r.pick(scope.targets) }); + } + if (!host) hBody.push(...this.handlers(inner, hBody)); + out.push({ + k: 'on', + host: host?.name, + trigger, + items, + members: this.listeners(false), + body: hBody, + }); + this.stmtCount += 1 + hBody.length; + if (this.stmtCount >= this.statementBudget) break; + } + return out; + } + + // Every `emit link` of the container gets its catch: close the flow, then + // one `await link` per pending name followed by a step. + private flushLinks(scope: Scope, body: Stmt[]): void { + if (scope.linkCatchesWanted.length === 0) return; + if (!this.terminates(body[body.length - 1])) { + body.push({ k: 'end', name: this.fresh('Done'), items: [], members: [] }); + } + while (scope.linkCatchesWanted.length > 0) { + const link = scope.linkCatchesWanted.shift()!; + this.linkCatches.add(link); + body.push({ + k: 'await', + trigger: 'link', + items: [{ value: `"${link}"` }], + members: [], + }); + body.push(this.simpleTask(scope)); + body.push({ k: 'end', name: this.fresh('Done'), items: [], members: [] }); + } + } + + private contains(s: Stmt, name: string): boolean { + if ('name' in s && s.name === name) return true; + for (const b of childBlocks(s)) + if (b.some((x) => this.contains(x, name))) return true; + return false; + } +} + +export function childBlocks(s: Stmt): Stmt[][] { + switch (s.k) { + case 'sub': + case 'while': + case 'do': + case 'on': + return [s.body]; + case 'if': + return [ + s.then, + ...s.elseIfs.map((e) => e.body), + ...(s.else ? [s.else] : []), + ]; + case 'parallel': + case 'race': + return s.branches.map((b) => b.body); + default: + return []; + } +} + +export function generateProgram(seed: number): Program { + return new Generator(seed).generate(); +} + +// --------------------------------------------------------------------------- +// Reducer: delta debugging over the program model +// --------------------------------------------------------------------------- + +export type Keep = (candidate: Program) => Promise; + +function clone(x: T): T { + return JSON.parse(JSON.stringify(x)) as T; +} + +// Every statement list in the program, addressed so a candidate can drop one entry. +function statementLists(p: Program): Stmt[][] { + const lists: Stmt[][] = [p.body]; + const walk = (body: Stmt[]): void => { + for (const s of body) { + for (const b of childBlocks(s)) { + lists.push(b); + walk(b); + } + } + }; + walk(p.body); + return lists; +} + +async function dropStatements(p: Program, keep: Keep): Promise { + let changed = true; + while (changed) { + changed = false; + const lists = statementLists(p); + for (let li = 0; li < lists.length; li++) { + const list = lists[li]; + for (let i = list.length - 1; i >= 0; i--) { + const candidate = clone(p); + const cList = statementLists(candidate)[li]; + cList.splice(i, 1); + if (await keep(candidate)) { + p = candidate; + changed = true; + break; + } + } + if (changed) break; + } + } + // Replace a compound statement by its body (unwrap), one at a time. + changed = true; + while (changed) { + changed = false; + const lists = statementLists(p); + for (let li = 0; li < lists.length; li++) { + const list = lists[li]; + for (let i = 0; i < list.length; i++) { + const blocks = childBlocks(list[i]); + for (let bi = 0; bi < blocks.length; bi++) { + const candidate = clone(p); + const cList = statementLists(candidate)[li]; + const inner = childBlocks(cList[i])[bi]; + cList.splice(i, 1, ...inner); + if (await keep(candidate)) { + p = candidate; + changed = true; + break; + } + } + if (changed) break; + } + if (changed) break; + } + } + return p; +} + +// Every array of droppable settings/members in the program. +function droppableArrays(p: Program): unknown[][] { + const arrays: unknown[][] = [p.header, p.vars, p.codes]; + const walk = (body: Stmt[]): void => { + for (const s of body) { + if ('items' in s) arrays.push(s.items); + if ('members' in s) { + arrays.push(s.members); + for (const m of s.members) { + if (m.m === 'listener') arrays.push(m.items, m.fields); + if (m.m === 'form') { + arrays.push(m.fields); + for (const f of m.fields) arrays.push(f.items, f.values, f.props); + } + } + } + if (s.k === 'race') + for (const b of s.branches) arrays.push(b.items, b.members); + if (s.k === 'if') arrays.push(s.elseIfs); + if (s.k === 'parallel' || s.k === 'race') arrays.push(s.branches); + for (const b of childBlocks(s)) walk(b); + } + }; + walk(p.body); + return arrays; +} + +async function dropEntries(p: Program, keep: Keep): Promise { + let changed = true; + while (changed) { + changed = false; + const arrays = droppableArrays(p); + for (let ai = 0; ai < arrays.length; ai++) { + const arr = arrays[ai]; + for (let i = arr.length - 1; i >= 0; i--) { + const candidate = clone(p); + droppableArrays(candidate)[ai].splice(i, 1); + if (await keep(candidate)) { + p = candidate; + changed = true; + break; + } + } + if (changed) break; + } + } + // Optional scalar fields: repeat clauses, else blocks, names, triggers, scripts, labels. + const optionalFields: [string, string][] = [ + ['task', 'repeat'], + ['call', 'repeat'], + ['sub', 'repeat'], + ['if', 'else'], + ['await', 'name'], + ['throw', 'name'], + ['emit', 'name'], + ['start', 'trigger'], + ['end', 'trigger'], + ['on', 'host'], + ['listener', 'script'], + ]; + changed = true; + while (changed) { + changed = false; + const nodes = allNodes(p); + for (let ni = 0; ni < nodes.length; ni++) { + const node = nodes[ni] as Record; + for (const [kind, field] of optionalFields) { + const nodeKind = (node.k ?? node.m) as string; + if (nodeKind !== kind || node[field] === undefined) continue; + const candidate = clone(p); + delete (allNodes(candidate)[ni] as Record)[field]; + if (await keep(candidate)) { + p = candidate; + changed = true; + break; + } + } + if (changed) break; + } + } + return p; +} + +function allNodes(p: Program): unknown[] { + const nodes: unknown[] = []; + const walk = (body: Stmt[]): void => { + for (const s of body) { + nodes.push(s); + if ('members' in s) for (const m of s.members) nodes.push(m); + if (s.k === 'race') + for (const b of s.branches) for (const m of b.members) nodes.push(m); + for (const b of childBlocks(s)) walk(b); + } + }; + walk(p.body); + return nodes; +} + +// Every expression slot, as a getter/setter pair over a cloned program. +function exprSlots(p: Program): { get: () => Expr; set: (e: Expr) => void }[] { + const slots: { get: () => Expr; set: (e: Expr) => void }[] = []; + const walk = (body: Stmt[]): void => { + for (const s of body) { + if (s.k === 'if') { + slots.push({ get: () => s.cond, set: (e) => (s.cond = e) }); + for (const ei of s.elseIfs) + slots.push({ get: () => ei.cond, set: (e) => (ei.cond = e) }); + } + if (s.k === 'while' || s.k === 'do') + slots.push({ get: () => s.cond, set: (e) => (s.cond = e) }); + if (s.k === 'parallel') + for (const b of s.branches) + if (b.cond) + slots.push({ get: () => b.cond!, set: (e) => (b.cond = e) }); + if ('repeat' in s && s.repeat?.until) { + const rep = s.repeat; + slots.push({ get: () => rep.until!, set: (e) => (rep.until = e) }); + } + if ('members' in s) + for (const m of s.members) + if (m.m === 'errorMapping') + slots.push({ + get: () => m.condition, + set: (e) => (m.condition = e), + }); + for (const b of childBlocks(s)) walk(b); + } + }; + walk(p.body); + return slots; +} + +function simplerForms(e: Expr): Expr[] { + const out: Expr[] = []; + const sameType = (x: Expr): boolean => x.type === e.type; + switch (e.t) { + case 'bin': + if (sameType(e.l)) out.push(e.l); + if (sameType(e.r)) out.push(e.r); + break; + case 'un': + case 'paren': + if (sameType(e.e)) out.push(e.e); + break; + case 'tern': + if (sameType(e.a)) out.push(e.a); + if (sameType(e.b)) out.push(e.b); + break; + case 'leaf': + break; + } + if (e.type === 'boolean' && !(e.t === 'leaf' && e.text === 'true')) + out.push({ t: 'leaf', text: 'true', type: 'boolean' }); + if (e.type === 'number' && !(e.t === 'leaf' && e.text === '1')) + out.push({ t: 'leaf', text: '1', type: 'number' }); + if (e.type === 'string' && !(e.t === 'leaf' && e.text === '"a"')) + out.push({ t: 'leaf', text: '"a"', type: 'string' }); + return out; +} + +// Children of an expression, for a depth-first replacement walk. +function exprChildren(e: Expr): { get: () => Expr; set: (x: Expr) => void }[] { + switch (e.t) { + case 'bin': + return [ + { get: () => e.l, set: (x) => (e.l = x) }, + { get: () => e.r, set: (x) => (e.r = x) }, + ]; + case 'un': + case 'paren': + return [{ get: () => e.e, set: (x) => (e.e = x) }]; + case 'tern': + return [ + { get: () => e.c, set: (x) => (e.c = x) }, + { get: () => e.a, set: (x) => (e.a = x) }, + { get: () => e.b, set: (x) => (e.b = x) }, + ]; + case 'leaf': + return []; + } +} + +async function simplifyExpressions(p: Program, keep: Keep): Promise { + let changed = true; + while (changed) { + changed = false; + const slotCount = exprSlots(p).length; + for (let si = 0; si < slotCount && !changed; si++) { + // Try every node in the slot's tree, root first. + const paths: number[][] = []; + const collect = (e: Expr, path: number[]): void => { + paths.push(path); + exprChildren(e).forEach((c, i) => collect(c.get(), [...path, i])); + }; + collect(exprSlots(p)[si].get(), []); + for (const path of paths) { + const target = ( + prog: Program, + ): { get: () => Expr; set: (x: Expr) => void } => { + let slot = exprSlots(prog)[si]; + for (const i of path) slot = exprChildren(slot.get())[i]; + return slot; + }; + const forms = simplerForms(target(p).get()); + for (const form of forms) { + const candidate = clone(p); + target(candidate).set(clone(form)); + if (await keep(candidate)) { + p = candidate; + changed = true; + break; + } + } + if (changed) break; + } + } + } + return p; +} + +export async function minimize(p: Program, keep: Keep): Promise { + p = await dropStatements(p, keep); + p = await dropEntries(p, keep); + p = await simplifyExpressions(p, keep); + // A second statement pass: entries dropped above may have freed statements. + p = await dropStatements(p, keep); + return p; +} diff --git a/tests/helpers/model-equivalence.test.ts b/tests/helpers/model-equivalence.test.ts new file mode 100644 index 0000000..334250f --- /dev/null +++ b/tests/helpers/model-equivalence.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect } from 'vitest'; +import { astToIr } from '@bpmn-script/transform'; +import { compareModels, type ModelComparison } from './model-equivalence.js'; +import { parseToAst } from './pipeline.js'; + +const compile = async (source: string) => astToIr(await parseToAst(source)); + +// Shared by the rows that must be `changed`; each variant differs in one place. +const baseline = (body: string): string => `process p { + var a: any + var b: any +${body} +}`; +const ifA = (then: string, otherwise: string): string => ` if (a) { + step ${then} + } else { + step ${otherwise} + }`; +const parallel = ` parallel { + { + step P1 + } + { + step P2 + } + }`; +const ifB = ` if (b) { + step W + }`; +const base = baseline( + [ifA('X', 'Y'), parallel, ' step Z(asyncBefore: true)', ifB].join('\n'), +); + +const joinSource = `process p { + if (true) (joinAsyncAfter: true) { + } else { + await timer Wait(at: "2027-01-01T00:00:00") + } + receive Pay + goto Pay +}`; + +const swappedGotos = (then: string, otherwise: string): string => `process p { + var a: any + step X + step Y + if (a) { + await signal("Foo") + goto ${then} + } else { + await signal("Foo") + goto ${otherwise} + } +}`; + +const table: [title: string, a: string, b: string, want: ModelComparison][] = [ + [ + 'a single-quoted and a double-quoted JUEL string are canonical', + `process p { + var x: any + if ("\${x == 'a'}") { + step A + } +}`, + `process p { + var x: any + if (x == "a") { + step A + } +}`, + 'canonical', + ], + [ + 'two unnamed events printed in the other order are canonical', + `process p { + parallel { + { + await signal("A") + } + { + await signal("B") + } + } +}`, + `process p { + parallel { + { + await signal("B") + } + { + await signal("A") + } + } +}`, + 'canonical', + ], + [ + 'two event handlers printed in the other order are canonical', + `process p { + step A + on signal("A") { + step HA + } + on signal("B") { + step HB + } +}`, + `process p { + step A + on signal("B") { + step HB + } + on signal("A") { + step HA + } +}`, + 'canonical', + ], + [ + 'a branch printed after its block and reached by goto is restructured', + `process p { + if (true) { + receive Approve + emit signal("Shutdown") + end Done + } else if (true) { + emit signal("Shutdown") + } + emit link("Skip") + await link("Skip") + end Done19 + on signal("Ready") { + step Handle + } +}`, + `process p { + if (true) { + goto Approve + } else if (true) { + emit signal("Shutdown") + } + emit link("Skip") + receive Approve + emit signal("Shutdown") + end Done + await link("Skip") + end Done19 + on signal("Ready") { + step Handle + } +}`, + 'restructured', + ], + [ + 'a do-while printed as an if with a backward goto is restructured', + `process p { + var x: any + var y: any + if (x) { + step A + } else if (y) { + do { + step B + call Call(process: "run") + } while (y) + call Call8(process: "run") + } else { + goto Call + } +}`, + `process p { + var x: any + var y: any + if (x) { + step A + } else if (y) { + step B + call Call(process: "run") + if (y) { + goto B + } else { + call Call8(process: "run") + } + } else { + goto Call + } +}`, + 'restructured', + ], + [ + 'swapped if and else bodies are changed', + base, + baseline( + [ifA('Y', 'X'), parallel, ' step Z(asyncBefore: true)', ifB].join('\n'), + ), + 'changed', + ], + [ + 'a condition replaced by true is changed', + base, + baseline( + [ + ifA('X', 'Y'), + parallel, + ' step Z(asyncBefore: true)', + ifB.replace('(b)', '(true)'), + ].join('\n'), + ), + 'changed', + ], + [ + 'asyncBefore dropped from a step is changed', + base, + baseline([ifA('X', 'Y'), parallel, ' step Z', ifB].join('\n')), + 'changed', + ], + [ + 'a parallel printed as a sequence is changed', + base, + baseline( + [ + ifA('X', 'Y'), + ' step P1\n step P2', + ' step Z(asyncBefore: true)', + ifB, + ].join('\n'), + ), + 'changed', + ], + [ + 'an added else branch is changed', + base, + baseline( + [ + ifA('X', 'Y'), + parallel, + ' step Z(asyncBefore: true)', + ifB.replace(/\}$/, '} else {\n step W2\n }'), + ].join('\n'), + ), + 'changed', + ], + [ + "a route that skips an if's joinAsyncAfter join is changed", + joinSource, + `process p { + if (true) (joinAsyncAfter: true) { + } else { + goto Wait + } + receive Pay + goto Pay + await timer Wait(at: "2027-01-01T00:00:00") + goto Pay +}`, + 'changed', + ], + [ + 'two identical unnamed events whose gotos are swapped are changed', + swappedGotos('X', 'Y'), + swappedGotos('Y', 'X'), + 'changed', + ], +]; + +describe('compareModels', () => { + it.each(table)('%s', async (_title, a, b, want) => { + expect(compareModels(await compile(a), await compile(b))).toBe(want); + }); +}); diff --git a/tests/helpers/model-equivalence.ts b/tests/helpers/model-equivalence.ts new file mode 100644 index 0000000..bcc28f9 --- /dev/null +++ b/tests/helpers/model-equivalence.ts @@ -0,0 +1,327 @@ +// Decides whether a recompiled print keeps the model of its source. Three +// layers, cheapest first: `normalizeIr` equality; the same after canonicalizing +// JUEL bodies and minted coordinate ids; and a path signature that accepts a +// restructured print as long as every step keeps its content and reaches the +// same next steps through the same conditions, default routes, non-exclusive +// gateways and gateway settings. + +import { isGateway, parseJuel } from '@bpmn-script/transform'; +import type { BpmnProcess, FlowElement } from '@bpmn-script/transform'; +import { normalizeIr } from './normalize-ir.js'; + +export type ModelComparison = 'same' | 'canonical' | 'restructured' | 'changed'; + +export function compareModels(a: BpmnProcess, b: BpmnProcess): ModelComparison { + if (stable(normalizeIr(a)) === stable(normalizeIr(b))) return 'same'; + if (canonicalForm(a) === canonicalForm(b)) return 'canonical'; + if (stable(modelSignature(a)) === stable(modelSignature(b))) { + return 'restructured'; + } + return 'changed'; +} + +// Expressions go first: a minted coordinate name embeds its event definition. +function canonicalForm(ir: BpmnProcess): string { + return stable( + normalizeIr(canonicalizeCoordinateIds(canonicalExpressions(ir))), + ); +} + +// `toEqual` semantics on a string: sorted keys, `undefined` dropped. +function stable(x: unknown): string { + return JSON.stringify(x, (_k, v: unknown) => { + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + const o = v as Record; + return Object.fromEntries( + Object.keys(o) + .sort() + .filter((k) => o[k] !== undefined) + .map((k) => [k, o[k]]), + ); + } + return v; + }); +} + +// Every IR field that can hold an expression body. +const EXPRESSION_KEYS = + /^(conditionExpression|condition|expression|value|source|collection|cardinality|completionCondition|delegateExpression|code|stringValue)$/; +const EXPRESSION_BODY = /^[$#]\{[\s\S]*\}$/; + +// A body inside the JUEL subset compares by its parse tree, so quote style and +// spacing do not count; a raw body stays verbatim. +function canonicalExpressions(value: T): T { + if (Array.isArray(value)) return value.map(canonicalExpressions) as T; + if (value === null || typeof value !== 'object') return value; + const o = value as Record; + return Object.fromEntries( + Object.keys(o).map((k) => { + const v = o[k]; + if (typeof v !== 'string' || !EXPRESSION_KEYS.test(k)) { + return [k, canonicalExpressions(v)]; + } + const body = v.trim(); + if (!EXPRESSION_BODY.test(body)) return [k, v]; + const parsed = parseJuel(body); + return [ + k, + parsed.kind === 'structured' ? body[0] + stable(parsed.expr) : v, + ]; + }), + ) as T; +} + +// `normalizeIr` re-keys gateways, handlers and boundary events but not the +// coordinate ids the compiler mints for unnamed events, a handler body's own +// start and end, and the handler itself (`Catch_p_0_b1`, `Throw_p_2`, +// `EventSubProcess_p_3`), which move whenever the printer reorders or hoists a +// statement. Each is renamed to its content (`#n` on a tie in element order). +const COORDINATE_ID = + /^(Catch_|Throw_|StartEvent_EventSubProcess_|EndEvent_EventSubProcess_|EndEvent_Boundary_)/; +const EVENT_SUB_PROCESS_ID = /^EventSubProcess_/; +const COORDINATE_GATEWAY_ID = /^Gateway_.+_(split|join|fork|loop|race)$/; + +export function canonicalizeCoordinateIds(container: BpmnProcess): BpmnProcess { + const rename = new Map(); + const used = new Map(); + const mint = (id: string, base: string): void => { + const n = (used.get(base) ?? 0) + 1; + used.set(base, n); + rename.set(id, n === 1 ? base : `${base}#${n}`); + }; + + // A handler's body is renamed first, so its content key holds no coordinate. + const elements = container.flowElements.map((el) => + el.kind === 'subProcess' + ? (canonicalizeCoordinateIds( + el as unknown as BpmnProcess, + ) as unknown as FlowElement) + : el, + ); + for (const el of elements) { + if (COORDINATE_ID.test(el.id)) { + const def = 'eventDefinition' in el ? stable(el.eventDefinition) : ''; + mint(el.id, `${el.kind}:${def}`); + } else if (EVENT_SUB_PROCESS_ID.test(el.id) && el.kind === 'subProcess') { + mint(el.id, `EventSubProcess:${handlerContent(el)}`); + } + } + + // A gateway's coordinate also sits inside the neighbour lists `normalizeIr` + // keys other gateways by, so it is renamed after the events, by its own + // kind and its neighbours under their new names; a second round lets a + // gateway whose neighbours are gateways pick up their first-round names. + // The `_join` suffix survives so `normalizeIr` still inlines a pass-through join. + const byId = new Map(elements.map((e) => [e.id, e])); + for (let round = 0; round < 2; round++) { + const previous = new Map(rename); + const neighbourName = (id: string): string => { + const el = byId.get(id); + if (el !== undefined && COORDINATE_GATEWAY_ID.test(id)) { + return round === 0 ? `gateway:${el.kind}` : (previous.get(id) ?? id); + } + return previous.get(id) ?? id; + }; + for (const k of [...used.keys()]) { + if (k.startsWith('Gateway_')) used.delete(k); + } + for (const el of elements) { + const role = COORDINATE_GATEWAY_ID.exec(el.id)?.[1]; + if (role === undefined) continue; + const ins = container.sequenceFlows + .filter((f) => f.targetRef === el.id) + .map((f) => neighbourName(f.sourceRef)) + .sort(); + const outs = container.sequenceFlows + .filter((f) => f.sourceRef === el.id) + .map((f) => neighbourName(f.targetRef)) + .sort(); + mint( + el.id, + `Gateway_${el.kind}:[${ins.join(',')}]:[${outs.join(',')}]_${role}`, + ); + } + } + + const to = (id: string): string => rename.get(id) ?? id; + const flowElements = elements.map((el): FlowElement => { + const renamed = { ...el, id: to(el.id) }; + if ('attachedToRef' in renamed) + renamed.attachedToRef = to(renamed.attachedToRef); + return renamed; + }); + const sequenceFlows = container.sequenceFlows.map((sf) => ({ + ...sf, + sourceRef: to(sf.sourceRef), + targetRef: to(sf.targetRef), + })); + return { ...container, flowElements, sequenceFlows }; +} + +// A handler's own settings plus its trigger; two handlers with one trigger are +// a validator error, so this cannot tie in a valid program. +function handlerContent(handler: FlowElement & { kind: 'subProcess' }): string { + const start = handler.flowElements.find((e) => e.kind === 'startEvent'); + return stable({ + ...ownContent(handler), + start: start === undefined ? undefined : ownContent(start), + }); +} + +function ownContent(el: object): Record { + const { + id: _id, + flowElements: _elements, + sequenceFlows: _flows, + defaultFlowId: _default, + ...rest + } = el as Record; + return rest; +} + +/** + * Sorted, readable lines; two IRs keep the same model iff their signatures are + * equal. Per container: one line for its own content, one per non-gateway + * element, and one per pair of non-gateway elements joined through gateways + * only, labelled with what the route crosses. + */ +export function modelSignature(ir: BpmnProcess): string[] { + return [ + ...new Set( + containerSignature(canonicalizeCoordinateIds(canonicalExpressions(ir))), + ), + ].sort(); +} + +function containerSignature(container: BpmnProcess): string[] { + const byId = new Map(container.flowElements.map((e) => [e.id, e])); + const outgoing = new Map(); + for (const f of container.sequenceFlows) { + outgoing.set(f.sourceRef, [...(outgoing.get(f.sourceRef) ?? []), f]); + } + + // An exclusive gateway without settings is pure routing: a hoist, a loop + // printed as a backward `goto` or a step sunk into its only live branch adds + // or removes exactly those, while the conditions on its flows still label + // the route. Any other gateway changes how the route runs and stays in. + const gatewayMark = (el: FlowElement): string | undefined => { + const { name: _name, ...settings } = ownContent(el); + return el.kind === 'exclusiveGateway' && Object.keys(settings).length === 1 + ? undefined + : stable(settings); + }; + + const nodes = container.flowElements.filter((el) => !isGateway(el)); + const routes: { from: string; route: string; to: string }[] = []; + for (const el of nodes) { + // Every simple path through gateways; a gateway already on the current + // path is not re-entered, so gateway-only cycles terminate and the result + // does not depend on flow order. + // ponytail: exponential in the length of a gateway-only chain; fine for + // the chains the compiler emits, memoize per gateway if that changes. + const walk = (at: string, labels: string[], onPath: Set): void => { + const source = byId.get(at); + for (const f of outgoing.get(at) ?? []) { + const route = [...labels]; + if (f.conditionExpression !== undefined) { + route.push(`if ${f.conditionExpression}`); + } else if ( + source !== undefined && + 'defaultFlowId' in source && + source.defaultFlowId === f.id + ) { + route.push('else'); + } + const target = byId.get(f.targetRef); + if (target !== undefined && isGateway(target)) { + if (onPath.has(target.id)) continue; + const mark = gatewayMark(target); + if (mark !== undefined) route.push(mark); + walk(target.id, route, new Set([...onPath, target.id])); + } else { + routes.push({ + from: el.id, + route: route.join('; '), + to: f.targetRef, + }); + } + } + }; + walk(el.id, [], new Set()); + } + + const key = nodeKeys(byId, nodes, routes); + const lines = [`container ${stable(ownContent(container))}`]; + for (const el of nodes) { + lines.push(`node ${key(el.id)}`); + if (el.kind === 'subProcess') { + for (const line of containerSignature(el as unknown as BpmnProcess)) { + lines.push(`${key(el.id)} / ${line}`); + } + } + } + for (const r of routes) { + lines.push(`${key(r.from)} -> [${r.route}] -> ${key(r.to)}`); + } + return lines; +} + +// A node is named by its id and content. The `#n` tie-break on a minted id is +// positional, so it is dropped; nodes that then share a name (identical +// unnamed events) are told apart by the routes into and out of them, refined +// until the partition is stable, and numbered in the sorted order of those +// route shapes. Without that, identical events in two branches whose gotos +// are swapped would produce the same set of lines. +function nodeKeys( + byId: Map, + nodes: FlowElement[], + routes: { from: string; route: string; to: string }[], +): (id: string) => string { + const base = (id: string): string => { + const el = byId.get(id); + return `${id.replace(/#\d+$/, '')} ${el === undefined ? '?' : stable(ownContent(el))}`; + }; + const colour = (id: string): string => shape.get(id) ?? base(id); + let shape = new Map(nodes.map((el) => [el.id, base(el.id)])); + for (let classes = 0; ;) { + const next = new Map( + nodes.map((el) => [ + el.id, + stable([ + colour(el.id), + routes + .filter((r) => r.from === el.id) + .map((r) => `-> [${r.route}] ${colour(r.to)}`) + .sort(), + routes + .filter((r) => r.to === el.id) + .map((r) => `<- [${r.route}] ${colour(r.from)}`) + .sort(), + ]), + ]), + ); + // Shapes can grow each round; renaming them to their rank keeps them short + // and still depends only on the graph, not on element order. + const ranked = [...new Set(next.values())].sort(); + shape = new Map( + [...next].map(([id, s]) => [id, String(ranked.indexOf(s))]), + ); + if (ranked.length === classes) break; + classes = ranked.length; + } + + const byBase = new Map(); + for (const el of nodes) { + const b = base(el.id); + byBase.set(b, [...(byBase.get(b) ?? []), shape.get(el.id)!]); + } + return (id: string): string => { + const b = base(id); + const shapes = [...new Set(byBase.get(b) ?? [])].sort( + (x, y) => Number(x) - Number(y), + ); + return shapes.length > 1 + ? `${b} #${shapes.indexOf(shape.get(id)!) + 1}` + : b; + }; +} diff --git a/tests/helpers/pipeline.ts b/tests/helpers/pipeline.ts index 946ccbb..2bffeec 100644 --- a/tests/helpers/pipeline.ts +++ b/tests/helpers/pipeline.ts @@ -4,7 +4,13 @@ import { parseHelper, validationHelper } from 'langium/test'; import { createBpmnScriptServices } from '@bpmn-script/language'; import type { Model } from '@bpmn-script/language'; -import { astToIr, irToDsl, irToXml, xmlToIr } from '@bpmn-script/transform'; +import { + astToIr, + irToDsl, + irToXml, + LayoutError, + xmlToIr, +} from '@bpmn-script/transform'; import type { BpmnProcess, ImportWarning } from '@bpmn-script/transform'; const services = createBpmnScriptServices(EmptyFileSystem); @@ -58,10 +64,14 @@ export interface RoundTripRun { // The cli's build.ts and the extension's conversion-core.ts run the same // astToIr -> irToXml chain, each with its own failure reporting; here a -// failure throws. +// failure throws. Like both, a layout failure keeps the diagram-less XML, +// since the model it carries is complete. export async function roundTrip(source: string): Promise { const ir1 = astToIr(await parseToAst(source)); - const xml = await irToXml(ir1); + const xml = await irToXml(ir1).catch((e: unknown) => { + if (e instanceof LayoutError) return e.xml; + throw e; + }); const { ir: ir2, warnings } = await xmlToIr(xml); const dsl = printDsl(ir2); const ir3 = astToIr(await parseToAst(dsl)); diff --git a/tests/invoice-receipt.import.test.ts b/tests/invoice-receipt.import.test.ts index fbd53f9..41f5ffe 100644 --- a/tests/invoice-receipt.import.test.ts +++ b/tests/invoice-receipt.import.test.ts @@ -23,8 +23,8 @@ const FIXTURE = readFileSync( const PRINTED = [ 'process invoice(label: "Invoice Receipt", versionTag: "V2.0", historyTimeToLive: "45") {', - ' var clarified: any', ' var approved: any', + ' var clarified: any', ' start StartEvent_1(label: "Invoice\\nreceived")', ' decide assignApprover(label: "Assign Approver Group", decision: "invoice-assign-approver", mapDecisionResult: collectEntries, resultVariable: "approverGroups")', ' do {', diff --git a/tests/round-trip-constructs.test.ts b/tests/round-trip-constructs.test.ts index 239d6c8..bb53601 100644 --- a/tests/round-trip-constructs.test.ts +++ b/tests/round-trip-constructs.test.ts @@ -122,26 +122,23 @@ describe('goto-degradation preserves the edges that have a goto form', () => { irSecondRound = astToIr(await parseToAst(printDsl(irReDesugared))); }); - it('the degraded DSL prints every edge with no structured form as a `goto`', () => { + it('the degraded DSL prints unstructured routing as `goto`, except a chain only this branch enters, which prints inline in its branch', () => { expect(degradedDsl).toBe( [ 'process unstructured-goto {', - ' var route: any', ' var retry: any', + ' var route: any', ' start Start', ' user Intake(assignee: "demo")', ' if (route == "A") {', - ' goto Alpha', + ' user Alpha(assignee: "demo")', + ' if (retry == true) {', + ' } else {', + ' end Done', + ' }', ' }', ' user Beta(assignee: "manager")', ' end DoneBeta', - ' user Alpha(assignee: "demo")', - ' if (retry == true) {', - ' goto Beta', - ' } else {', - ' end Done', - ' }', - ' goto DoneBeta', '}', '', ].join('\n'), From 0be109a1b5d60f97965f0a4254e17d0e819ca8de Mon Sep 17 00:00:00 2001 From: Marlon Kranz Date: Sat, 26 Sep 2026 14:17:02 +0200 Subject: [PATCH 08/11] Share language facts through the vocabulary, cut comments and fold tests into broad cases --- .../0025-events-at-the-process-boundary.md | 2 +- package-lock.json | 206 + package.json | 1 + packages/cli/src/build.ts | 35 +- packages/cli/src/parse.ts | 26 +- packages/cli/src/util.ts | 31 +- packages/cli/test/build-parse.smoke.test.ts | 443 +- packages/cli/test/build.layout-guard.test.ts | 4 +- .../cli/test/decompile-contract.e2e.test.ts | 208 +- packages/cli/test/helpers/actions.ts | 22 +- packages/cli/test/util.test.ts | 58 - .../src/extension/conversion-core.ts | 35 +- .../extension/src/extension/conversion.ts | 10 +- packages/extension/src/extension/main.ts | 2 +- .../src/extension/sidebar-view-provider.ts | 7 +- .../test/bundled-conversion.e2e.test.ts | 23 +- .../extension/test/conversion-core.test.ts | 45 +- packages/extension/test/conversion.test.ts | 41 +- .../extension/test/injection-grammar.test.ts | 48 +- .../test/sidebar-view-provider.test.ts | 4 - packages/language/README.md | 2 +- .../language/src/bpmn-script-completion.ts | 206 +- .../src/bpmn-script-document-validator.ts | 16 +- packages/language/src/bpmn-script-hover.ts | 9 +- packages/language/src/bpmn-script-linker.ts | 23 +- packages/language/src/bpmn-script-module.ts | 3 +- ...mn-script-parser-error-message-provider.ts | 32 +- .../language/src/bpmn-script-references.ts | 25 +- .../src/bpmn-script-scope-provider.ts | 59 +- .../src/bpmn-script-semantic-tokens.ts | 11 +- packages/language/src/bpmn-script-symbols.ts | 8 +- .../language/src/bpmn-script-validator.ts | 918 +- .../src/bpmn-script-value-converter.ts | 7 +- packages/language/src/expression-render.ts | 29 +- packages/language/src/index.ts | 4 +- packages/language/src/paren-items.ts | 77 +- .../language/src/variable-symbol-provider.ts | 85 +- packages/language/src/vocabulary.ts | 580 +- packages/language/test/completion.test.ts | 177 +- packages/language/test/helpers/block-hosts.ts | 52 +- packages/language/test/helpers/diagnostics.ts | 15 +- .../language/test/helpers/parse-failure.ts | 11 +- packages/language/test/hover.test.ts | 5 +- packages/language/test/navigation.test.ts | 13 +- packages/language/test/parsing.test.ts | 760 +- packages/language/test/recovery-fuzz.test.ts | 36 +- packages/language/test/scoping.test.ts | 25 +- .../language/test/semantic-tokens.test.ts | 204 +- packages/language/test/symbols.test.ts | 9 +- packages/language/test/validating.test.ts | 3508 +++---- packages/language/test/variables.test.ts | 5 - .../language/test/vocabulary-sync.test.ts | 12 +- packages/transform/src/ast-to-ir.ts | 356 +- packages/transform/src/bpmn-moddle.d.ts | 14 +- packages/transform/src/cfg-analysis.ts | 322 +- packages/transform/src/errors.ts | 153 +- packages/transform/src/host-messages.ts | 27 +- packages/transform/src/humanize.ts | 5 +- packages/transform/src/index.ts | 10 +- packages/transform/src/ir-to-dsl.ts | 1461 +-- packages/transform/src/ir-to-xml.ts | 248 +- packages/transform/src/ir/types.ts | 354 +- packages/transform/src/juel.ts | 94 +- packages/transform/src/synthesize-ids.ts | 58 +- packages/transform/src/xml-to-ir.ts | 1019 +- packages/transform/test/ast-to-ir.test.ts | 3308 +------ packages/transform/test/cfg-analysis.test.ts | 113 +- packages/transform/test/form-fields.test.ts | 88 +- .../transform/test/helpers/ir-fixtures.ts | 60 +- packages/transform/test/helpers/ir-query.ts | 12 +- packages/transform/test/helpers/parse.ts | 18 + packages/transform/test/humanize.test.ts | 6 +- packages/transform/test/ir-to-dsl.test.ts | 6166 +++--------- .../test/ir-to-xml.layout-guard.test.ts | 37 +- packages/transform/test/ir-to-xml.test.ts | 3533 ++----- packages/transform/test/ir/types.test.ts | 515 +- packages/transform/test/juel.test.ts | 172 +- .../transform/test/operaton-moddle.test.ts | 44 +- .../transform/test/synthesize-ids.test.ts | 15 +- packages/transform/test/xml-to-ir.test.ts | 8286 +++-------------- tests/boundary-events.round-trip.test.ts | 164 +- tests/branch-and-race.round-trip.test.ts | 312 +- tests/call-activity.round-trip.test.ts | 567 +- tests/compensation.round-trip.test.ts | 112 +- tests/cyclic-loop-performance.test.ts | 10 +- tests/documentation.round-trip.test.ts | 40 +- tests/engine-attributes.round-trip.test.ts | 190 +- tests/event-handlers.round-trip.test.ts | 154 +- tests/event-positions.round-trip.test.ts | 104 +- tests/event-triggers.round-trip.test.ts | 122 +- tests/examples.health.test.ts | 170 +- tests/external-task.round-trip.test.ts | 89 +- tests/forms.round-trip.test.ts | 85 +- tests/fuzz-regressions.test.ts | 541 +- tests/fuzz.test.ts | 299 +- tests/gateway-settings.round-trip.test.ts | 143 +- tests/golden/README.md | 4 +- tests/goto-fallback.round-trip.test.ts | 544 +- tests/helpers/di-bounds.ts | 135 +- tests/helpers/fuzz-generator.ts | 110 +- tests/helpers/import-first.ts | 18 +- tests/helpers/ir-query.ts | 7 +- tests/helpers/model-equivalence.test.ts | 65 +- tests/helpers/model-equivalence.ts | 68 +- tests/helpers/normalize-ir.test.ts | 412 +- tests/helpers/normalize-ir.ts | 60 +- tests/helpers/pipeline.ts | 20 +- tests/helpers/real-node-reachability.ts | 7 +- tests/helpers/round-trip-fixture.ts | 172 +- tests/import-contract.round-trip.test.ts | 209 +- tests/input-output.round-trip.test.ts | 140 +- tests/intermediate-catch.round-trip.test.ts | 88 +- tests/invoice-receipt.import.test.ts | 70 +- tests/link-events.round-trip.test.ts | 184 +- tests/listeners.round-trip.test.ts | 88 +- tests/mail-and-shell.round-trip.test.ts | 142 +- tests/multiple-starts.round-trip.test.ts | 48 +- tests/nested-subprocess.round-trip.test.ts | 131 +- tests/new-constructs.round-trip.test.ts | 243 - tests/parse-performance.test.ts | 10 +- tests/readme-fences.test.ts | 11 +- tests/repetition.round-trip.test.ts | 197 +- tests/review-loop.round-trip.test.ts | 42 +- tests/round-trip-constructs.test.ts | 769 +- tests/round-trip.test.ts | 133 +- tests/task-kinds.round-trip.test.ts | 103 +- tests/transactions.round-trip.test.ts | 207 +- 127 files changed, 10829 insertions(+), 31381 deletions(-) delete mode 100644 packages/cli/test/util.test.ts create mode 100644 packages/transform/test/helpers/parse.ts delete mode 100644 tests/new-constructs.round-trip.test.ts diff --git a/docs/decisions/0025-events-at-the-process-boundary.md b/docs/decisions/0025-events-at-the-process-boundary.md index bf0d986..10c68a2 100644 --- a/docs/decisions/0025-events-at-the-process-boundary.md +++ b/docs/decisions/0025-events-at-the-process-boundary.md @@ -104,7 +104,7 @@ No source spells that shape, since `goto` names an authored statement and never `packages/language/test/parsing.test.ts` and `validating.test.ts` pin the two clauses token by token and every diagnostic behind them, and the validator rows pin every position a start may take, the chain error, and both warnings. `packages/transform/test/xml-to-ir.test.ts` pins each refusal by error class and message, `ast-to-ir.test.ts` and `ir-to-dsl.test.ts` the lowering and the printed line for every authored shape, and `packages/cli/test/decompile-contract.e2e.test.ts` that a refused trigger exits nonzero and writes no output file. -The frozen pair `tests/golden/event-positions.{bpmnscript,bpmn}` holds a message start, an `emit message`, a `throw message`, and a terminate end, and `tests/event-positions.round-trip.test.ts` compares the compiled XML byte for byte and requires an import with no warning at all; `tests/new-constructs.round-trip.test.ts` carries the timer, signal, and conditional starts, and the frozen pair `tests/golden/multiple-starts.{bpmnscript,bpmn}` holds four starts across two chains, its suite asserting at every hop that all four keep their trigger and none is the target of a sequence flow. +The frozen pair `tests/golden/event-positions.{bpmnscript,bpmn}` holds a message start, an `emit message`, a `throw message`, and a terminate end, and `tests/event-positions.round-trip.test.ts` compares the compiled XML byte for byte and requires an import with no warning at all; the frozen pair `tests/golden/event-triggers.{bpmnscript,bpmn}` carries the timer, signal, and conditional starts, and the frozen pair `tests/golden/multiple-starts.{bpmnscript,bpmn}` holds four starts across two chains, its suite asserting at every hop that all four keep their trigger and none is the target of a sequence flow. `tests/e2e/event-positions.test.ts` deploys to a real Operaton through Testcontainers: a correlated message with no instance to aim at creates one, a broadcast signal creates one with both branches active, a timer parks a job, the terminate cancels a sibling branch parked on a user task, and both thrown messages pass the token through to completion, which is what shows an implementation-free message throw deploys and runs. The same suite starts `support-ticket`, a process with a plain and a message start, once by key and once by message, and each instance's history holds exactly its own start and the shared task, which is the assertion a pass-through start would fail. diff --git a/package-lock.json b/package-lock.json index d2b2a0f..bcdea8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "devDependencies": { "@types/node": "^26.4.0", "@typescript-eslint/parser": "^8.69.0", + "@vitest/coverage-v8": "^4.1.11", "eslint": "^9.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-bpmn-io": "^2.3.1", @@ -208,6 +209,56 @@ "node": ">=20" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@balena/dockerignore": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", @@ -215,6 +266,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bpmn-script/cli": { "resolved": "packages/cli", "link": true @@ -1085,6 +1146,16 @@ "node": ">=12" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", @@ -1092,6 +1163,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", @@ -2452,6 +2534,37 @@ "win32" ] }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", @@ -3069,6 +3182,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -5792,6 +5924,13 @@ "node": ">=10" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -6387,6 +6526,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/istextorbinary": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", @@ -7116,6 +7294,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/marked": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.13.tgz", diff --git a/package.json b/package.json index 0d150b6..0ea6c49 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "devDependencies": { "@types/node": "^26.4.0", "@typescript-eslint/parser": "^8.69.0", + "@vitest/coverage-v8": "^4.1.11", "eslint": "^9.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-bpmn-io": "^2.3.1", diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 59df8c6..0cb26be 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -1,4 +1,8 @@ -import { BpmnScriptLanguageMetaData } from '@bpmn-script/language'; +import { + BpmnScriptLanguageMetaData, + Diagnostic, + DiagnosticSeverity, +} from '@bpmn-script/language'; import type { Model } from '@bpmn-script/language'; import chalk from 'chalk'; import * as path from 'node:path'; @@ -11,10 +15,8 @@ import { } from '@bpmn-script/transform'; import { CLI_VERSION, - SEVERITY_ERROR, - SEVERITY_WARNING, + type CommandOptions, buildDocument, - diagnosticMessage, fail, formatDiagnostic, guardOutputPath, @@ -24,20 +26,13 @@ import { writeOutput, } from './util.js'; -type BuildOptions = { - output?: string; - force?: boolean; -}; - export async function buildAction( fileName: string, - opts: BuildOptions, + opts: CommandOptions, ): Promise { const resolvedInput = resolveInputPath(fileName); - // The document loader picks its language service by extension, so a wrong - // one cannot merely warn: it fails a few lines later with an internal - // Langium message ("service registry contains no services"). + // A wrong extension fails later with an internal Langium "service registry contains no services". const extensions: readonly string[] = BpmnScriptLanguageMetaData.fileExtensions; if (!extensions.includes(path.extname(resolvedInput))) { @@ -59,7 +54,7 @@ export async function buildAction( } const errors = (document.diagnostics ?? []).filter( - (d) => d.severity === SEVERITY_ERROR, + (d) => d.severity === DiagnosticSeverity.Error, ); if (errors.length > 0) { console.error(chalk.red('Validation errors:')); @@ -69,22 +64,19 @@ export async function buildAction( process.exit(1); } - // Checked after the error gate: a keyword typo also parses into a model with - // no processes, and should report its parser error instead. + // After the error gate: a keyword typo also parses into a model with no processes. const ast = document.parseResult.value as Model; if (ast.processes.length === 0) fail(1, `Error: ${NO_PROCESS_MESSAGE}`); const warnings = (document.diagnostics ?? []).filter( - (d) => d.severity === SEVERITY_WARNING, + (d) => d.severity === DiagnosticSeverity.Warning, ); for (const diag of warnings) { warn( - `Warning: line ${diag.range.start.line + 1}: ${diagnosticMessage(diag)}`, + `Warning: line ${diag.range.start.line + 1}: ${Diagnostic.getMessageString(diag)}`, ); } - // The extension's conversion-core.ts and tests/helpers/pipeline.ts run the - // same astToIr -> irToXml chain, each with its own failure reporting. let ir; try { ir = astToIr(ast); @@ -99,8 +91,7 @@ export async function buildAction( if (!(err instanceof LayoutError)) { fail(1, `Error: ${(err as Error).message}`); } - // Operaton deploys the document without a diagram, so it is written - // rather than lost. + // Operaton deploys the document without a diagram. xml = err.xml; warn( `Warning: no diagram could be drawn for this process (${err.message}); the file deploys but opens without shapes in a modeler`, diff --git a/packages/cli/src/parse.ts b/packages/cli/src/parse.ts index 86e39ff..b01a83f 100644 --- a/packages/cli/src/parse.ts +++ b/packages/cli/src/parse.ts @@ -1,3 +1,4 @@ +import { DiagnosticSeverity } from '@bpmn-script/language'; import chalk from 'chalk'; import * as fs from 'node:fs/promises'; @@ -7,13 +8,14 @@ import { EMPTY_INPUT_MESSAGE, readableParseError, xmlInputProblem, + SERVICE_TASK_FORM_ATTRIBUTES, UnsupportedConstructError, UnsupportedServiceTaskFormError, UnsupportedElementError, } from '@bpmn-script/transform'; import type { ImportWarning, PrintWarning } from '@bpmn-script/transform'; import { - SEVERITY_ERROR, + type CommandOptions, buildDocument, fail, formatDiagnostic, @@ -24,14 +26,9 @@ import { writeOutput, } from './util.js'; -type ParseOptions = { - output?: string; - force?: boolean; -}; - export async function parseAction( fileName: string, - opts: ParseOptions, + opts: CommandOptions, ): Promise { const resolvedInput = resolveInputPath(fileName); const outPath = resolveOutputPath(resolvedInput, '.bpmnscript', opts.output); @@ -64,11 +61,7 @@ export async function parseAction( 1, `Error: unsupported ${err.subject.toLowerCase()} form in ${fileName}:\n` + ` ${err.message}\n` + - ' The attributes are operaton:class (or the deprecated camunda:class ' + - 'alias), operaton:expression, operaton:delegateExpression, ' + - 'operaton:type="external" with operaton:topic, ' + - 'operaton:type="mail" or "shell" with their operaton:field ' + - 'children, and operaton:decisionRef.', + ` The attributes are ${SERVICE_TASK_FORM_ATTRIBUTES}.`, ); } if (err instanceof UnsupportedElementError) { @@ -101,18 +94,15 @@ export async function parseAction( await writeOutput(outPath, dsl); console.log(chalk.green(`Parsed: ${outPath}`)); - // The id leads because several messages do not name their step, so two of - // them would otherwise print as the same line. + // Several messages do not name their step, so the id tells them apart. for (const w of [...warnings, ...printWarnings]) { warn(`Warning: ${w.elementId}: ${w.message}`); } - // The print hop never refuses and none of the warnings above run - // the validator, so a setting carried as written may still draw an error; - // building the written script the way `build` would is what catches it. + // Nothing above runs the validator, so rebuilding the written script is what catches an error. const rebuilt = await buildDocument(outPath); const buildErrors = (rebuilt.diagnostics ?? []).filter( - (d) => d.severity === SEVERITY_ERROR, + (d) => d.severity === DiagnosticSeverity.Error, ); if (buildErrors.length > 0) { warn( diff --git a/packages/cli/src/util.ts b/packages/cli/src/util.ts index 0ba80a1..3d67449 100644 --- a/packages/cli/src/util.ts +++ b/packages/cli/src/util.ts @@ -5,13 +5,7 @@ import chalk from 'chalk'; import type { LangiumDocument } from 'langium'; import { URI } from 'langium'; import { NodeFileSystem } from 'langium/node'; -import { createBpmnScriptServices } from '@bpmn-script/language'; - -type Diagnostic = NonNullable[number]; - -/** LSP's `DiagnosticSeverity` values. */ -export const SEVERITY_ERROR = 1; -export const SEVERITY_WARNING = 2; +import { createBpmnScriptServices, Diagnostic } from '@bpmn-script/language'; export function fail(code: number, message: string): never { console.error(chalk.red(message)); @@ -22,18 +16,7 @@ export function warn(message: string): void { console.error(chalk.yellow(message)); } -/** - * Langium's `message` is a string or LSP markup; a template literal would - * print the latter as `[object Object]`. - */ -export function diagnosticMessage(diagnostic: Diagnostic): string { - return typeof diagnostic.message === 'string' - ? diagnostic.message - : diagnostic.message.value; -} - -// Resolved relative to this module, which sits one level below the package -// root in both `out/` (compiled) and `src/` (vitest). +// One level below the package root in both `out/` and `src/`. export const CLI_VERSION: string = ( JSON.parse( readFileSync(new URL('../package.json', import.meta.url), 'utf-8'), @@ -50,7 +33,6 @@ export function resolveInputPath(fileName: string): string { return resolved; } -// The extension's `swapExtension` (conversion-core.ts) is the no-override case. export function resolveOutputPath( resolvedInput: string, defaultExt: string, @@ -61,19 +43,18 @@ export function resolveOutputPath( return path.join(path.dirname(resolvedInput), `${base}${defaultExt}`); } const resolved = path.resolve(outputOverride); - // `-o some/dir` names a place to put the file, not the file itself: without - // this, the write later fails with a bare EISDIR. + // `-o some/dir` names a place for the file. return existsSync(resolved) && statSync(resolved).isDirectory() ? path.join(resolved, `${base}${defaultExt}`) : resolved; } -type GuardOptions = { force?: boolean }; +export type CommandOptions = { output?: string; force?: boolean }; export function guardOutputPath( resolvedInput: string, outPath: string, - opts: GuardOptions, + opts: CommandOptions, ): void { if (outPath === resolvedInput) { fail(2, 'Error: the input and the output are the same file'); @@ -120,7 +101,7 @@ export function formatDiagnostic( diagnostic: Diagnostic, ): string { return ( - ` line ${diagnostic.range.start.line + 1}: ${diagnosticMessage(diagnostic)}` + + ` line ${diagnostic.range.start.line + 1}: ${Diagnostic.getMessageString(diagnostic)}` + ` [${document.textDocument.getText(diagnostic.range)}]` ); } diff --git a/packages/cli/test/build-parse.smoke.test.ts b/packages/cli/test/build-parse.smoke.test.ts index 2057556..19640de 100644 --- a/packages/cli/test/build-parse.smoke.test.ts +++ b/packages/cli/test/build-parse.smoke.test.ts @@ -1,14 +1,9 @@ -// What `bpmns build` and `bpmns parse` make of a file: the exit code, what -// lands on stderr, and what is written. The decompile contract itself, and the -// fixtures that carry it, are in `decompile-contract.e2e.test.ts`. - import { describe, test, expect, beforeAll } from 'vitest'; import * as fs from 'node:fs'; import * as fsp from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; import { EmptyFileSystem } from 'langium'; import { parseHelper } from 'langium/test'; @@ -23,14 +18,11 @@ import { runParse, type Input, } from './helpers/actions.js'; +import type { CommandOptions } from '../src/util.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const REPO_ROOT = path.resolve(__dirname, '../../..'); +const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)); -// Read from package.json rather than imported from util.ts: that export is -// what the exporterVersion assertion pins. +// Read here rather than imported: util.ts's export is what this pins. const PACKAGE_VERSION = ( JSON.parse( fs.readFileSync( @@ -133,7 +125,6 @@ const DECIMAL_DEFAULT_BPMN = ` type ParseExpectation = { /** The id of every `Warning: : ...` line, in the order printed. */ warningIds: string[]; - /** Substrings the warnings must carry; the wording is the transform's own. */ mentions?: string[]; script?: string[]; /** Set where the warnings differ in nothing but the element they name. */ @@ -254,7 +245,6 @@ const TYPE_MISMATCH_SOURCE = `process type-mismatch { type BuildExpectation = { exit?: number; - /** The process id the written BPMN imports back under, if one was written. */ reimportsAs?: string; stderrLines: number; mentions?: string[]; @@ -327,65 +317,121 @@ const VALID_BPMN = ` `; -// Over 40 characters, so the preview's cutoff shows. +// Over 40 characters, so the preview's cutoff shows, and accented, which the +// preview keeps. const SWAPPED_DSL = - 'process invoice_approval { start S user Review(assignee: "demo") end E }'; + 'process rechnung_prüfen { start S user Prüfung(assignee: "demo") end E }'; -// Over 40 characters of accented text, which the preview keeps. -const NON_ASCII_PREVIEW_SOURCE = - 'prüfen, ob Umlaute überleben und nicht nur ASCII-Zeichen übrig bleiben'; +const NOT_XML = (preview: string) => + `Error: not an XML document (starts with "${preview}"); ` + + 'a .bpmnscript file is built with `bpmns build`'; type GuardCase = { inputPath: string; - opts: { output?: string; force?: boolean }; + opts: CommandOptions; exit: number | undefined; /** The first stderr line, where the row expects one. */ line?: string; check?: () => void; }; +type Action = 'build' | 'parse'; + type GuardRow = readonly [ title: string, - action: 'build' | 'parse', + action: Action, make: (dir: string) => GuardCase, ]; -// The path guards are one mechanism in `util.ts`, so each has a row per -// command to pin that both call it. +const COMMAND_FILES = { + build: { inExt: '.bpmnscript', outExt: '.bpmn', source: VALID_DSL }, + parse: { inExt: '.bpmn', outExt: '.bpmnscript', source: VALID_BPMN }, +}; + +const WRITTEN: Record = { + build: ' + (dir: string): GuardCase => { + const { inputPath, outputPath } = writeInput(dir, action, true); + return { + inputPath, + opts: {}, + exit: 2, + line: `Error: ${outputPath} exists; pass --force to overwrite it or -o for another path`, + check: () => + expect(fs.readFileSync(outputPath, 'utf-8')).toBe('PRE-EXISTING'), + }; + }; + +/** One input file written fresh and run with no options. */ +const refusedFile = ( + title: string, + action: Action, + name: string, + content: string | Buffer, + exit: number, + line: string | ((inputPath: string) => string), +): GuardRow => [ + title, + action, + (dir) => { + const inputPath = path.join(dir, name); + fs.writeFileSync(inputPath, content); + return { + inputPath, + opts: {}, + exit, + line: typeof line === 'string' ? line : line(inputPath), + }; + }, +]; + +const BINARY = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0x00]); + +// `saxen` echoes the whole unparsed remainder, the NUL included, into one +// line, then appends `\n\tline: ...`, which the cutoff must never reach. +const UNCLOSED_TAG = ' { test.each([ [ 'build: an existing output is refused without --force, and is left untouched', 'build', - (dir) => { - const inputPath = path.join(dir, 'order.bpmnscript'); - fs.writeFileSync(inputPath, VALID_DSL, 'utf-8'); - const outputPath = path.join(dir, 'order.bpmn'); - fs.writeFileSync(outputPath, 'PRE-EXISTING', 'utf-8'); - return { - inputPath, - opts: {}, - exit: 2, - line: `Error: ${outputPath} exists; pass --force to overwrite it or -o for another path`, - check: () => - expect(fs.readFileSync(outputPath, 'utf-8')).toBe('PRE-EXISTING'), - }; - }, + refusesExisting('build'), + ], + [ + 'parse: an existing output is refused without --force, and is left untouched', + 'parse', + refusesExisting('parse'), ], [ 'build: --force overwrites an existing output', 'build', (dir) => { - const inputPath = path.join(dir, 'order.bpmnscript'); - fs.writeFileSync(inputPath, VALID_DSL, 'utf-8'); - const outputPath = path.join(dir, 'order.bpmn'); - fs.writeFileSync(outputPath, 'PRE-EXISTING', 'utf-8'); + const { inputPath, outputPath } = writeInput(dir, 'build', true); return { inputPath, opts: { force: true }, exit: undefined, check: () => - expect(fs.readFileSync(outputPath, 'utf-8')).toContain(' { - const inputPath = path.join(dir, 'same.bpmnscript'); - fs.writeFileSync(inputPath, VALID_DSL, 'utf-8'); + const { inputPath, source } = writeInput(dir, 'build'); return { inputPath, opts: { output: inputPath }, exit: 2, line: 'Error: the input and the output are the same file', - check: () => - expect(fs.readFileSync(inputPath, 'utf-8')).toBe(VALID_DSL), + check: () => expect(fs.readFileSync(inputPath, 'utf-8')).toBe(source), }; }, ], @@ -419,8 +463,7 @@ describe('bpmns build / bpmns parse: guards against destructive or unclear failu 'build: an -o naming a directory writes inside it under the default basename', 'build', (dir) => { - const inputPath = path.join(dir, 'order.bpmnscript'); - fs.writeFileSync(inputPath, VALID_DSL, 'utf-8'); + const { inputPath } = writeInput(dir, 'build'); const outDir = path.join(dir, 'out'); fs.mkdirSync(outDir); return { @@ -430,262 +473,96 @@ describe('bpmns build / bpmns parse: guards against destructive or unclear failu check: () => expect( fs.readFileSync(path.join(outDir, 'order.bpmn'), 'utf-8'), - ).toContain(' { - const inputPath = path.join(dir, 'order.txt'); - fs.writeFileSync(inputPath, VALID_DSL, 'utf-8'); - return { - inputPath, - opts: {}, - exit: 2, - line: - 'Error: expected a file with one of these extensions: .bpmnscript; ' + - 'a .bpmn file is decompiled with `bpmns parse`', - }; - }, - ], - [ + 'order.txt', + VALID_DSL, + 2, + 'Error: expected a file with one of these extensions: .bpmnscript; ' + + 'a .bpmn file is decompiled with `bpmns parse`', + ), + refusedFile( 'build: an empty source is reported plainly, without a pipeline stage name', 'build', - (dir) => { - const inputPath = path.join(dir, 'empty.bpmnscript'); - fs.writeFileSync(inputPath, ' \n \n', 'utf-8'); - return { - inputPath, - opts: {}, - exit: 1, - line: 'Error: the file has no process', - }; - }, - ], - [ + 'empty.bpmnscript', + ' \n \n', + 1, + 'Error: the file has no process', + ), + refusedFile( 'build: a comment-only source is reported the same way, not as an astToIr internal error', 'build', - (dir) => { - const inputPath = path.join(dir, 'comment-only.bpmnscript'); - fs.writeFileSync(inputPath, '// nothing but a comment here\n', 'utf-8'); - return { - inputPath, - opts: {}, - exit: 1, - line: 'Error: the file has no process', - }; - }, - ], - [ - 'parse: an existing output is refused without --force, and is left untouched', - 'parse', - (dir) => { - const inputPath = path.join(dir, 'order.bpmn'); - fs.writeFileSync(inputPath, VALID_BPMN, 'utf-8'); - const outputPath = path.join(dir, 'order.bpmnscript'); - fs.writeFileSync(outputPath, 'PRE-EXISTING', 'utf-8'); - return { - inputPath, - opts: {}, - exit: 2, - line: `Error: ${outputPath} exists; pass --force to overwrite it or -o for another path`, - check: () => - expect(fs.readFileSync(outputPath, 'utf-8')).toBe('PRE-EXISTING'), - }; - }, - ], - [ - 'parse: --force overwrites an existing output', - 'parse', - (dir) => { - const inputPath = path.join(dir, 'order.bpmn'); - fs.writeFileSync(inputPath, VALID_BPMN, 'utf-8'); - const outputPath = path.join(dir, 'order.bpmnscript'); - fs.writeFileSync(outputPath, 'PRE-EXISTING', 'utf-8'); - return { - inputPath, - opts: { force: true }, - exit: undefined, - check: () => - expect(fs.readFileSync(outputPath, 'utf-8')).toContain( - 'process guard', - ), - }; - }, - ], - [ - 'parse: an -o equal to the input path is refused, and the source is left untouched', - 'parse', - (dir) => { - const inputPath = path.join(dir, 'same.bpmn'); - fs.writeFileSync(inputPath, VALID_BPMN, 'utf-8'); - return { - inputPath, - opts: { output: inputPath }, - exit: 2, - line: 'Error: the input and the output are the same file', - check: () => - expect(fs.readFileSync(inputPath, 'utf-8')).toBe(VALID_BPMN), - }; - }, - ], - [ - 'parse: a directory input is refused', - 'parse', - (dir) => ({ - inputPath: dir, - opts: {}, - exit: 2, - line: `Error: ${dir} is a directory`, - }), - ], - [ - 'parse: an -o naming a directory writes inside it under the default basename', - 'parse', - (dir) => { - const inputPath = path.join(dir, 'order.bpmn'); - fs.writeFileSync(inputPath, VALID_BPMN, 'utf-8'); - const outDir = path.join(dir, 'out'); - fs.mkdirSync(outDir); - return { - inputPath, - opts: { output: outDir }, - exit: undefined, - check: () => - expect( - fs.readFileSync(path.join(outDir, 'order.bpmnscript'), 'utf-8'), - ).toContain('process guard'), - }; - }, - ], - [ + 'comment-only.bpmnscript', + '// nothing but a comment here\n', + 1, + 'Error: the file has no process', + ), + refusedFile( 'parse: an empty file is reported plainly, not as an XML parse failure', 'parse', - (dir) => { - const inputPath = path.join(dir, 'empty.bpmn'); - fs.writeFileSync(inputPath, '', 'utf-8'); - return { - inputPath, - opts: {}, - exit: 2, - line: 'Error: the file is empty', - }; - }, - ], - [ + 'empty.bpmn', + '', + 2, + 'Error: the file is empty', + ), + refusedFile( 'parse: binary input is reported, not dumped onto stderr', 'parse', - (dir) => { - const inputPath = path.join(dir, 'binary.bpmn'); - const bytes = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0x00]); - fs.writeFileSync(inputPath, bytes); - // 0xff/0xfe are not valid UTF-8 and decode to U+FFFD, which is not a - // control character, so the preview keeps them. - const preview = bytes.toString('utf-8').replace(/\p{C}/gu, ''); - return { - inputPath, - opts: {}, - exit: 2, - line: - `Error: not an XML document (starts with "${preview}"); ` + - 'a .bpmnscript file is built with `bpmns build`', - }; - }, - ], - [ - 'parse: an accented preview keeps its own characters, only control bytes are stripped', - 'parse', - (dir) => { - const inputPath = path.join(dir, 'umlaut.bpmn'); - fs.writeFileSync(inputPath, NON_ASCII_PREVIEW_SOURCE, 'utf-8'); - return { - inputPath, - opts: {}, - exit: 2, - line: - `Error: not an XML document (starts with "${NON_ASCII_PREVIEW_SOURCE.slice(0, 40)}"); ` + - 'a .bpmnscript file is built with `bpmns build`', - }; - }, - ], - [ - 'parse: DSL text given by mistake is reported, not echoed back whole', + 'binary.bpmn', + BINARY, + 2, + // 0xff/0xfe decode to U+FFFD, which is not a control character. + NOT_XML(BINARY.toString('utf-8').replace(/\p{C}/gu, '')), + ), + refusedFile( + 'parse: DSL text given by mistake is reported by a preview that keeps its accented characters, not echoed back whole', 'parse', - (dir) => { - const inputPath = path.join(dir, 'swapped.bpmn'); - fs.writeFileSync(inputPath, SWAPPED_DSL, 'utf-8'); - return { - inputPath, - opts: {}, - exit: 2, - line: - `Error: not an XML document (starts with "${SWAPPED_DSL.slice(0, 40)}"); ` + - 'a .bpmnscript file is built with `bpmns build`', - }; - }, - ], - [ + 'swapped.bpmn', + SWAPPED_DSL, + 2, + NOT_XML(SWAPPED_DSL.slice(0, 40)), + ), + refusedFile( 'parse: a non-BPMN root names the tag and namespace it expected', 'parse', - (dir) => { - const inputPath = path.join(dir, 'not-definitions.bpmn'); - fs.writeFileSync(inputPath, 'hi', 'utf-8'); - return { - inputPath, - opts: {}, - exit: 2, - line: - `Error: failed to parse ${inputPath}: failed to parse document as ` + - '(root element is ; expected in namespace ' + - 'http://www.omg.org/spec/BPMN/20100524/MODEL)', - }; - }, - ], - [ + 'not-definitions.bpmn', + 'hi', + 2, + (inputPath) => + `Error: failed to parse ${inputPath}: failed to parse document as ` + + '(root element is ; expected in namespace ' + + 'http://www.omg.org/spec/BPMN/20100524/MODEL)', + ), + refusedFile( 'parse: a wrong bpmn namespace names the namespace it found', 'parse', - (dir) => { - const inputPath = path.join(dir, 'wrong-ns.bpmn'); - fs.writeFileSync( - inputPath, - '' + - '', - 'utf-8', - ); - return { - inputPath, - opts: {}, - exit: 2, - line: - `Error: failed to parse ${inputPath}: failed to parse document as ` + - '(root element is ; expected in namespace ' + - 'http://www.omg.org/spec/BPMN/20100524/MODEL; found xmlns:bpmn=http://example.com/wrong)', - }; - }, - ], - [ + 'wrong-ns.bpmn', + '' + + '', + 2, + (inputPath) => + `Error: failed to parse ${inputPath}: failed to parse document as ` + + '(root element is ; expected in namespace ' + + 'http://www.omg.org/spec/BPMN/20100524/MODEL; found xmlns:bpmn=http://example.com/wrong)', + ), + refusedFile( 'parse: an unclosed tag with no closing bracket anywhere is cut to its first line, capped at 200 characters, with any control byte stripped', 'parse', - (dir) => { - const inputPath = path.join(dir, 'unclosed.bpmn'); - // `saxen` echoes the whole unparsed remainder, the NUL included, into - // this one line, then appends `\n\tline: ...`, which the cutoff must - // never reach. - const unclosedTag = ' + `Error: failed to parse ${inputPath}: ` + + ( + `unparsable content ${UNCLOSED_TAG} detected`.slice(0, 200) + '...' + ).replace(/\p{C}/gu, ''), + ), ])('%s', async (_title, action, make) => { const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bpmns-guard-')); try { diff --git a/packages/cli/test/build.layout-guard.test.ts b/packages/cli/test/build.layout-guard.test.ts index 113332a..37815a2 100644 --- a/packages/cli/test/build.layout-guard.test.ts +++ b/packages/cli/test/build.layout-guard.test.ts @@ -1,6 +1,4 @@ -// `bpmn-auto-layout` is mocked rather than crashed for real (transform's -// `ir-to-xml.layout-guard.test.ts` does that), and `vi.mock` applies to the -// whole file, so the fallback gets a file of its own. +// Its own file because `vi.mock` applies to the whole file. import { describe, it, expect, vi } from 'vitest'; vi.mock('bpmn-auto-layout', () => ({ diff --git a/packages/cli/test/decompile-contract.e2e.test.ts b/packages/cli/test/decompile-contract.e2e.test.ts index d37ab5a..4581332 100644 --- a/packages/cli/test/decompile-contract.e2e.test.ts +++ b/packages/cli/test/decompile-contract.e2e.test.ts @@ -1,32 +1,25 @@ -// The decompile contract on the two fixtures that carry it: what the import -// keeps, what it warns about, what it refuses, and that the script it hands -// back compiles again. `build-parse.smoke.test.ts` covers the commands -// themselves. - -import { describe, it, test, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; import { EmptyFileSystem } from 'langium'; -import { parseHelper, validationHelper } from 'langium/test'; -import { createBpmnScriptServices } from '@bpmn-script/language'; -import type { Model } from '@bpmn-script/language'; +import { validationHelper } from 'langium/test'; +import { + createBpmnScriptServices, + Diagnostic, + intoBranchMessage, + type Model, +} from '@bpmn-script/language'; import { xmlToIr, irToDsl, - UnsupportedConstructError, UnsupportedEventFeatureError, } from '@bpmn-script/transform'; -import { diagnosticMessage } from '../src/util.js'; import { runBuild, runParse, expectMentions } from './helpers/actions.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const REPO_ROOT = path.resolve(__dirname, '../../..'); +const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)); const LANES_AND_ASYNC_BPMN = path.resolve( REPO_ROOT, @@ -38,7 +31,6 @@ const ERROR_START_BPMN = path.resolve( 'tests/fixtures/error-start.bpmn', ); -/** A labeled start and end under the given ids, in process `generated-id-labels`. */ const labeledTerminalsBpmn = ( startId: string, endId: string, @@ -67,21 +59,18 @@ function assertNoForbiddenJargon(text: string): void { } } -let parse: ReturnType>; let validate: ReturnType>; beforeAll(() => { const services = createBpmnScriptServices(EmptyFileSystem); - parse = parseHelper(services.BpmnScript); validate = validationHelper(services.BpmnScript); }); -describe('decompile contract: what the import makes of a fixture', () => { - it('the lanes-and-async fixture imports into the supported subset, keeping the engine settings the IR carries and warning once per dropped item', async () => { - const xml = fs.readFileSync(LANES_AND_ASYNC_BPMN, 'utf-8'); - const { ir, warnings } = await xmlToIr(xml); - - expect(ir.id).toBe('lanes-and-async'); +describe('decompile contract on the fixtures', () => { + it('the lanes-and-async fixture imports into the supported subset, warns once per dropped item, and its script builds and re-imports cleanly', async () => { + const { ir, warnings } = await xmlToIr( + fs.readFileSync(LANES_AND_ASYNC_BPMN, 'utf-8'), + ); expect(ir.flowElements.map((fe) => fe.kind)).toEqual([ 'startEvent', 'userTask', @@ -90,118 +79,61 @@ describe('decompile contract: what the import makes of a fixture', () => { const task = ir.flowElements.find((fe) => fe.kind === 'userTask'); expect(task?.kind === 'userTask' && task.assignee).toBe('demo'); expect(task?.kind === 'userTask' && task.asyncBefore).toBe(true); - // operaton:properties is a declared type, so moddle ties the drop to the // task that carries it rather than to the process. expect(warnings.map((w) => [w.elementId, w.category])).toEqual([ ['Lane_Ops', 'lane'], ['ReviewRequest', 'extensionAttribute'], ]); - expectMentions(warnings.map((w) => w.message).join('\n'), [ - 'operaton:properties', - ]); - }); - it('the error-start fixture is refused with UnsupportedEventFeatureError naming the offending start event, with no BPMN jargon', async () => { - const xml = fs.readFileSync(ERROR_START_BPMN, 'utf-8'); + const parsed = await runParse({ file: LANES_AND_ASYNC_BPMN }); + expect(parsed.exit).toBeUndefined(); + expect( + parsed.stderr.map((line) => /^Warning: ([^:]+): /.exec(line)?.[1]), + ).toEqual(['Lane_Ops', 'ReviewRequest']); + expectMentions(parsed.stderr.join('\n'), ['operaton:properties']); + const dsl = parsed.output!; + expectMentions(dsl, [ + 'process lanes-and-async', + 'start ReviewStart', + 'user ReviewRequest', + 'assignee: "demo"', + 'end ReviewDone', + ]); - await expect(xmlToIr(xml)).rejects.toBeInstanceOf( - UnsupportedEventFeatureError, - ); + const { document, diagnostics } = await validate(dsl); + expect(document.parseResult.parserErrors).toHaveLength(0); + expect(diagnostics).toHaveLength(0); - try { - await xmlToIr(xml); - expect.fail('Should have thrown'); - } catch (err) { - expect(err).toBeInstanceOf(UnsupportedConstructError); - const e = err as UnsupportedEventFeatureError; - expect(e.elementId).toBe('ShipmentFailed'); - expect(e.message).toContain('ShipmentFailed'); - expect(e.message).toContain("Catch it with 'on error'"); - assertNoForbiddenJargon(e.message); - } + const built = await runBuild({ text: dsl }); + expect(built.exit).toBeUndefined(); + expect(built.stderr).toEqual([]); + expect((await xmlToIr(built.output!)).ir.id).toBe('lanes-and-async'); }); -}); - -type ParseExpectation = - | { - /** Ran to the end: the ids of the warning lines, in the order printed. */ - exit?: undefined; - warningIds: string[]; - mentions: string[]; - script: string[]; - } - | { exit: number; mentions: string[] }; -type ParseRow = readonly [ - title: string, - fixture: string, - expected: ParseExpectation, -]; + it('the error-start fixture is refused, by the import and by `bpmns parse` with exit 1 and nothing written, naming the start event without BPMN jargon', async () => { + const err: unknown = await xmlToIr( + fs.readFileSync(ERROR_START_BPMN, 'utf-8'), + ).catch((e: unknown) => e); + expect(err).toBeInstanceOf(UnsupportedEventFeatureError); + expect((err as UnsupportedEventFeatureError).elementId).toBe( + 'ShipmentFailed', + ); -describe('decompile contract: what `bpmns parse` does with the same fixtures', () => { - test.each([ - [ - 'a dropped lane and a dropped engine attribute are printed as warnings, and the script is written anyway', - LANES_AND_ASYNC_BPMN, - { - warningIds: ['Lane_Ops', 'ReviewRequest'], - mentions: ['operaton:properties'], - script: [ - 'process lanes-and-async', - 'start ReviewStart', - 'user ReviewRequest', - 'assignee: "demo"', - 'end ReviewDone', - ], - }, - ], - [ - 'a refused construct exits 1, writes nothing, and says which element it was', - ERROR_START_BPMN, - // 1 means unsupported construct; 2 would mean I/O or generic failure. - { exit: 1, mentions: ['ShipmentFailed', 'on error'] }, - ], - ])('%s', async (_title, fixture, expected) => { - const run = await runParse({ file: fixture }); + // 1 means unsupported construct; 2 would mean I/O or generic failure. + const run = await runParse({ file: ERROR_START_BPMN }); + expect(run.exit).toBe(1); + expect(run.output).toBeUndefined(); const stderr = run.stderr.join('\n'); - - expect(run.exit).toBe(expected.exit); - expectMentions(stderr, expected.mentions); - - if (expected.exit !== undefined) { - // A refusal is the one message the author has to act on, so it stays in - // the vocabulary the DSL uses. - assertNoForbiddenJargon(stderr); - expect(run.output).toBeUndefined(); - return; - } - - expect( - run.stderr.map((line) => /^Warning: ([^:]+): /.exec(line)?.[1]), - ).toEqual(expected.warningIds); - expectMentions(run.output ?? '', expected.script); + expectMentions(stderr, ['ShipmentFailed', "Catch it with 'on error'"]); + assertNoForbiddenJargon(stderr); }); }); describe('decompile contract: the script it hands back goes through the pipeline again', () => { - it('the DSL produced from the lanes-and-async fixture re-parses with zero parser errors and zero validation diagnostics', async () => { - const xml = fs.readFileSync(LANES_AND_ASYNC_BPMN, 'utf-8'); - const { ir } = await xmlToIr(xml); - const dsl = irToDsl(ir).source; - - const document = await parse(dsl, { validation: true }); - expect(document.parseResult.parserErrors).toHaveLength(0); - - const { diagnostics } = await validate(dsl); - expect(diagnostics).toHaveLength(0); - }); - - // Only the exact ids the compiler generates for the process are left out of - // the script; a modeller's defaults (`StartEvent_1`) are names like any - // other. A start with nowhere else to carry a label still drops it, warned - // at import; an end always has the statement to carry one, so it prints - // and is refused on the name alone instead, its label never lost. + // Only the ids the compiler generates are left out; a modeller's defaults + // are names like any other. A generated-id start drops its label with an + // import warning, a generated-id end prints and is refused on the name. it.each([ [ "a modelling tool's default ids keep their statements and labels", @@ -237,10 +169,8 @@ describe('decompile contract: the script it hands back goes through the pipeline ); const dsl = irToDsl(ir).source; - const document = await parse(dsl, { validation: true }); + const { document, diagnostics } = await validate(dsl); expect(document.parseResult.parserErrors).toHaveLength(0); - - const { diagnostics } = await validate(dsl); expect(diagnostics).toHaveLength(diagnosticCount); expect( dsl.split('\n').filter((line) => /^\s+(start|end) /.test(line)), @@ -256,17 +186,6 @@ describe('decompile contract: the script it hands back goes through the pipeline } }, ); - - it('the DSL produced from the lanes-and-async fixture builds again without validation errors, and the rebuilt BPMN re-imports cleanly', async () => { - const xml = fs.readFileSync(LANES_AND_ASYNC_BPMN, 'utf-8'); - const { ir } = await xmlToIr(xml); - - const run = await runBuild({ text: irToDsl(ir).source }); - - expect(run.exit).toBeUndefined(); - expect(run.stderr).toEqual([]); - expect((await xmlToIr(run.output!)).ir.id).toBe('lanes-and-async'); - }); }); describe('decompile contract: language integrity', () => { @@ -288,22 +207,17 @@ process Second { const { document, diagnostics } = await validate(source); expect(document.parseResult.parserErrors).toHaveLength(0); - expect(diagnostics).toHaveLength(2); for (const d of diagnostics) { expect(d.severity).toBe(1); - assertNoForbiddenJargon(diagnosticMessage(d)); + assertNoForbiddenJargon(Diagnostic.getMessageString(d)); } - - const extraProcess = diagnostics.find((d) => - diagnosticMessage(d).includes('Only one process is supported'), - ); - expect(extraProcess).toBeDefined(); - - const gotoIntoParallel = diagnostics.find((d) => - diagnosticMessage(d).toLowerCase().includes('branch'), - ); - expect(gotoIntoParallel && diagnosticMessage(gotoIntoParallel)).toContain( - 'A', + expect( + diagnostics.map((d) => Diagnostic.getMessageString(d)).sort(), + ).toEqual( + [ + 'Only one process is supported per file. Move additional processes into separate files.', + intoBranchMessage('goto A', 'goto', 'parallel'), + ].sort(), ); }); }); diff --git a/packages/cli/test/helpers/actions.ts b/packages/cli/test/helpers/actions.ts index eb0b4f4..51ef165 100644 --- a/packages/cli/test/helpers/actions.ts +++ b/packages/cli/test/helpers/actions.ts @@ -1,7 +1,3 @@ -// Runs a command the way `bpmns` does, minus the shell: a real file in and a -// real file out, with the exit code, the stderr lines and the written output -// handed back instead of leaving the process. - import * as fs from 'node:fs'; import * as fsp from 'node:fs/promises'; import * as os from 'node:os'; @@ -11,9 +7,9 @@ import { expect, vi } from 'vitest'; import { buildAction } from '../../src/build.js'; import { parseAction } from '../../src/parse.js'; +import type { CommandOptions } from '../../src/util.js'; -// Captured stderr is compared line for line, so the colouring must not vary -// with whether the runner happens to own a terminal. +// Stderr is compared line for line, so colour must not depend on a TTY. chalk.level = 0; class ExitCalled extends Error { @@ -32,11 +28,9 @@ export type ActionRun = { export type Input = { text: string } | { file: string }; -export type ActionOptions = { output?: string; force?: boolean }; - type Action = ( fileName: string, - opts: ActionOptions, + opts: CommandOptions, ) => Promise; async function capture( @@ -48,8 +42,7 @@ async function capture( stderr.push(String(args[0])); }), vi.spyOn(console, 'log').mockImplementation(() => {}), - // Throws so the action stops where `process.exit` would have; a no-op - // mock would let it run on. + // A no-op mock would let the action run on past the exit. vi.spyOn(process, 'exit').mockImplementation((code?: unknown) => { throw new ExitCalled(typeof code === 'number' ? code : 0); }), @@ -98,14 +91,11 @@ async function run( } } -/** - * For the guard tests, which lay out their own files: no path is invented or - * cleaned up here, so a test can assert on the exact file it set up. - */ +/** Runs on caller-owned paths: nothing is invented or cleaned up. */ export const runActionAt = ( action: 'build' | 'parse', inputPath: string, - opts: ActionOptions, + opts: CommandOptions, ): Promise> => capture(() => (action === 'build' ? buildAction : parseAction)(inputPath, opts), diff --git a/packages/cli/test/util.test.ts b/packages/cli/test/util.test.ts deleted file mode 100644 index ecdc720..0000000 --- a/packages/cli/test/util.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { diagnosticMessage, resolveOutputPath } from '../src/util.js'; - -describe('resolveOutputPath', () => { - it.each([ - [ - 'no override: the default extension replaces the input extension', - '/work/invoice-approval.bpmnscript', - undefined, - '/work/invoice-approval.bpmn', - ], - [ - 'only the final extension goes, so a dotted basename survives', - '/work/my.invoice.bpmnscript', - undefined, - '/work/my.invoice.bpmn', - ], - [ - 'an override is taken verbatim, resolved from cwd', - '/work/invoice-approval.bpmnscript', - 'out/custom.bpmn', - 'out/custom.bpmn', - ], - ] as const)('%s', (_title, input, override, expected) => { - expect(resolveOutputPath(path.resolve(input), '.bpmn', override)).toBe( - path.resolve(expected), - ); - }); - - it('an override naming an existing directory writes inside it under the input basename', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bpmns-util-')); - try { - const input = path.join(dir, 'invoice-approval.bpmnscript'); - expect(resolveOutputPath(input, '.bpmn', dir)).toBe( - path.join(dir, 'invoice-approval.bpmn'), - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); -}); - -describe('diagnosticMessage', () => { - const RANGE = { - start: { line: 0, character: 0 }, - end: { line: 0, character: 4 }, - }; - - it.each([ - ['plain text', 'no such step'], - ['markup', { kind: 'markdown', value: 'no such step' }], - ] as const)('reads a %s message as its text', (_kind, message) => { - expect(diagnosticMessage({ range: RANGE, message })).toBe('no such step'); - }); -}); diff --git a/packages/extension/src/extension/conversion-core.ts b/packages/extension/src/extension/conversion-core.ts index 599664c..fdd2c0c 100644 --- a/packages/extension/src/extension/conversion-core.ts +++ b/packages/extension/src/extension/conversion-core.ts @@ -1,7 +1,10 @@ -// No `vscode` import here: the unit tests run this without an editor host; -// `conversion.ts` is the VS Code adapter. +// No `vscode` import, so unit tests run this without an editor host. -import { createBpmnScriptServices } from '@bpmn-script/language'; +import { + createBpmnScriptServices, + Diagnostic, + DiagnosticSeverity, +} from '@bpmn-script/language'; import type { Model } from '@bpmn-script/language'; import { EmptyFileSystem, URI } from 'langium'; import * as path from 'node:path'; @@ -18,7 +21,6 @@ import { } from '@bpmn-script/transform'; import type { ImportWarning, PrintWarning } from '@bpmn-script/transform'; -// Positions are 0-based, LSP convention. export interface ConvDiagnostic { line: number; character: number; @@ -39,11 +41,6 @@ export type DecompileResult = | { ok: false; kind: 'unsupported'; message: string } | { ok: false; kind: 'error'; message: string }; -// LSP's `DiagnosticSeverity.Error`; `vscode-languageserver-types` is not a -// dependency of this package. -const SEVERITY_ERROR = 1; - -// Built once per module load: creating the services is expensive. const { shared } = createBpmnScriptServices(EmptyFileSystem); let nextDocId = 0; @@ -54,8 +51,7 @@ export async function compileDslToBpmn( ): Promise { const uri = URI.parse(`memory:///conv-${nextDocId++}.bpmnscript`); - // Registered so the DocumentBuilder resolves cross-references, removed - // afterwards so the index does not grow with every call. + // Registered so cross-references resolve, removed so the index does not grow. const doc = shared.workspace.LangiumDocumentFactory.fromString( source, uri, @@ -66,7 +62,7 @@ export async function compileDslToBpmn( await shared.workspace.DocumentBuilder.build([doc], { validation: true }); const errors = (doc.diagnostics ?? []).filter( - (d) => d.severity === SEVERITY_ERROR, + (d) => d.severity === DiagnosticSeverity.Error, ); if (errors.length > 0) { const diagnostics: ConvDiagnostic[] = errors.map((d) => ({ @@ -74,21 +70,18 @@ export async function compileDslToBpmn( character: d.range.start.character, endLine: d.range.end.line, endCharacter: d.range.end.character, - message: typeof d.message === 'string' ? d.message : d.message.value, - severity: SEVERITY_ERROR, + message: Diagnostic.getMessageString(d), + severity: DiagnosticSeverity.Error, text: doc.textDocument.getText(d.range), })); return { ok: false, kind: 'validation', diagnostics }; } - // Checked after the error gate: a keyword typo also parses into a model - // with no processes, and should report its parser error instead. + // After the error gate: a keyword typo also parses into a model with no processes. if (doc.parseResult.value.processes.length === 0) { return { ok: false, kind: 'error', message: NO_PROCESS_MESSAGE }; } - // The cli's build.ts and tests/helpers/pipeline.ts run the same - // astToIr -> irToXml chain, each with its own failure reporting. let ir; try { ir = astToIr(doc.parseResult.value); @@ -149,8 +142,7 @@ export async function decompileBpmnToDsl( if (err instanceof UnsupportedConstructError) { return { ok: false, kind: 'unsupported', message: err.message }; } - // Straight into a notification otherwise: a parser error quotes the rest - // of the document, which for a large file is the whole file. + // A parser error quotes the rest of the document, too long for a notification. return { ok: false, kind: 'error', @@ -173,8 +165,7 @@ export async function decompileBpmnToDsl( return { ok: true, output, warnings: [...warnings, ...printWarnings] }; } -// The cli's `resolveOutputPath` does the same without an override, but -// importing it would pull chalk and commander into the extension bundle. +// Importing the cli's `resolveOutputPath` would pull chalk and commander into the bundle. export function swapExtension(filePath: string, newExt: string): string { const dir = path.dirname(filePath); const base = path.basename(filePath, path.extname(filePath)); diff --git a/packages/extension/src/extension/conversion.ts b/packages/extension/src/extension/conversion.ts index 9e79fde..64d6710 100644 --- a/packages/extension/src/extension/conversion.ts +++ b/packages/extension/src/extension/conversion.ts @@ -14,7 +14,7 @@ interface Source { text: string; } -// Prefers an open TextDocument so unsaved edits convert, not the stale file. +// An open TextDocument carries unsaved edits. async function readText(sourceUri: vscode.Uri): Promise { const openDoc = vscode.workspace.textDocuments.find( (doc) => doc.uri.toString() === sourceUri.toString(), @@ -52,7 +52,6 @@ async function confirmOverwrite(outputUri: vscode.Uri): Promise { try { await vscode.workspace.fs.stat(outputUri); } catch { - // stat throws when the target does not exist: nothing to overwrite. return true; } const answer = await vscode.window.showWarningMessage( @@ -99,8 +98,7 @@ export function compileCommand(extensionVersion: string): ConvertHandler { const result = await compileDslToBpmn(source.text, extensionVersion); if (!result.ok) { if (result.kind === 'validation') { - // The language client already publishes these diagnostics; a second - // collection would only duplicate them. + // The language client already publishes these diagnostics. await vscode.window.showTextDocument(source.uri); await vscode.commands.executeCommand('workbench.action.problems.focus'); await vscode.window.showErrorMessage( @@ -153,9 +151,7 @@ export function decompileCommand(): ConvertHandler { result.output, ); if (outputUri && result.warnings.length > 0) { - // Prefixed with the element id: several messages describe the route on - // from a step without naming it, so two of them would read as one line - // repeated. + // Several messages do not name their step, so the id tells them apart. const details = result.warnings .map((w) => `${w.elementId}: ${w.message}`) .join('; '); diff --git a/packages/extension/src/extension/main.ts b/packages/extension/src/extension/main.ts index 704b324..f313bfa 100644 --- a/packages/extension/src/extension/main.ts +++ b/packages/extension/src/extension/main.ts @@ -47,7 +47,7 @@ export async function activate( vscode.window.onDidChangeActiveTextEditor(() => void provider.refresh()), ); - // Safe before resolveWebviewView: refresh() no-ops until the view exists. + // refresh() no-ops until the view exists. void provider.refresh(); client = await startLanguageClient(context); diff --git a/packages/extension/src/extension/sidebar-view-provider.ts b/packages/extension/src/extension/sidebar-view-provider.ts index 87acf5a..a2319ec 100644 --- a/packages/extension/src/extension/sidebar-view-provider.ts +++ b/packages/extension/src/extension/sidebar-view-provider.ts @@ -4,7 +4,7 @@ import * as fs from 'node:fs'; import { randomBytes } from 'node:crypto'; import { swapExtension } from './conversion-core.js'; -// The webview in media/sidebar.js reads this shape untyped: change both. +// media/sidebar.js reads this shape untyped: change both. interface Counterpart { uri: string; name: string; @@ -39,8 +39,7 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { webviewView.webview.html = this._buildHtml(webviewView.webview); - // retainContextWhenHidden is false, so the view is rebuilt on every show - // and the listeners would pile up. + // retainContextWhenHidden is false, so the view is rebuilt on every show. const disposables: vscode.Disposable[] = []; webviewView.webview.onDidReceiveMessage( (message: unknown) => void this._handleMessage(message), @@ -49,7 +48,6 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { ); webviewView.onDidDispose(() => { disposables.forEach((d) => d.dispose()); - // A later refresh() must be a no-op, not a post to a disposed webview. this._view = undefined; }); } @@ -157,7 +155,6 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { ).fsPath; let html = fs.readFileSync(templatePath, 'utf-8'); - // replaceAll: {{NONCE}} and {{CSP_SOURCE}} each appear more than once. html = html .replaceAll('{{NONCE}}', nonce) .replaceAll('{{CSP_SOURCE}}', cspSource) diff --git a/packages/extension/test/bundled-conversion.e2e.test.ts b/packages/extension/test/bundled-conversion.e2e.test.ts index d9caab0..74b34c6 100644 --- a/packages/extension/test/bundled-conversion.e2e.test.ts +++ b/packages/extension/test/bundled-conversion.e2e.test.ts @@ -1,7 +1,6 @@ -// Needs `npm run build` first: it consumes the compiled out/ of language, -// transform and extension. Under test is the one thing only a bundle can -// answer: whether the transform still finds its moddle asset once esbuild has -// flattened it into a single CommonJS file, which is how the extension ships. +// Needs `npm run build` first. Checks that the transform still finds its +// moddle asset once esbuild flattens it into the single CommonJS file the +// extension ships as. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import * as fs from 'node:fs'; @@ -14,25 +13,16 @@ import * as esbuild from 'esbuild'; // @ts-ignore esbuild.mjs is a plain JS module with no type declarations. import { sharedBuildOptions, assetCopyPlugin } from '../esbuild.mjs'; -// Vitest transforms TS in place, so import.meta.url is this source file. -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const REPO_ROOT = path.resolve(__dirname, '../../..'); - -const EXT_DIR = path.resolve(__dirname, '..'); +const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)); +const EXT_DIR = fileURLToPath(new URL('..', import.meta.url)); const GOLDEN_GENERATED_BPMN = path.resolve( REPO_ROOT, 'tests/golden/invoice-approval-generated.bpmn', ); -if (!fs.existsSync(GOLDEN_GENERATED_BPMN)) { - throw new Error(`Fixture not found: ${GOLDEN_GENERATED_BPMN}`); -} - describe('bundled asset resolution and transform under the shim', () => { - // Unique per run: parallel suites would otherwise collide on these paths. + // Unique per run so parallel suites do not collide. const runId = `${process.pid}-${Date.now()}`; const verifyEntryFile = path.join(os.tmpdir(), `verify-entry-${runId}.js`); // Under out/extension/ so assetCopyPlugin lands operaton-moddle.json where @@ -87,7 +77,6 @@ describe('bundled asset resolution and transform under the shim', () => { }, 60_000); afterAll(() => { - // out/extension/operaton-moddle.json belongs to the extension build. if (fs.existsSync(verifyEntryFile)) fs.unlinkSync(verifyEntryFile); if (fs.existsSync(verifyOutfile)) fs.unlinkSync(verifyOutfile); }); diff --git a/packages/extension/test/conversion-core.test.ts b/packages/extension/test/conversion-core.test.ts index e65955f..72465b8 100644 --- a/packages/extension/test/conversion-core.test.ts +++ b/packages/extension/test/conversion-core.test.ts @@ -1,11 +1,7 @@ -// What each conversion direction makes of a given input; what the VS Code -// adapter then shows the author is `conversion.test.ts`. - import { describe, expect, test, beforeAll } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; import { EmptyFileSystem } from 'langium'; import { validationHelper } from 'langium/test'; @@ -16,25 +12,16 @@ import { xmlToIr } from '@bpmn-script/transform'; import { compileDslToBpmn, decompileBpmnToDsl, - swapExtension, } from '../src/extension/conversion-core.js'; import type { ConvDiagnostic } from '../src/extension/conversion-core.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const REPO_ROOT = path.resolve(__dirname, '../../..'); +const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)); const INVOICE_APPROVAL_SRC = path.resolve( REPO_ROOT, 'examples/spring-boot/processes/invoice-approval.bpmnscript', ); -const GOLDEN_GENERATED_BPMN = path.resolve( - REPO_ROOT, - 'tests/golden/invoice-approval-generated.bpmn', -); - const BAD_SERVICE_TASK_BPMN = path.resolve( REPO_ROOT, 'tests/golden/bad-service-task-no-binding.bpmn', @@ -55,8 +42,7 @@ function expectMentions(text: string, mentions: readonly string[]): void { } } -// Minus the wording: the positions, severity and source text are this module's -// own mapping, the sentence belongs to the validator. +// The wording belongs to the validator, the rest is this module's mapping. type DiagnosticPosition = Omit; type CompileOutcome = @@ -112,7 +98,6 @@ describe('compileDslToBpmn', () => { if (result.ok) return; expect(result.kind).toBe(expected.kind); - // A failed result carries nothing the adapter could write. expect('output' in result).toBe(false); if (result.kind !== 'validation') return; expect( @@ -218,11 +203,6 @@ type DecompileRow = readonly [ describe('decompileBpmnToDsl', () => { test.each([ - [ - 'a BPMN this tool generated comes back with nothing to report', - fs.readFileSync(GOLDEN_GENERATED_BPMN, 'utf-8'), - { ok: true, warnings: [], mentions: [] }, - ], [ 'a lane and an unsupported engine attribute are dropped with a warning each, naming the element they came off', LANE_AND_ASYNC_ATTR_BPMN, @@ -262,9 +242,8 @@ describe('decompileBpmnToDsl', () => { result.warnings.map((w) => w.message).join('\n'), expected.mentions, ); - // Parsing alone accepts source the validator refuses, so the output has - // to clear what the compile direction blocks on: no severity-1 - // diagnostic (a BPMN declares no variables, so warnings are expected). + // Parsing alone accepts source the validator refuses. Warnings are + // expected: a BPMN declares no variables. const { diagnostics } = await validate(result.output); expect( diagnostics.filter((d) => d.severity === 1).map((d) => d.message), @@ -277,8 +256,8 @@ describe('decompileBpmnToDsl', () => { expectMentions(result.message, expected.mentions); }); - // The sidebar's file picker offers every file, and whatever comes back here - // goes into a notification whole. + // The file picker offers every file, and the message goes into a + // notification whole. const UNPARSABLE_TAG = ' { }); }); }); - -describe('swapExtension', () => { - test.each([ - ['/a/b/my.invoice.bpmnscript', '.bpmn', '/a/b/my.invoice.bpmn'], - ['/a/b/x.bpmn', '.bpmnscript', '/a/b/x.bpmnscript'], - ] as const)( - 'only the final extension of %s is replaced by %s', - (input, newExt, expected) => { - expect(swapExtension(input, newExt)).toBe(expected); - }, - ); -}); diff --git a/packages/extension/test/conversion.test.ts b/packages/extension/test/conversion.test.ts index bf5ef8a..583c2f4 100644 --- a/packages/extension/test/conversion.test.ts +++ b/packages/extension/test/conversion.test.ts @@ -1,11 +1,8 @@ -// `vscode` is injected by the extension host, not installed from npm, so it is -// mocked with the surface the adapter touches. The conversion is mocked too: -// under test is what each outcome shows the author. +// `vscode` is injected by the extension host, not installed from npm. import { beforeEach, describe, expect, test, vi } from 'vitest'; -// vi.mock factories are hoisted above the module body, where a plain top-level -// const is still in its temporal dead zone. +// vi.mock factories are hoisted above a plain top-level const. const mocks = vi.hoisted(() => ({ showWarningMessage: vi.fn(), showErrorMessage: vi.fn(), @@ -44,11 +41,10 @@ vi.mock('vscode', () => ({ }, })); -vi.mock('../src/extension/conversion-core.js', () => ({ +vi.mock('../src/extension/conversion-core.js', async (importOriginal) => ({ + ...(await importOriginal()), compileDslToBpmn: vi.fn(), decompileBpmnToDsl: vi.fn(), - swapExtension: (fsPath: string, newExt: string) => - fsPath.replace(/\.[^./]+$/, newExt), })); import * as vscode from 'vscode'; @@ -65,10 +61,6 @@ import { decompileCommand, } from '../src/extension/conversion.js'; -function occurrences(haystack: string, needle: string): number { - return haystack.split(needle).length - 1; -} - function notifications(): Record<'info' | 'warning' | 'error', string[]> { const firstArgs = (fn: { mock: { calls: unknown[][] } }): string[] => fn.mock.calls.map((call) => String(call[0])); @@ -105,9 +97,9 @@ type Expected = { type Row = readonly [ title: string, command: 'compile' | 'decompile', - // undefined hands the command no uri, with no active editor to fall back on. + // undefined: no uri and no active editor. sourceName: string | undefined, - // undefined means the guard refuses the file before the core ever runs. + // undefined: refused before the core runs. result: CompileResult | DecompileResult | undefined, expected: Expected, ]; @@ -203,19 +195,6 @@ describe('conversion commands: what the author is shown', () => { returns: undefined, }, ], - [ - 'compile refuses a file that is not a script', - 'compile', - 'example.bpmn', - undefined, - { - warning: [ - 'BPMNscript: "example.bpmn" is not a .bpmnscript file; ' + - 'use "Decompile to BPMNscript" for it.', - ], - returns: undefined, - }, - ], [ 'decompile refuses a file that is not BPMN', 'decompile', @@ -288,13 +267,5 @@ describe('conversion commands: what the author is shown', () => { const core = command === 'compile' ? compileDslToBpmn : decompileBpmnToDsl; expect(core).toHaveBeenCalledTimes(result === undefined ? 0 : 1); - - // The file is named once per line; the success line is the exception, it - // names the file it read and the file it wrote. - if (sourceName !== undefined) { - for (const message of [...warning, ...error]) { - expect(occurrences(message, sourceName)).toBe(1); - } - } }); }); diff --git a/packages/extension/test/injection-grammar.test.ts b/packages/extension/test/injection-grammar.test.ts index 0cae1ac..2e880cb 100644 --- a/packages/extension/test/injection-grammar.test.ts +++ b/packages/extension/test/injection-grammar.test.ts @@ -8,8 +8,6 @@ import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { SCRIPT_FORMAT_ALIASES } from '@bpmn-script/language'; -// A format missing here has no installed grammar, and every tag reaching it -// must be listed in NO_INSTALLED_GRAMMAR. const EMBEDDED_SCOPE_BY_FORMAT: Readonly> = { javascript: 'source.js', python: 'source.python', @@ -71,39 +69,33 @@ describe('fenced-script injection grammar', () => { // Driven off the alias table, so a new alias fails here until it is either // routed by the grammar or declared as having no installed grammar. - it.each(Object.keys(SCRIPT_FORMAT_ALIASES))( - 'tag ```%s routes to its embedded scope, or is a declared miss', - (tag) => { + it('every alias tag routes to its embedded scope or is a declared miss, and unknown tags fall back to a plain block', () => { + for (const tag of [ + ...Object.keys(SCRIPT_FORMAT_ALIASES), + 'kotlin', + 'sql', + ]) { const match = matchFence('script demo ```' + tag); - expect(match).toBeDefined(); - if (NO_INSTALLED_GRAMMAR.has(tag)) { - expect(match!.name).toBe('plain-block'); - expect(match!.rule.contentName).toBeUndefined(); - expect(match!.rule.patterns).toBeUndefined(); - return; - } + expect(match, tag).toBeDefined(); const format = SCRIPT_FORMAT_ALIASES[tag]; + if (format === undefined || NO_INSTALLED_GRAMMAR.has(tag)) { + expect( + [match!.name, match!.rule.contentName, match!.rule.patterns], + tag, + ).toEqual(['plain-block', undefined, undefined]); + continue; + } const embedded = EMBEDDED_SCOPE_BY_FORMAT[format]; expect( embedded, `tag '${tag}' normalizes to '${format}': give it an injection block and an EMBEDDED_SCOPE_BY_FORMAT entry, or add the tag to NO_INSTALLED_GRAMMAR`, ).toBeDefined(); - expect(match!.rule.contentName).toBe(`meta.embedded.block.${format}`); - expect(match!.rule.patterns).toContainEqual({ include: embedded }); - }, - ); - - it.each([['kotlin'], ['sql']])( - 'unknown tag ```%s falls back to a plain block (no embedded include)', - (tag) => { - expect(SCRIPT_FORMAT_ALIASES[tag]).toBeUndefined(); - const match = matchFence('script demo ```' + tag); - expect(match).toBeDefined(); - expect(match!.name).toBe('plain-block'); - expect(match!.rule.contentName).toBeUndefined(); - expect(match!.rule.patterns).toBeUndefined(); - }, - ); + expect(match!.rule.contentName, tag).toBe( + `meta.embedded.block.${format}`, + ); + expect(match!.rule.patterns, tag).toContainEqual({ include: embedded }); + } + }); it('a bare closing fence starts no block and matches an end pattern', () => { expect(matchFence('```')).toBeUndefined(); diff --git a/packages/extension/test/sidebar-view-provider.test.ts b/packages/extension/test/sidebar-view-provider.test.ts index faeb84e..5943a81 100644 --- a/packages/extension/test/sidebar-view-provider.test.ts +++ b/packages/extension/test/sidebar-view-provider.test.ts @@ -1,6 +1,3 @@ -// `vscode` is injected by the extension host, not installed from npm, so it is -// mocked. The fake view exists to fire onDidDispose on demand. - import { describe, expect, it, vi } from 'vitest'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -19,7 +16,6 @@ vi.mock('vscode', () => ({ import { SidebarViewProvider } from '../src/extension/sidebar-view-provider.js'; -// _buildHtml reads media/sidebar.html from this directory. const EXTENSION_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', diff --git a/packages/language/README.md b/packages/language/README.md index c324bef..26a20d2 100644 --- a/packages/language/README.md +++ b/packages/language/README.md @@ -129,7 +129,7 @@ The type is `string`, `number`, `boolean`, `date`, or `enum`. `number` is written as `long` on the wire, the engine's own name for it, and an `enum` field stores the id of the chosen value, so its variable is a string. Each setting in the parens but `pattern` is one `operaton:constraint`, evaluated in the order written. -`FormValidators.createValidator` fails the deployment on any constraint name the engine has not registered, so the seven below are the closed set the parens take. +`FormValidators.createValidator` fails the deployment on any constraint name the engine has not registered, so the seven constraint names below, together with `pattern`, are the closed set the parens take. | Setting | What the engine checks | Fits | Value | | ---------------------------------- | ----------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------- | diff --git a/packages/language/src/bpmn-script-completion.ts b/packages/language/src/bpmn-script-completion.ts index 0b1c411..8b01995 100644 --- a/packages/language/src/bpmn-script-completion.ts +++ b/packages/language/src/bpmn-script-completion.ts @@ -64,7 +64,8 @@ import { } from './variable-symbol-provider.js'; import { attributeBlockRuleOf, - BOOLEAN_ENGINE_KEYS, + BOOLEAN_SETTING_KEYS, + BOUNDARY_TRIGGERS, CALL_BINDING_VALUES, CATCH_TRIGGERS, DECLARED_CODE_TRIGGERS, @@ -72,9 +73,10 @@ import { EMIT_TRIGGERS, END_TRIGGERS, ENGINE_KEYS, - engineSpellings, ERROR_MAPPING_HEAD, ERROR_MAPPING_WHEN, + EVENT_CODE_FIELD, + EVENT_MESSAGE_FIELD, eventBindingFieldsFor, EXECUTION_LISTENER_EVENTS, EXTERNAL_BINDING_KEY, @@ -83,6 +85,8 @@ import { FORM_FIELD_SETTING_KEYS, FORM_FIELD_TYPES, gatewayStatementRuleOf, + HANDLER_START_TRIGGERS, + INPUT_DIRECTION, JOIN_ENGINE_KEYS, joinSettingKey, JUEL_LITERAL_WORDS, @@ -90,6 +94,7 @@ import { listenerEventsFor, namesACode, ON_TRIGGERS, + OUTPUT_DIRECTION, parameterDirectionsFor, PROCESS_HEADER_KEYS, PROPERTY_DIRECTION, @@ -104,6 +109,7 @@ import { TRIGGER_PAYLOAD, TYPE_BINDING_KEY, TYPE_BINDING_VALUES, + VAR_TYPES, type EngineKey, } from './vocabulary.js'; @@ -124,15 +130,12 @@ const subscriptionChoices = (triggers: readonly string[]): string => .filter((word) => namesACode(word) && !DECLARED_CODE_TRIGGERS.has(word)) .join(','); -/** - * Keyed by keyword; accepting one scaffolds the whole construct and leaves the - * caret inside it. A keyword absent here keeps Langium's bare-keyword item. - */ +/** A keyword absent here keeps Langium's bare-keyword item. */ const STRUCTURE_SNIPPETS: Readonly< Record > = { process: 'process ${1:name} {\n\t$0\n}', - var: 'var ${1:name}: ${2|string,number,boolean,date,json,any|}', + var: 'var ${1:name}: ${2|' + VAR_TYPES.join(',') + '|}', start: 'start ${1:name}', end: 'end ${1:name}', user: 'user ${1:id}(assignee: "${2:user}")', @@ -158,9 +161,8 @@ const STRUCTURE_SNIPPETS: Readonly< ], subprocess: 'subprocess ${1:id} {\n\t$0\n}', attempt: 'attempt ${1:id} {\n\t$0\n}', - // A code names a header declaration and is written bare; a subscription name - // is quoted. A timer or condition payload is scaffolded at the trigger word - // instead, and the host is a cross-reference, so neither gets a form here. + // A code is written bare, a subscription name quoted; timer and condition are + // scaffolded at the trigger word. on: [ { label: 'on', @@ -224,7 +226,6 @@ const STRUCTURE_SNIPPETS: Readonly< ], }; -/** The value each engine setting scaffolds, under whichever spelling of the key. */ const ENGINE_VALUE_SNIPPETS: Readonly> = { asyncBefore: '${1|true,false|}', asyncAfter: '${1|true,false|}', @@ -245,8 +246,7 @@ const engineSnippets = ( ); /** - * Setting keys lex as plain identifiers, so the default completion offers - * nothing for them. The `\$` escapes keep an EL `${...}` literal instead of + * Setting keys lex as plain IDs; the `\$` escapes keep an EL `${...}` from * opening a nested placeholder. */ const SETTING_SNIPPETS: Readonly> = { @@ -288,7 +288,7 @@ const SETTING_SNIPPETS: Readonly> = { candidateStarterUsers: 'candidateStarterUsers: "${1:demo,manager}"', candidateStarterGroups: 'candidateStarterGroups: "${1:adjusters}"', initiator: 'initiator: "${1:starter}"', - // The two form flags are on while written, so neither scaffolds a choice. + // The form flags are on while written, so neither scaffolds a choice. required: 'required: true', readonly: 'readonly: true', min: 'min: ${1:0}', @@ -299,19 +299,15 @@ const SETTING_SNIPPETS: Readonly> = { pattern: 'pattern: "${1:dd/MM/yyyy}"', }; -/** - * A catch binding names a variable the handler declares, so it is bare; the - * same `message` key on a receive task is the quoted subscription name. - */ +/** A catch binding names a variable, so it is bare, unlike a receive task's quoted `message`. */ const CATCH_BINDING_SNIPPETS: Readonly> = { - code: 'code: ${1:code}', - message: 'message: ${1:message}', + [EVENT_CODE_FIELD]: `${EVENT_CODE_FIELD}: \${1:code}`, + [EVENT_MESSAGE_FIELD]: `${EVENT_MESSAGE_FIELD}: \${1:message}`, }; -/** A declaration's fields are text, so both are quoted. */ const DECLARATION_FIELD_SNIPPETS: Readonly> = { - code: 'code: "${1:code}"', - message: 'message: "${1:message}"', + [EVENT_CODE_FIELD]: `${EVENT_CODE_FIELD}: "\${1:code}"`, + [EVENT_MESSAGE_FIELD]: `${EVENT_MESSAGE_FIELD}: "\${1:message}"`, }; const settingForms = ( @@ -320,26 +316,18 @@ const settingForms = ( ): StructureForm[] => keys.map((key) => ({ label: key, insertText: snippets[key] })); -/** The words a value slot offers; a quoted choice inserts its quotes. */ const SETTING_VALUE_CHOICES: Readonly> = { [TYPE_BINDING_KEY]: TYPE_BINDING_VALUES.map((value) => `"${value}"`), binding: CALL_BINDING_VALUES, mapDecisionResult: DECISION_RESULT_MAPPINGS, }; -const BOOLEAN_SETTING_KEYS: ReadonlySet = new Set( - BOOLEAN_ENGINE_KEYS.flatMap(engineSpellings), -); - const LITERAL_WORDS: ReadonlySet = new Set(JUEL_LITERAL_WORDS); -/** The grammar rule of the two boolean words; `null` has one of its own. */ +/** `null` has a rule of its own. */ const BOOLEAN_LITERAL_RULE = LiteralBool.$type; -/** - * A duration is the bare payload the trigger word's snippet scaffolds, so only - * the date and cycle keys are offered. - */ +/** A duration is the bare payload, so only the date and cycle keys are offered. */ const TIMER_KEY_SNIPPETS: Readonly> = { [TIMER_PARTICLE_BY_KIND.date]: `${TIMER_PARTICLE_BY_KIND.date}: "\${1:2026-08-01T09:00:00}"`, [TIMER_PARTICLE_BY_KIND.cycle]: `${TIMER_PARTICLE_BY_KIND.cycle}: "\${1:R/PT10M}"`, @@ -365,11 +353,7 @@ function catchBindingForms(node: AstNode): StructureForm[] { const itemsOf = (owner: AstNode): ParenItem[] => (owner as { items?: ParenItem[] }).items ?? []; -/** - * Whether a setting is legal only after the unkeyed payload. A throw or emit - * must name its code even where a handler of the same trigger may catch every - * code. - */ +/** A throw or emit must name its code even where a handler of that trigger may catch every code. */ function payloadRequired(owner: AstNode): boolean { const trigger = triggerWordOf(owner); if (trigger === undefined) return false; @@ -385,8 +369,8 @@ function payloadRequired(owner: AstNode): boolean { } /** - * Keyed on the payload's CST end against the caret's token, not the caret: a - * word still being typed at the slot is that slot, not something after it. + * Compared with the caret's token, not the caret, so a word being typed at the + * slot is that slot. */ function payloadWritten(owner: AstNode, context: CompletionContext): boolean { const items = itemsOf(owner); @@ -411,10 +395,7 @@ function conditionSlotOpen(context: CompletionContext): boolean { ); } -/** - * The only flag any rule lists is the handler's `alongside`, so the list is - * offered whole where the trigger admits it. - */ +/** The only flag any rule lists is the handler's `alongside`. */ function flagWordsFor(node: AstNode): readonly string[] { const trigger = triggerWordOf(node); return trigger !== undefined && TRIGGER_PAYLOAD[trigger]?.alongside @@ -422,10 +403,7 @@ function flagWordsFor(node: AstNode): readonly string[] { : []; } -/** - * `undefined` where `node` has no parens of its own; `owningElement` keys on - * that. The `run` keys need the `for` clause the validator requires for them. - */ +/** `undefined` where `node` has no parens of its own, which `owningElement` keys on. */ function settingFormsFor(node: AstNode): StructureForm[] | undefined { if (isProcess(node)) { return settingForms(PROCESS_HEADER_KEYS); @@ -470,14 +448,13 @@ function settingFormsFor(node: AstNode): StructureForm[] | undefined { const writtenKeysOf = (owner: AstNode): ReadonlySet => new Set(settingsOf(itemsOf(owner)).map((setting) => setting.key)); -/** A listener's fenced script binds it in place of its settings, so no field. */ function bindsAField(owner: AstNode): boolean { if (isListener(owner) && owner.script !== undefined) return false; const keys = writtenKeysOf(owner); return FIELD_BINDING_KEYS.some((key) => keys.has(key)); } -/** A property line and a mapping ride the topic binding alone; its worker reads them. */ +/** Its worker reads property lines and mappings. */ const bindsATopic = (owner: AstNode): boolean => writtenKeysOf(owner).has(EXTERNAL_BINDING_KEY); @@ -485,10 +462,8 @@ const mappingOffered = (owner: AstNode): boolean => (attributeBlockRuleOf(owner)?.externalExtras ?? false) && bindsATopic(owner); /** - * A listener's block holds fields alone, for the reason - * `BpmnScriptValidator.checkListenerFields` gives, and a form field's holds - * properties alone, so neither has a rule row; a field or a property is - * offered once the binding it rides is written. + * A listener's block holds fields and a form field's properties alone, so + * neither has a rule row. */ function parameterDirectionsOf(owner: AstNode): readonly string[] { if (isListener(owner)) { @@ -508,19 +483,11 @@ function parameterDirectionsOf(owner: AstNode): readonly string[] { ); } -/** - * On an element with a body, a lone brace after its head opens the body, not - * the member block, and `Block` parses a parameter there as readily as the - * member block does, so none is offered in the body, where it belongs to - * nothing. - */ +/** `Block` parses a parameter as readily as a member block, but in a body it belongs to nothing. */ const inBodyOf = (owner: AstNode, context: CompletionContext): boolean => AstUtils.getContainerOfType(context.node, isBlock)?.$container === owner; -/** - * The validator's `isVariableUse` cannot be asked: with nothing typed after - * the colon the recovered tree holds the key as a bare value and no `Setting`. - */ +/** Not `isVariableUse`: with nothing after the colon the recovered tree has no `Setting`. */ function valueSlotReadsVariable(owner: AstNode, key: string): boolean { return ( !NON_VARIABLE_ATTR_KEYS.has(key) && @@ -531,7 +498,7 @@ function valueSlotReadsVariable(owner: AstNode, key: string): boolean { ); } -/** The default's stand-in for an untyped reference, held by the caret's node. */ +/** Mirrors the stand-in Langium's default builds for an untyped reference. */ const referenceStandInAt = (node: AstNode): VarRef => ({ $type: 'VarRef', $container: node as VarRef['$container'], @@ -540,10 +507,7 @@ const referenceStandInAt = (node: AstNode): VarRef => ({ accessors: [], }); -/** - * A mapping with nothing after its head word leaves no node, so the head is - * read off the text. - */ +/** A mapping with nothing after its head word leaves no node. */ const wordBefore = (context: CompletionContext): string | undefined => /(\w+)\s*$/.exec( context.textDocument.getText().slice(0, context.tokenOffset), @@ -556,10 +520,9 @@ function closesWithBrace(cst: CstNode): boolean { } /** - * The innermost block whose braces hold the caret, read off the CST: at - * `on |` inside a block the recovery closes that block before the handler and - * hangs it on the process, so the handler's containers say nothing about where - * it was typed, and the closed block must still count as open past its end. + * At `on |` the recovery closes the enclosing block early and hangs the + * handler on the process, so the block is read off the CST and counts as open + * past its end. */ function innermostOpenBlockAt(context: CompletionContext): Block | undefined { return AstUtils.streamAst(context.document.parseResult.value) @@ -577,10 +540,8 @@ function innermostOpenBlockAt(context: CompletionContext): Block | undefined { } /** - * Host-less, a handler is an event sub-process and its undo block belongs in - * the subprocess it undoes; hosted, it is a boundary event, which Operaton - * attaches as a cancel to a transaction alone. An unresolved host keeps the - * whole list. + * Host-less, `compensation` belongs in a subprocess; hosted, `cancel` only on a + * transaction. An unresolved host keeps the whole list. */ function handlerTriggerWords( handler: OnHandler, @@ -591,35 +552,28 @@ function handlerTriggerWords( const inSubProcess = isSubProcess( innermostOpenBlockAt(context)?.$container, ); - return words.filter( - (word) => - TRIGGER_PAYLOAD[word]?.hostless && - (word !== 'compensation' || inSubProcess), + return HANDLER_START_TRIGGERS.filter( + (word) => word !== 'compensation' || inSubProcess, ); } const host = handler.host.ref; if (host === undefined) return words; - return words.filter( + return BOUNDARY_TRIGGERS.filter( (word) => - TRIGGER_PAYLOAD[word]?.boundary && (word !== 'cancel' || isAttemptBlock(host)) && (word !== 'escalation' || isEscalationLegalHost(host)), ); } /** - * The grammar rule a feature belongs to. Where two rules write one word the - * rule tells them apart and the caret's node cannot: after a finished construct - * the node is that construct, not the enclosing one. + * Tells apart two rules writing one word, which the caret's node cannot after a + * finished construct. */ function ruleNameOf(node: AstNode): string | undefined { return AstUtils.getContainerOfType(node, GrammarAST.isParserRule)?.name; } -/** - * A `MapKey` in a parameter value assigns `key` too, and takes the author's - * own keys. A datatype rule has no generated `$type` to name it by. - */ +/** Named by rule, since a datatype rule has no `$type` and `MapKey` assigns `key` too. */ export const SETTING_KEY_RULE = 'ParenKey'; export const FLAG_WORD_RULE = 'FlagWord'; @@ -630,9 +584,8 @@ const atParenItemStart = (context: CompletionContext): boolean => ); /** - * The key whose value slot holds the caret, or `undefined`. At `key: |` the - * parser recovers the key as a bare unkeyed value and drops the colon, so that - * shape is told by the colon still standing between the word and the caret. + * At `key: |` the parser recovers the key as a bare value and drops the colon, + * so the colon is read off the text. */ function settingKeyAt(context: CompletionContext): string | undefined { const node = context.node; @@ -652,10 +605,9 @@ function settingKeyAt(context: CompletionContext): string | undefined { } /** - * The element whose settings hold the caret. The caret's node is that element - * only while its parens are empty and the preceding item's leaf afterwards, so - * the walk climbs to the first element still enclosing the caret; parens whose - * closing token is not typed enclose nothing, so there the innermost stands in. + * The caret's node is the element only while its parens are empty, so the walk + * climbs to the first element enclosing the caret; unclosed parens enclose + * nothing, so the innermost stands in. */ function owningElement(context: CompletionContext): AstNode | undefined { let innermost: AstNode | undefined; @@ -748,10 +700,6 @@ const FLAG_DETAIL = 'BPMNscript flag'; const FORM_TYPE_DETAIL = 'BPMNscript form type'; const SETTING_VALUE_DETAIL = 'BPMNscript setting value'; -/** - * Snippet completions for the structural keywords and for the soft words the - * grammar leaves as plain identifiers; everything else keeps Langium's default. - */ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { private readonly variables: VariableSymbolProvider; @@ -760,14 +708,13 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { this.variables = services.references.VariableSymbolProvider; } - /** The soft words lex as plain `ID`s, so the default offers nothing for them. */ protected override completionFor( context: CompletionContext, next: NextFeature, acceptor: CompletionAcceptor, ): MaybePromise { - // The node under the caret stays the shared rule while a keyword still - // opens more than one shape, so the next feature decides first. + // While a keyword still opens several shapes the caret's node is the shared + // rule, so the next feature decides. const nodeType = next.type ?? context.node?.$type; const owner = owningElement(context); if ( @@ -775,8 +722,8 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { ruleNameOf(next.feature) === SETTING_KEY_RULE && owner ) { - // Until a required payload is written only the payload is offered, and - // a timer's date and cycle keys are that payload. + // Until a required payload is written only it is offered; a timer's date + // and cycle keys are that payload. const forms = payloadOpen(owner, context) ? timerKeyForms(owner) : (settingFormsFor(owner) ?? []); @@ -788,8 +735,7 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { } return; } - // A form field's type slot admits any word, so the grammar's own list of - // variable types is replaced by the form types the validator holds it to. + // A form field's type admits any word; offer the form types instead of the grammar's var types. if ( next.property === 'type' && AstUtils.getContainerOfType(context.node, isFormField) @@ -801,8 +747,7 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { } if (GrammarAST.isCrossReference(next.feature)) { if (atParenItemStart(context)) { - // A second unkeyed value is refused, so after the payload no - // reference belongs at the item start. + // A second unkeyed value is refused. if (owner && payloadWritten(owner, context)) return; } else { const key = settingKeyAt(context); @@ -861,22 +806,19 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { } /** - * A host resolves against every named statement so the validator can say - * why a start or a throw cannot carry a boundary event; the offer stops at - * the activities that can. + * A host resolves against every named statement so the validator can explain + * a bad one; only activities are offered. */ protected override getReferenceCandidates( refInfo: ReferenceInfo, context: CompletionContext, ): Stream { - // Inside a block body a bare `on Ins` parses as a complete `Listener`, not - // an `OnHandler`, so the guard keys on the property: only `OnHandler` has - // `host`. + // In a block body a bare `on Ins` parses as a `Listener`, so key on the property. if (refInfo.property !== 'host') { return super.getReferenceCandidates(refInfo, context); } - // The recovery hangs a trigger-less handler on the process, so the scope - // is read for a stand-in placed where the handler was typed. + // The recovery hangs a trigger-less handler on the process, so read the + // scope where it was typed. const placed: ReferenceInfo = { ...refInfo, container: { @@ -900,8 +842,7 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { ): MaybePromise { if (next.type === ErrorMapping.$type && next.property === 'code') { // Not `owningElement`: with only the head word written the member block - // is dropped from the CST and the element no longer encloses the caret, - // so an enclosure walk climbs past it. + // is dropped from the CST. let owner = context.node; while (owner !== undefined && attributeBlockRuleOf(owner) === undefined) { owner = owner.$container; @@ -927,9 +868,8 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { } /** - * The default's stand-in carries no trigger word, and with nothing after the - * head word no parsed mapping holds one either, so the scope would read no - * code kind; the stand-in is given the head word the parser has passed. + * Neither the default's stand-in nor the parsed mapping holds the head word + * yet, so it is supplied. */ private acceptMappingCodes( context: CompletionContext, @@ -967,7 +907,6 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { ); } - /** A declared name sorts ahead of a keyword, as a code or a host does. */ private acceptVariables( context: CompletionContext, acceptor: CompletionAcceptor, @@ -1048,8 +987,8 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { private static readonly DIRECTION_DETAILS: Readonly> = { - input: 'a value handed to this step', - output: 'a value this step hands back', + [INPUT_DIRECTION]: 'a value handed to this step', + [OUTPUT_DIRECTION]: 'a value this step hands back', [FIELD_DIRECTION]: 'a value injected into the class, delegate, or built-in behaviour this step names', [PROPERTY_DIRECTION]: @@ -1057,9 +996,8 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { }; /** - * A field or property value is quoted where an io parameter's is not: a - * field lowers to a `stringValue` attribute or an expression child and a - * property to a `value` attribute, and none takes a list, map or script. + * A field lowers to `stringValue` or an expression and a property to `value`, + * so both are quoted. */ private static readonly DIRECTION_VALUES: Readonly> = { [FIELD_DIRECTION]: '"${2:value}"', @@ -1094,8 +1032,7 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { return; } for (const event of listenerEventsFor(rule)) { - // A listener's timer clause is written before its settings, so the two - // tab stops swap places on `timeout`. + // A listener's timer clause precedes its settings, so the tab stops swap on `timeout`. const timer = event === 'timeout' ? ' after "${1:PT1H}"' : ''; const binding = event === 'timeout' ? '${2:' : '${1:'; this.acceptSnippet(context, acceptor, { @@ -1122,8 +1059,7 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { }); return; } - // The grammar admits a form block on every element with a member block; - // the rule says which take one. + // The grammar admits a form block on every member block; the rule says which take one. if (keyword.value === 'form') { const owner = owningElement(context); if (owner && attributeBlockRuleOf(owner)?.forms) { @@ -1132,9 +1068,7 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { return; } const rule = ruleNameOf(keyword); - // In a setting position a keyword is a key or a flag, and which of them the - // element takes is answered from its vocabulary, so the grammar's raw - // alternatives are dropped. + // The element's vocabulary answers which keys and flags it takes, not the grammar. if (rule === FLAG_WORD_RULE) { const owner = owningElement(context); if ( diff --git a/packages/language/src/bpmn-script-document-validator.ts b/packages/language/src/bpmn-script-document-validator.ts index 182f9d7..b11ac93 100644 --- a/packages/language/src/bpmn-script-document-validator.ts +++ b/packages/language/src/bpmn-script-document-validator.ts @@ -1,12 +1,8 @@ /** - * Drops the link failures that were never meant to resolve. Every identifier - * in every expression is a cross-reference to a code declaration, and only a - * code position has a scope, so `if (amount > 100)` fails to link by design - * and the undeclared-variable warning stays the one diagnostic its author - * sees. This is the seam because `Linker` can only reword: `doLink` stores - * the error and lists the reference regardless, and skipping that push would - * leave a stale `_ref` behind, since `document.references` is what `unlink()` - * walks between builds. + * Drops the link failures of identifiers outside a code position, which never + * resolve by design; the undeclared-variable warning speaks for them. Not the + * linker: skipping the reference there would leave a stale `_ref` that + * `unlink()` never walks. */ import { @@ -19,7 +15,6 @@ import type { Diagnostic } from 'vscode-languageserver-types'; import { isVarRef } from './generated/ast.js'; import { isCodePosition } from './paren-items.js'; -/** Only an identifier outside a code position is one the author never asked to resolve. */ function isExpectedToResolve(source: AstNode): boolean { return !isVarRef(source) || isCodePosition(source); } @@ -30,8 +25,7 @@ export class BpmnScriptDocumentValidator extends DefaultDocumentValidator { diagnostics: Diagnostic[], options: ValidationOptions, ): void { - // A view rather than a spread copy: `textDocument` is a lazy getter, and - // spreading would build the whole TextDocument for one reference list. + // Not a spread: `textDocument` is a lazy getter that would build the whole document. const reported: LangiumDocument = Object.create(document, { references: { value: document.references.filter( diff --git a/packages/language/src/bpmn-script-hover.ts b/packages/language/src/bpmn-script-hover.ts index 34a4968..c90cf43 100644 --- a/packages/language/src/bpmn-script-hover.ts +++ b/packages/language/src/bpmn-script-hover.ts @@ -1,8 +1,6 @@ /** - * Hover for a named statement, a declared code and a variable. Langium - * resolves the caret through `References.findDeclarations` first, so a `goto` - * target, a host, a thrown or caught code and every site of a variable show - * their declaration's line with no case of their own. + * Langium resolves the caret through `findDeclarations` first, so every + * reference shows its declaration with no case of its own. */ import type { AstNode } from 'langium'; @@ -42,8 +40,7 @@ function namedStatementHover(node: NamedStatement): string { function codeDeclHover(decl: CodeDecl): string { const article = /^[aeiou]/i.test(decl.kind) ? 'an' : 'a'; let line = `${article} ${decl.kind} '${decl.name}'`; - // `declaredCodeOf` falls back to the name; hover quotes only a written - // setting. + // `declaredCodeOf` falls back to the name; hover quotes only a written setting. const hasCodeSetting = settingsOf(decl.items).some( (item) => item.key === EVENT_CODE_FIELD, ); diff --git a/packages/language/src/bpmn-script-linker.ts b/packages/language/src/bpmn-script-linker.ts index c396755..6e20fc4 100644 --- a/packages/language/src/bpmn-script-linker.ts +++ b/packages/language/src/bpmn-script-linker.ts @@ -1,11 +1,7 @@ /** - * Langium's stock resolution failure names the grammar type (`Statement`) - * rather than anything the author wrote. A `goto` or host naming a step that - * exists elsewhere in the process gets the boundary it crosses instead, one - * naming nothing gets "no step named", and an undeclared code gets how to - * declare it. A validator cannot do this: it only sees a reference that - * already resolved. Rewording is all a linker can do; whether the error - * becomes a diagnostic is decided in `bpmn-script-document-validator.ts`. + * Rewords Langium's resolution failure, which names the grammar type: the + * boundary a `goto` or host crosses, or how to declare a missing code. A + * validator cannot, since it only sees resolved references. */ import { @@ -45,10 +41,6 @@ function findNamedStatement( return undefined; } -/** - * `undefined` when error recovery left the trigger empty: with no boundary to - * name, the stock unresolved-reference message runs instead. - */ function handlerPhrase(handler: OnHandler): string | undefined { if (handler.trigger === undefined) return undefined; @@ -66,13 +58,11 @@ function declaredCodeKind(source: AstNode, name: string): string | undefined { return decls.find((decl) => decl.name === name)?.kind; } -/** `crossesHandler` picks the trailing boundary sentence. */ interface Location { phrase: string; crossesHandler: boolean; } -/** Where `target` lives relative to `sourceContainer`, once the two are known to differ. */ function locateTarget( target: NamedStatement, sourceContainer: FlowContainer, @@ -84,8 +74,7 @@ function locateTarget( if (targetContainer && isOnHandler(targetContainer)) { return handlerLocation(targetContainer, 'inside'); } - // The target lives at process level; since it did not resolve, the - // reference itself must be inside a subprocess or a handler body. + // It did not resolve, so the reference sits inside a subprocess or handler body. if (isOnHandler(sourceContainer)) { return handlerLocation(sourceContainer, 'outside'); } @@ -120,9 +109,7 @@ export class BpmnScriptLinker extends DefaultLinker { const codeTrigger = codeTriggerOf(source); if (codeTrigger !== undefined) { const name = refInfo.reference.$refText; - // A name declared under the other kind arrives here unresolved too; - // advising a second declaration would walk the author into the - // duplicate-name error. + // Advising a second declaration under the other kind would lead to the duplicate-name error. const declaredKind = declaredCodeKind(source, name); const message = declaredKind === undefined diff --git a/packages/language/src/bpmn-script-module.ts b/packages/language/src/bpmn-script-module.ts index d4f8e37..2efa864 100644 --- a/packages/language/src/bpmn-script-module.ts +++ b/packages/language/src/bpmn-script-module.ts @@ -34,7 +34,6 @@ import { BpmnScriptNodeKindProvider, } from './bpmn-script-symbols.js'; -/** {@link VariableSymbolProvider} sits in `references`: resolving identifiers is its concern. */ export type BpmnScriptAddedServices = { references: { VariableSymbolProvider: VariableSymbolProvider; @@ -76,7 +75,7 @@ export const BpmnScriptModule: Module< }, }; -/** `NodeKindProvider` lives on `LangiumSharedServices`, not the language module, so overriding it takes a second module here. */ +/** `NodeKindProvider` is a shared service, so overriding it takes a second module. */ export const BpmnScriptSharedModule: Module< LangiumSharedServices, PartialLangiumSharedServices diff --git a/packages/language/src/bpmn-script-parser-error-message-provider.ts b/packages/language/src/bpmn-script-parser-error-message-provider.ts index 9eb09db..c017123 100644 --- a/packages/language/src/bpmn-script-parser-error-message-provider.ts +++ b/packages/language/src/bpmn-script-parser-error-message-provider.ts @@ -1,13 +1,7 @@ /** - * Guidance in place of Chevrotain's stock wording for the mistakes an author - * makes with the grammar's shape: a reserved word where a plain identifier - * belongs, a `var` or a repeat clause after the position that takes it, a - * mistyped statement keyword. A reserved word reaches the parser down two - * Chevrotain paths, so both builders are overridden: `buildMismatchTokenMessage` - * where the grammar expects exactly `ID`, `buildNoViableAltMessage` where `ID` - * is one alternative among several. Every message stays free of BPMN - * vocabulary. Only the message changes; recovery and the legal - * token positions are Chevrotain's. + * Guidance in place of Chevrotain's stock wording; recovery is unchanged. A + * reserved word arrives as a token mismatch where the grammar expects `ID` and + * as no viable alternative where `ID` is one choice, so both are overridden. */ import { @@ -24,10 +18,8 @@ import { const ID_TOKEN_NAME = 'ID'; /** - * A `var` after the first step, or a `for` after a statement that takes no - * repeat clause, ends the statement list early: the parser expects `}` and - * finds the keyword. An unclosed block reads the same way at `EOF`, whose - * image is the empty string, so the stock message would say "found ``". + * A misplaced `var` or `for` ends the statement list early; at `EOF` the stock + * message would say "found ``". */ const CLOSE_BRACE_TOKEN_NAME = '}'; const VAR_KEYWORD_TOKEN_NAME = 'var'; @@ -35,16 +27,14 @@ const FOR_KEYWORD_TOKEN_NAME = 'for'; const EOF_TOKEN_NAME = 'EOF'; /** - * A code declaration is the one header declaration opening with a plain `ID`, - * so a mistyped statement keyword parses as its kind and fails at the name - * slot. A word that really opens a declaration is excluded, so `error "PF"` - * still blames the text where the name belongs. + * A mistyped statement keyword parses as a code declaration and fails at the + * name slot; a real declaration word is excluded. */ const CODE_DECL_RULE_NAME = 'CodeDecl'; const WORD_SHAPED = /^[A-Za-z_]/; -/** Langium suffixes every Chevrotain rule name with a zero-width space (`withRuleSuffix` in `langium-parser.ts`). */ +/** Langium suffixes every Chevrotain rule name with a zero-width space. */ function bareRuleName(ruleName: string): string { return ruleName.replace(/\u200b+$/, ''); } @@ -148,11 +138,7 @@ export class BpmnScriptParserErrorMessageProvider extends LangiumParserErrorMess return `'${word}' is not a word this position takes; write ${formatWordList(alternatives)}.`; } - /** - * The keywords a slot admits when every alternative is one keyword and - * nothing else, else `undefined`: a slot also taking an identifier, a - * literal or a longer phrase is not a closed set of words. - */ + /** `undefined` unless every alternative is a single keyword. */ private keywordAlternatives( expectedPathsPerAlt: NoViableAltOptions['expectedPathsPerAlt'], ): readonly string[] | undefined { diff --git a/packages/language/src/bpmn-script-references.ts b/packages/language/src/bpmn-script-references.ts index a1e8df0..6be8925 100644 --- a/packages/language/src/bpmn-script-references.ts +++ b/packages/language/src/bpmn-script-references.ts @@ -1,13 +1,8 @@ /** - * Definition, references, rename, document highlight and hover for a variable. - * `VarRef.ref` resolves in a code position alone, so Langium's five providers, - * keyed on the resolved reference and a `name` node, know nothing of - * `if (amount > 1)`; all five go through `References.findDeclarations` and - * `References.findReferences`, so one override serves them. No AST node stands - * for a variable (five node kinds declare one, and a repeated statement - * declares its element beside its own name), so each request builds a symbol - * node from `declaringSites`. That walk answers in single-digit milliseconds - * on a 3,000-line file, so a highlight per cursor move needs no cache. + * `VarRef.ref` resolves in a code position alone, so Langium's definition, + * references, rename, highlight and hover know nothing of variables; all five + * go through `findDeclarations`/`findReferences`, overridden here with a + * symbol node built from `declaringSites` per request (fast enough uncached). */ import { @@ -36,7 +31,6 @@ import type { VariableSymbolProvider, } from './variable-symbol-provider.js'; -/** The node a variable's sites all resolve to. Its `$cstNode` is the first site's name leaf. */ interface VariableSymbolNode extends AstNode { readonly $type: 'VariableSymbolNode'; readonly $container: Process; @@ -89,12 +83,10 @@ export class BpmnScriptReferences extends DefaultReferences { ...sites.map(nameLeafOf).filter((leaf) => leaf !== undefined), ); } - // A catch binding is a `VarRef` too, and it is listed above as a site. const declaring = new Set(sites.map((site) => site.node)); for (const ref of AstUtils.streamAst(process).filter(isVarRef)) { if (declaring.has(ref)) continue; - // The `ref` leaf alone, so `order.total` renames `order` and keeps - // `.total`. + // The `ref` leaf alone, so `order.total` renames `order` and keeps `.total`. const leaf = ref.ref.$refNode; if (leaf && ref.ref.$refText === node.name && isVariableUse(ref)) { leaves.push(leaf); @@ -106,12 +98,7 @@ export class BpmnScriptReferences extends DefaultReferences { ); } - /** - * The variable the leaf under the caret names, by a declaring site or a use - * with at least one site. A name with no site (a loop counter, an undeclared - * name) falls through to the default, which finds no resolved reference and - * so refuses a rename. - */ + /** A name with no site falls through to the default, which refuses a rename. */ private variableAt(cst: CstNode): VariableSymbolNode | undefined { const node = cst.astNode; const feature = GrammarUtils.findAssignment(cst)?.feature; diff --git a/packages/language/src/bpmn-script-scope-provider.ts b/packages/language/src/bpmn-script-scope-provider.ts index 8278ae0..7758510 100644 --- a/packages/language/src/bpmn-script-scope-provider.ts +++ b/packages/language/src/bpmn-script-scope-provider.ts @@ -1,19 +1,9 @@ /** - * A `goto` and an `on` handler's host see every named step of their own flow - * container at any nesting depth and nothing outside it. A code reference, an - * event's payload or the code slot of an `error ... when` mapping, sees the - * process's declarations of its own kind in a code position and nothing - * anywhere else. - * - * Langium's block-lexical default is wrong for `goto` twice over: a step - * nested in a `parallel`/`if`/`while` block would be invisible to a legal - * `goto` outside it, and nothing would stop a jump into another container, - * which BPMN forbids for a subprocess and an event handler alike. The host - * candidates are not narrowed to activities, so a host naming a step that - * cannot carry an attached event still resolves and the validator can say - * what it is. Langium's precomputed scopes are not consulted for a code - * reference: they would resolve `if (PAYMENT_DECLINED)` to the declaration - * and offer every code as a completion wherever an expression is legal. + * A `goto` or handler host sees every named step of its own flow container at + * any depth and nothing outside; Langium's block-lexical default would hide a + * nested step and allow jumps into another container. A code reference sees + * only its process's declarations of its kind; the precomputed scopes would + * resolve `if (PAYMENT_DECLINED)` and offer codes in every expression. */ import { @@ -64,9 +54,8 @@ import { } from './generated/ast.js'; /** - * The `Statement` subtypes carrying a `name`: the `goto` targets and handler - * hosts. An unnamed `throw`/`emit`/`await` gets a synthesized id, so it is - * neither referenceable nor able to collide. + * An unnamed `throw`/`emit`/`await` gets a synthesized id, so it cannot be + * referenced or collide. */ export type NamedStatement = | StartEvent @@ -103,7 +92,6 @@ export function isNamedStatement(node: AstNode): node is NamedStatement { ); } -/** A handler body counts as a full BPMN container for every container-scoped rule. */ export type FlowContainer = Process | SubProcess | OnHandler; function isFlowContainer(node: AstNode): node is FlowContainer { @@ -111,10 +99,8 @@ function isFlowContainer(node: AstNode): node is FlowContainer { } /** - * The flow container `node` lives in, starting at `node.$container` so a - * container does not answer itself. A hosted handler is skipped: its body - * compiles into its host's container, so its steps and the main flow share one - * sequence-flow scope. + * Starts at `node.$container`. A hosted handler is skipped: its body compiles + * into its host's container. */ export function enclosingFlowContainer( node: AstNode, @@ -129,24 +115,14 @@ export function enclosingFlowContainer( return container; } -/** - * The grammar has four cross-references, `VarRef.ref`, `ErrorMapping.code`, - * `GotoStatement.target` and `OnHandler.host`, and every one of them sits - * under a `Process` (a completion stand-in hangs off a parsed node), so - * neither branch has a default to fall back to. - */ +/** All four cross-references sit under a `Process`, so neither branch falls back to the default. */ export class BpmnScriptScopeProvider extends DefaultScopeProvider { override getScope(context: ReferenceInfo): Scope { if (isVarRef(context.container) || isErrorMapping(context.container)) { - // Never the default scope: it would resolve the name against every - // declaration of both kinds, so a mapping headed by a word that names no - // code gets nothing, as a `VarRef` outside a code position does. + // Never the default scope, which would resolve against both kinds. const trigger = codeTriggerOf(context.container); if (trigger === undefined) return EMPTY_SCOPE; - // No outer scope: a declaration of another process, or of the other - // kind, is out of reach. An error and an escalation are separate event - // definitions even under one code, so `escalation(X)` naming an error - // has to fail rather than reach the XML as a second root. + // No outer scope: `escalation(X)` naming an error must fail, not become a second root. const process = AstUtils.getContainerOfType( context.container, isProcess, @@ -157,21 +133,16 @@ export class BpmnScriptScopeProvider extends DefaultScopeProvider { ), ); } - // From the reference's container, not the node itself: a handler naming a - // host is a container, and a scope taken from it would offer the handler's - // own body instead of its host's surroundings. + // From the reference's container: a handler is itself a container. const container = enclosingFlowContainer(context.container)!; - // The reference type is `Statement`, so process-scope declarations such - // as `var`, which also carry a `name`, are filtered out here. + // The reference type `Statement` filters out `var`s, which carry a `name` too. const referenceType = this.reflection.getReferenceType(context); const targets = AstUtils.streamAllContents(container).filter( (node) => this.reflection.isSubtype(node.$type, referenceType) && - // Only where this container is the candidate's own nearest one, - // isolating a nested `subprocess` or host-less handler body. + // Isolates a nested `subprocess` or host-less handler body. enclosingFlowContainer(node) === container, ); - // No outer scope: anything beyond this container is unreachable. return this.createScopeForNodes(targets); } } diff --git a/packages/language/src/bpmn-script-semantic-tokens.ts b/packages/language/src/bpmn-script-semantic-tokens.ts index 3924361..d093f61 100644 --- a/packages/language/src/bpmn-script-semantic-tokens.ts +++ b/packages/language/src/bpmn-script-semantic-tokens.ts @@ -1,11 +1,7 @@ /** - * The soft words lex as plain `ID`, so the TextMate grammar, a regex over - * token text, cannot tell `on error` from `var error: string`. A semantic - * token comes off the parsed AST, so a soft word highlights exactly where the - * grammar gave it that meaning, and VS Code's default themes render a - * semantic `keyword` like a lexical one. `OnHandler.host` is a cross-reference - * to the activity the handler attaches to, not a trigger word, so it is left - * out. + * Soft words lex as `ID`, so TextMate cannot tell `on error` from + * `var error: string`; semantic tokens come off the AST. `OnHandler.host` is a + * reference, not a trigger word. */ import type { AstNode } from 'langium'; @@ -35,7 +31,6 @@ export class BpmnScriptSemanticTokenProvider extends AbstractSemanticTokenProvid node: AstNode, acceptor: SemanticTokenAcceptor, ): void | undefined | 'prune' { - // Every soft word highlights as a keyword; only the property differs. const keyword = (property: string): void => acceptor({ node, property, type: SemanticTokenTypes.keyword }); diff --git a/packages/language/src/bpmn-script-symbols.ts b/packages/language/src/bpmn-script-symbols.ts index f48b2ec..ac895cd 100644 --- a/packages/language/src/bpmn-script-symbols.ts +++ b/packages/language/src/bpmn-script-symbols.ts @@ -1,4 +1,7 @@ -/** Outline kinds; the default provider skips an `on` handler (no `name`) and flattens its steps into the process. */ +/** + * The default outline skips an `on` handler (no `name`) and flattens its steps + * into the process. + */ import type { AstNode, AstNodeDescription, LangiumDocument } from 'langium'; import { GrammarUtils } from 'langium'; @@ -9,7 +12,6 @@ import { import { SymbolKind, type DocumentSymbol } from 'vscode-languageserver-types'; import { isOnHandler } from './generated/ast.js'; -/** A dense table, hand-formatted rather than one property per Prettier's default. */ // prettier-ignore const SYMBOL_KIND_BY_TYPE: Readonly> = { Process: SymbolKind.Module, SubProcess: SymbolKind.Namespace, @@ -28,7 +30,7 @@ export class BpmnScriptNodeKindProvider extends DefaultNodeKindProvider { } } -/** Named by its own header (`alongside` included: it tells two handlers on one host apart). */ +/** A handler is named by its header; `alongside` tells two on one host apart. */ export class BpmnScriptDocumentSymbolProvider extends DefaultDocumentSymbolProvider { protected override getSymbol( document: LangiumDocument, diff --git a/packages/language/src/bpmn-script-validator.ts b/packages/language/src/bpmn-script-validator.ts index 1ffc0df..580500c 100644 --- a/packages/language/src/bpmn-script-validator.ts +++ b/packages/language/src/bpmn-script-validator.ts @@ -1,10 +1,7 @@ /** - * Every diagnostic the language raises. Most messages and the value shapes - * come first, then the class, in this order: the process-wide checks, the - * per-element entry points `registerValidationChecks` wires, the - * attribute-block machinery from `checkAttributeKeys` on, gateways and blocks, - * handlers and events, declarations. Free helpers sit outside the class, - * nearest the check or message they serve. + * Every diagnostic the language raises: messages and value shapes first, then + * process-wide checks, per-element checks, attribute blocks, gateways and + * blocks, handlers and events, declarations. */ import { @@ -137,7 +134,7 @@ import { ASYNC_FLAG_KEYS, ATTRIBUTE_BLOCK_RULES, attributeBlockRuleOf, - BOOLEAN_ENGINE_KEYS, + BOOLEAN_SETTING_KEYS, BUILTIN_FIELD_NAMES, BUILTIN_FIELD_VALIDATOR, BUILTIN_REQUIRED_FIELDS, @@ -147,6 +144,7 @@ import { CATCH_TRIGGERS, DATE_PATTERN_KEY, DECLARED_CODE_TRIGGERS, + DECISION_MODIFIER_KEYS, DECISION_RESULT_MAPPINGS, EMIT_TRIGGERS, END_TRIGGERS, @@ -260,11 +258,9 @@ export function registerValidationChecks(services: BpmnScriptServices) { type VersionPinnedElement = CallActivity | BusinessRuleTask | UserTask; -/** The two shapes `await` opens: a race branch is the same header with a body, - * down to the slot names, so both run through one set of payload rules. */ +/** A race branch is the same header with a body, so both share the payload rules. */ type CatchHeader = IntermediateCatchEvent | RaceBranch; -/** The statements whose head parens carry the settings of the gateways they lower to. */ type GatewayStatement = | IfStatement | WhileStatement @@ -272,44 +268,28 @@ type GatewayStatement = | ParallelStatement | RaceStatement; -/** A user task's own priority, `operaton:priority`, beside the job and external-task ones. */ const USER_PRIORITY_KEY = 'priority'; /** - * Keys whose value names something other than a process variable, so a - * bareword there neither warns as undeclared nor is scanned for the JUEL - * spelling rules. The priority keys and `businessKey` stay out because a - * bareword there lowers to `${...}`; `assignee` is in because it lowers to the - * plain text `operaton:assignee="demo"`, the user id - * (`TaskDecorator.initializeTaskAssignee`). The date keys are in so that - * declaring `deadline` cannot hide the unparseable-date error - * {@link BpmnScriptValidator.checkAttributeValues} raises for `dueDate = deadline`. + * A bareword under these names no variable: no undeclared warning, no JUEL + * scan. `assignee` lowers to a plain user id; the date keys are in so a + * declared `deadline` cannot hide the unparseable-date error. The priority + * keys and `businessKey` stay out because a bareword there lowers to `${...}`. */ export const NON_VARIABLE_ATTR_KEYS: ReadonlySet = new Set([ - 'label', - 'documentation', + ...PROCESS_HEADER_KEYS, 'formRef', ...BUSINESS_RULE_BINDING_KEYS, + ...DECISION_MODIFIER_KEYS, 'process', - 'binding', - 'version', - 'mapDecisionResult', ...USER_TASK_VERBATIM_KEYS.filter((key) => key !== USER_PRIORITY_KEY), ...engineSpellings('retryCycle'), 'resultVariable', - 'versionTag', - 'historyTimeToLive', - 'candidateStarterUsers', - 'candidateStarterGroups', 'initiator', 'validator', ...Object.values(CALL_MAPPER_KEY_BY_KIND), ]); -const BOOLEAN_ATTR_KEYS: ReadonlySet = new Set( - BOOLEAN_ENGINE_KEYS.flatMap(engineSpellings), -); - const TIMER_JOB_KEYS_WITHOUT_PRIORITY: readonly string[] = TIMER_JOB_KEYS.filter((key) => key !== 'jobPriority'); const TIMER_JOB_KEY_SET: ReadonlySet = new Set(TIMER_JOB_KEYS); @@ -317,7 +297,7 @@ const TIMER_JOB_KEY_SET: ReadonlySet = new Set(TIMER_JOB_KEYS); const DUE_DATE_KEYS: readonly string[] = ['dueDate', 'followUpDate']; const DUE_DATE_KEY_SET: ReadonlySet = new Set(DUE_DATE_KEYS); -/** Each parsed with `Integer.parseInt`, at deployment (`BpmnParse.parsePriority`) or when the task is created (`TaskDecorator.initializeTaskPriority`). */ +/** Parsed with `Integer.parseInt`, at deployment or at task creation. */ const PRIORITY_ATTR_KEYS: ReadonlySet = new Set([ ...engineSpellings('jobPriority'), TASK_PRIORITY_KEY, @@ -328,109 +308,82 @@ const RETRY_CYCLE_ATTR_KEYS: ReadonlySet = new Set( engineSpellings('retryCycle'), ); -/** - * Keys whose value the engine parses rather than takes as written, so a - * bareword or a number there reaches it as something it cannot read. The other - * text keys stay out: the engine takes them as written, and the process - * header's keys have rules of their own ({@link PROCESS_HEADER_VALUE_RULES}). - */ +/** Parsed by the engine, so a bareword or number there is unreadable to it. */ const TEXT_ATTR_KEYS: ReadonlySet = new Set([ ...RETRY_CYCLE_ATTR_KEYS, ...DUE_DATE_KEYS, ]); /** - * `TaskDecorator.initializeTaskDueDate` hands a string constant to - * `DueDateBusinessCalendar.resolveDuedate`, which parses a `P...` period or - * an ISO date-time and fails the task creation on anything else. + * `DueDateBusinessCalendar` reads a `P...` period or an ISO date-time; anything + * else fails task creation. */ const DUE_DATE_TEXT = /^(P|\d{4}-\d{2}-\d{2})/; -/** - * A bare name is refused too, unlike a priority's: `version` is in - * {@link NON_VARIABLE_ATTR_KEYS}, so the name would never be checked against - * the declarations, and a raw template is the spelling for a computed version. - */ +/** `version` names no variable, so a bare name is refused; a raw template computes one. */ const VERSION_TEXT = /^[1-9]\d*$/; -/** The one shape `ParseUtil.parseHistoryTimeToLive` reads: `^P(\d+)D$`, else `Integer.parseInt`. */ +/** `ParseUtil.parseHistoryTimeToLive`: `PD`, else `Integer.parseInt`. */ const HISTORY_TIME_TO_LIVE_TEXT = /^(P\d+D|\d+)$/; -/** `ACT_RE_PROCDEF.VERSION_TAG_` is 64 characters wide in every `create.engine.sql`; nothing checks the length before the insert. */ +/** `ACT_RE_PROCDEF.VERSION_TAG_` is 64 wide and nothing checks the length before the insert. */ const VERSION_TAG_MAX_LENGTH = 64; /** - * The duration `DurationHelper.parsePeriod` reads (no anchors, for reuse in an - * interval and a retry cycle's comma list): `PW` on its own, or the XML - * Schema duration `DatatypeFactory.newDuration` parses, which has no `W` and - * throws on a bare `P` or a `T` with nothing after it, so `P1W2D` deploys on a - * timer start and fails there. + * `DurationHelper.parsePeriod` (unanchored, for reuse): `PW` alone, or an + * XML Schema duration, which has no `W` and throws on a bare `P` or empty `T`. */ const ISO_DURATION_BODY = String.raw`(?:P\d+W|P(?!$)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?)`; -/** - * The period `DueDateBusinessCalendar.resolveDuedate` reads under `at`: Joda's - * `ISOPeriodFormat.standard`, which takes `W` beside the other fields. - */ +/** Joda's `ISOPeriodFormat.standard`, read under `at`; it takes `W` beside the other fields. */ const JODA_PERIOD_BODY = String.raw`P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?`; /** - * `DateTimeUtil.parseDateTime` is Joda's `ISODateTimeFormat.dateTimeParser`, - * whose grammar is `date-element ['T' [time-element] [offset]]`: the zone sits - * inside the `T` group, so `2026-12-01T` parses and `2026-12-01Z` is refused. - * Joda is looser still (single-digit fields, a time with no date); those - * spellings stay refused here. + * Joda's `dateTimeParser`: the zone sits inside the `T` group, so + * `2026-12-01T` parses and `2026-12-01Z` does not. Joda's looser spellings + * stay refused. */ const ISO_DATE_TIME_BODY = String.raw`(?:\d{4}(-\d{2}(-\d{2})?)?|\d{4}-\d{3}|\d{4}-W\d{2}(-\d)?)(T(\d{2}(:\d{2}(:\d{2})?)?([.,]\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)?)?`; /** - * The interval `DurationHelper.parseDurationOrTime` reads: a duration with an - * optional end date, or a start date with a duration or an end date. + * `DurationHelper.parseDurationOrTime`: a duration with optional end, or a + * start with a duration or end. */ const INTERVAL_BODY = String.raw`(?:${ISO_DURATION_BODY}(?:/${ISO_DATE_TIME_BODY})?|${ISO_DATE_TIME_BODY}/(?:${ISO_DURATION_BODY}|${ISO_DATE_TIME_BODY}))`; const REPEAT_BODY = String.raw`R\d*/${INTERVAL_BODY}`; /** - * `after`: an interval, the shape `DurationBusinessCalendar.resolveDuedate` - * parses. A repeat parses there too (`DurationHelper.parseRecurrence`) and - * the timer then fires once at the first interval, since only a cycle sets a - * repeat on its job (`TimerDeclarationImpl.initializeConfiguration`). + * An interval. A repeat parses too, but only a cycle sets a repeat on its job, + * so the timer fires once. */ const AFTER_TIME_TEXT = new RegExp(`^(${INTERVAL_BODY}|${REPEAT_BODY})$`); -/** `at`: a point in time, or a period counted from now (`DueDateBusinessCalendar.resolveDuedate` reads both). */ +/** A point in time, or a period from now. */ const AT_TIME_TEXT = new RegExp( `^(${JODA_PERIOD_BODY}|${ISO_DATE_TIME_BODY})$`, ); /** - * `CronTimer.parse` reads a six-field Spring cron (`CronType.SPRING53`, which - * also takes these seven nicknames and not `@reboot`): a seventh field fails - * at deployment. + * Spring's six-field cron plus these nicknames (not `@reboot`); a seventh field + * fails at deployment. */ const CRON_BODY = String.raw`(?:\S+(\s+\S+){5}|@(?:yearly|annually|monthly|weekly|daily|midnight|hourly))`; -/** `every`: a repeat or a cron expression (`CycleBusinessCalendar.resolveDuedate`). */ const EVERY_TIME_TEXT = new RegExp(`^(${REPEAT_BODY}|${CRON_BODY})$`); -/** - * `retryCycle`: a single repeat, or one-or-more comma-separated durations. - * `ParseUtil.parseRetryIntervals` only reads the `R.../...` repeat shape when - * it is the sole interval; every other spelling is a plain list of durations. - */ +/** `ParseUtil.parseRetryIntervals` reads the `R.../...` shape only as the sole interval. */ const RETRY_CYCLE_TEXT = new RegExp( `^(${REPEAT_BODY}|${ISO_DURATION_BODY}(,${ISO_DURATION_BODY})*)$`, ); -/** A non-negative whole number, bare or quoted, as {@link FORM_BOUND_TEXT} admits a signed one. */ const NON_NEGATIVE_INT_TEXT = /^\d+$/; -/** `DateFormType`'s constructor builds a `java.text.SimpleDateFormat` from `pattern`, which reads only these letters, a quoted literal run, or a non-letter. */ +/** The letters `SimpleDateFormat` reads, a quoted run, or a non-letter. */ const PATTERN_LETTERS_TEXT = /^(?:'[^']*'|[^A-Za-z]|[GyYMLwWDdFEuaHkKhmsSzZX])*$/; -/** The ISO shape a date field's default is refused in when no `pattern` overrides the engine's own "dd/MM/yyyy" (`ProcessEngineConfigurationImpl.initFormTypes`). */ +/** Refused as a date default unless `pattern` overrides the engine's "dd/MM/yyyy". */ const ISO_DATE_ONLY_TEXT = /^\d{4}-\d{2}-\d{2}$/; const ON_TRIGGERS_SET: ReadonlySet = new Set(ON_TRIGGERS); @@ -445,30 +398,25 @@ const EMIT_TRIGGERS_SET: ReadonlySet = new Set(EMIT_TRIGGERS); const ENGINE_KEY_SET: ReadonlySet = new Set(ENGINE_KEYS); const JOIN_ENGINE_KEY_SET: ReadonlySet = new Set(JOIN_ENGINE_KEYS); const RUN_ENGINE_KEY_SET: ReadonlySet = new Set(RUN_ENGINE_KEYS); -/** The element spelling of each run key, for the message a clause-less statement draws. */ const ENGINE_KEY_BY_RUN_KEY: Readonly> = Object.fromEntries(ENGINE_KEYS.map((key) => [runSettingKey(key), key])); -/** The one engine key with no per-run spelling (see `RUN_ENGINE_KEYS`); it draws a refusal of its own. */ +/** No per-run spelling exists (see `RUN_ENGINE_KEYS`), so it draws its own refusal. */ const RUN_JOB_PRIORITY_KEY = runSettingKey('jobPriority'); -/** What the head of a statement with a join takes: both gateways' settings. */ const SPLIT_AND_JOIN_KEY_SET: ReadonlySet = new Set([ ...ENGINE_KEYS, ...JOIN_ENGINE_KEYS, ]); -/** The head spelling of each join key, for the message a loop's parens draw. */ const ENGINE_KEY_BY_JOIN_KEY: Readonly> = Object.fromEntries( Object.entries(JOIN_KEY_BY_ENGINE_KEY).map(([key, join]) => [join, key]), ); const IO_DIRECTION_SET: ReadonlySet = new Set(IO_DIRECTIONS); const FIELD_BINDING_KEY_SET: ReadonlySet = new Set(FIELD_BINDING_KEYS); -/** The remaining binding keys, so the two lists cannot name the same word. */ const FIELDLESS_BINDING_KEYS: readonly string[] = BUSINESS_RULE_BINDING_KEYS.filter((key) => !FIELD_BINDING_KEY_SET.has(key)); const LISTENER_BINDING_KEY_SET: ReadonlySet = new Set( LISTENER_BINDING_KEYS, ); -/** The field bindings a listener can write: it binds no `type`. */ const LISTENER_FIELD_BINDING_KEYS: readonly string[] = LISTENER_BINDING_KEYS.filter((key) => FIELD_BINDING_KEY_SET.has(key)); const SHELL_FLAG_FIELD_SET: ReadonlySet = new Set(SHELL_FLAG_FIELDS); @@ -489,9 +437,8 @@ const TASK_LISTENER_EVENT_SET: ReadonlySet = new Set( TASK_LISTENER_EVENTS, ); /** - * Read off the block rules so the message cannot name a set the checks do not - * enforce. `attempt` shares its AST type with `subprocess`, so it has no row - * of its own to read and is added back here. + * Read off the block rules so the message matches what is enforced; `attempt` + * has no row of its own. */ export const PARAMETER_HOSTS_MESSAGE = `parameters belong on ${formatPlainWordList( [...Object.values(ATTRIBUTE_BLOCK_RULES), ATTEMPT_BLOCK_RULE] @@ -505,11 +452,7 @@ export const REPEATED_OUTPUT_MESSAGE = 'deploy it (BpmnParse.checkActivityOutputParameterSupported). Move the ' + 'mapping to a step after the repetition.'; -/** - * Read off the block rules, as {@link PARAMETER_HOSTS_MESSAGE} is. A listener - * has no row of its own, being a callback on an element rather than one, and - * is added back here. - */ +/** A listener has no row of its own, so it is added back. */ const FIELD_HOSTS_MESSAGE = `an injected field belongs on ${Object.values( ATTRIBUTE_BLOCK_RULES, ) @@ -521,9 +464,8 @@ export const noFieldHostMessage = (description: string) => `${capitalize(description)} cannot declare a 'field' parameter; ${FIELD_HOSTS_MESSAGE}`; /** - * `written` names the fieldless bindings the author wrote rather than every - * fieldless key, so the tail never names a key the subject cannot write: a - * listener takes neither `topic` nor `decision`. + * Names only the fieldless bindings written, since a listener takes neither + * `topic` nor `decision`. */ export const fieldBindingMessage = ( subject: string, @@ -549,7 +491,7 @@ export const LISTENER_FIELD_BINDINGS = { const fieldlessBindingsOf = (attrs: readonly Setting[]): string[] => bindingKeysOf(attrs, FIELDLESS_BINDING_KEYS); -/** The extras are legal beside `topic` alone: `parseExternalServiceTask`, their one reader, runs for `operaton:type="external"` and for nothing else. */ +/** `parseExternalServiceTask`, their one reader, runs for `operaton:type="external"` alone. */ const EXTERNAL_HOSTS_PHRASE = `${formatPlainWordList( Object.values(ATTRIBUTE_BLOCK_RULES) .filter((rule) => rule.externalExtras) @@ -608,12 +550,6 @@ export const MAP_DECISION_RESULT_UNREAD_MESSAGE = export const decisionModifierMessage = (key: string) => `Setting '${key}' stands only beside 'decision': the engine reads 'binding', 'version' and 'mapDecisionResult' on a step answering a decision table alone (BpmnParse.parseBusinessRuleTask).`; -const DECISION_MODIFIER_KEYS: readonly string[] = [ - 'binding', - 'version', - 'mapDecisionResult', -]; - export const HISTORY_TIME_TO_LIVE_MESSAGE = "Setting 'historyTimeToLive' takes a quoted number of days, 'PD' or ''; " + 'the engine reads the attribute as text, never as an expression, and refuses to deploy anything else (ParseUtil.parseHistoryTimeToLive).'; @@ -648,17 +584,14 @@ const BUILTIN_BEHAVIOUR_CLASS: Readonly> = { }; /** - * The bindings `BpmnParse.parseServiceTaskLike` refuses a result variable - * beside, each with the attribute name its refusal quotes. The `expression` - * branch alone is built with the variable; the `type` branches never read it, - * and a `decision` reads it on its own path. + * `parseServiceTaskLike` refuses a result variable beside these, keyed to the + * attribute its refusal quotes; `decision` reads it on its own path. */ const RESULT_VARIABLE_REFUSING_BINDINGS: Readonly> = { class: 'class', delegate: 'delegateExpression', }; -/** The element name the same refusal quotes, per kind that reaches that method. */ const SERVICE_TASK_LIKE_ELEMENT: Readonly< Record<(ServiceTask | SendTask | BusinessRuleTask)['$type'], string> > = { @@ -707,10 +640,8 @@ export const fieldValueMessage = (name: string) => 'put the value in quotes.'; /** - * The engine reads `stringValue` as fixed text - * (`BpmnParse.parseStringFieldDeclaration`), but which slot a value is written - * to is decided by its opening, so a literal opening with one can only ever - * reach the expression slot. + * The opening decides the slot, so a literal opening with one can only reach + * the expression slot. */ export const escapedFieldLiteralMessage = (name: string) => `Field '${name}' cannot carry quoted text opening with '\${' or '#{': the expression slot is picked by that opening, so the text would be evaluated rather than injected as written. Drop the backslash to write an expression.`; @@ -741,7 +672,6 @@ export const juelKeywordMessage = (word: string, key: string) => export const hyphenNameMessage = (name: string, key: string) => `'${name}' carries a hyphen, which JUEL scans as a minus (Scanner.nextIdentifier), so the engine reads a subtraction. Reach the variable through a string key instead: "\${${key}}".`; -/** The slots whose value the engine casts to a Boolean, each throwing in its own method. */ type ConditionSlot = 'condition' | 'until'; export const nonBooleanConditionMessage = ( @@ -755,20 +685,17 @@ export const nonBooleanConditionMessage = ( export const COMPOSITE_OPERAND_MESSAGE = "A composite template cannot be spliced into the surrounding expression: JUEL has no '${' token once inside an expression (Scanner.nextEval), so only a raw that is exactly one '${...}' with no '}' outside a string literal in its body can be an operand. Write the whole expression as one raw template instead."; -/** The keys whose value the engine evaluates as EL, so quoted text reaches it as a string. */ const EL_BINDING_KEYS: ReadonlySet = new Set([ 'expression', 'delegate', CALL_MAPPER_KEY_BY_KIND.delegateExpression, ]); -/** The keys whose value is loaded as a class name, each with the key that resolves one at runtime instead. */ const CLASS_BINDING_ALTERNATIVE: Readonly> = { class: 'delegate', [CALL_MAPPER_KEY_BY_KIND.class]: CALL_MAPPER_KEY_BY_KIND.delegateExpression, }; -/** Every key a blank literal is refused under, with what its value names. */ const EMPTY_BINDING_NOUN: Readonly> = { class: 'class to load', expression: 'expression to evaluate', @@ -791,11 +718,8 @@ export const templateAsClassMessage = (key: string, alternative: string) => `Setting '${key}' takes a class name, loaded as written (ClassDelegateUtil.instantiateDelegate); a "\${...}" template there is not evaluated. Write '${key}: com.example.X', or '${alternative}: "\${bean}"' to resolve one at runtime.`; /** - * A blank literal is refused under every key alike, though the engine treats - * them apart: `validateServiceTaskLike` and `parseExecutionListener` refuse a - * blank `class`, `expression` or `delegateExpression` at deployment, while a - * blank `topic`, `decision`, `process`, mapping class, `delegate` or - * `mapperDelegate` deploys and then names nothing, or throws, at the step. + * The engine refuses a blank `class`, `expression` or `delegateExpression` at + * deployment; the other keys deploy blank and fail at the step. */ function bindingValueMessage(key: string, value: Expr): string | undefined { const noun = EMPTY_BINDING_NOUN[key]; @@ -846,7 +770,6 @@ export const constraintMisfitMessage = ( ) => `Constraint '${name}' fits a ${formatPlainWordList(fits)} field, not the ${field.type} field '${field.id}': the engine checks a submitted ${formatPlainWordList(fits)} alone and fails every other submission.`; -/** `FormTypes.parseFormPropertyType` reads `datePattern` under `type="date"` alone. */ export const patternMisfitMessage = (field: FormField) => `Setting 'pattern' is the date pattern a 'date' field is parsed with; '${field.id}' is a ${field.type} field, which the engine reads no pattern off.`; @@ -874,17 +797,12 @@ export const VALIDATOR_EMPTY_MESSAGE = export const valuesOnNonEnumMessage = (field: FormField) => `Value lines belong on an 'enum' field; '${field.id}' is a ${field.type} field.`; -/** `EnumFormType.validateValue` refuses any value outside the (empty) map. */ export const emptyEnumMessage = (id: string) => `Enum field '${id}' offers no values, so the engine rejects every submitted value; add a value line such as 'basic "Basic"'.`; export const duplicateValueMessage = (id: string) => `Duplicate value '${id}'.`; -/** - * `FormFieldHandler.createFormField` converts the default through the enum type - * on every render of the form, so the deployment succeeds and the form never - * opens. - */ +/** The default is converted on every render, so it deploys and the form never opens. */ export const enumDefaultMessage = ( id: string, value: string, @@ -901,7 +819,7 @@ export const booleanDefaultMessage = (id: string, text: string) => `The default ${text} of boolean field '${id}' is not "true" or "false"; the engine ` + 'reads any other spelling as false (BooleanFormType.convertValue).'; -/** An ISO-shaped default is the one misfit worth guessing at: it reads as a legal date to a human and fails the engine's own pattern. */ +/** An ISO date reads as legal to a human and fails the engine's own pattern. */ export const isoDateDefaultMessage = (id: string, value: string) => `The default "${value}" of date field '${id}' is an ISO date, but the engine's default ` + 'pattern is "dd/MM/yyyy" (ProcessEngineConfigurationImpl.initFormTypes); add a \'pattern\' ' + @@ -921,17 +839,15 @@ export const propertyValueMessage = (name: string) => const isQuotedMatching = (value: Expr | undefined, shape: RegExp): boolean => isLiteralString(value) && shape.test(value.value); -/** A bare integer, signed or not, as {@link FORM_BOUND_TEXT} admits it quoted. */ const isIntegerValue = (value: Expr | undefined): boolean => value !== undefined && integerLiteralText(value) !== undefined; -/** The message a value of the wrong shape draws, or `undefined` where it fits. */ type ValueShapeRule = ( key: string, value: Expr | undefined, ) => string | undefined; -/** `false` has no representation: a flag is on while written and off otherwise. */ +/** A flag is on while written; `false` has no representation. */ const literalTrue: ValueShapeRule = (key, value) => isLiteralBool(value) ? value.value === 'true' @@ -944,21 +860,13 @@ const integer: ValueShapeRule = (key, value) => ? undefined : integerBoundMessage(key); -/** - * `validator` reads as `class:` does: a quoted class, a bare dotted name, or - * an expression; only a blank literal is refused, the one shape - * `FormValidators.createValidator` refuses to deploy. - */ +/** A blank literal is the one shape `FormValidators.createValidator` refuses to deploy. */ const nonEmptyText: ValueShapeRule = (_key, value) => isLiteralString(value) && value.value.length === 0 ? VALIDATOR_EMPTY_MESSAGE : undefined; -/** - * `pattern` is always literal text (`FormTypes.parseFormPropertyType` reads it - * off the XML attribute, never an expression), so a raw template is refused - * the same as an empty or illegal-letter one. - */ +/** Read off the XML attribute, never as an expression. */ const datePatternShape: ValueShapeRule = (_key, value) => { if (!isLiteralString(value) || value.value.length === 0) { return PATTERN_VALUE_MESSAGE; @@ -997,18 +905,14 @@ const versionTag: ValueShapeRule = (_key, value) => ? VERSION_TAG_LENGTH_MESSAGE : undefined; -/** A bare name is an id as written; only a template, quoted whole or inside the comma-split list, is refused. */ +/** A bare name is an id; only a template is refused. */ const candidateStarters: ValueShapeRule = (key, value) => isRawExpr(value) || (isLiteralString(value) && EXPRESSION_ANYWHERE.test(value.value)) ? candidateStarterMessage(key) : undefined; -/** - * What each process header setting takes. `BpmnParse.parseProcess` reads - * every one of them as text, so none is an expression, unlike the same - * `label` on a task, whose name `TaskDecorator.initializeTaskName` evaluates. - */ +/** `BpmnParse.parseProcess` reads each as text, unlike a task's `label`, which is evaluated. */ const PROCESS_HEADER_VALUE_RULES: Readonly> = { label: headerLiteral, documentation: headerLiteral, @@ -1034,7 +938,6 @@ export const TIMER_PAYLOAD_MESSAGE = `'timer("PT1H")', 'timer(at: "2026-08-01T09:00:00")', or ` + `'timer(every: "R/PT10M")'.`; -/** A listener writes the clause after its event word, so it needs its own wording. */ export const LISTENER_TIMER_PAYLOAD_MESSAGE = TIMER_PAYLOAD_PREFIX + `'after "PT1H"', 'at "2026-08-01T09:00:00"', or 'every "R/PT10M"'.`; @@ -1064,7 +967,6 @@ export const EVERY_SHAPE_MESSAGE = timerShapeMessage( 'CycleBusinessCalendar.resolveDuedate', ); -/** The shape each particle's calendar reads. */ const TIMER_SHAPE_BY_PARTICLE: Readonly< Record > = { @@ -1073,11 +975,7 @@ const TIMER_SHAPE_BY_PARTICLE: Readonly< every: { pattern: EVERY_TIME_TEXT, message: EVERY_SHAPE_MESSAGE }, }; -/** - * `only` is separate from `clause` because the start clause carries a name - * slot: quoting `start S condition` at an author who never wrote an `S` puts - * a placeholder in front of them that nothing introduces. - */ +/** `only` omits the name slot, which an author who wrote none should not see as a placeholder. */ const CONDITION_PHRASING = { handler: { subject: 'A condition handler', @@ -1234,7 +1132,6 @@ export const CANCEL_NOT_RAISED_MESSAGE = 'A cancel is not raised: it is how a block gives itself up; write ' + `'end cancel' inside the 'attempt' block.`; -/** Unlike a thrower, an awaiting author needs the catch surface named too. */ export const CANCEL_NOT_AWAITED_MESSAGE = 'A cancel is not awaited: it is how a block gives itself up; write ' + `'end cancel' inside the 'attempt' block, and ` + @@ -1269,7 +1166,6 @@ export const LINK_CATCH_FLOW_MESSAGE = "'throw', 'goto', or 'emit link', because a link catch is entered only by " + "'emit link' of the same name."; -/** `BpmnParse.parseIntermediateCatchEvent` refuses a link catch behind an event-based gateway. */ export const LINK_IN_RACE_MESSAGE = "'link' cannot head a branch of an 'await' block: the engine refuses a link " + `catch after an event-based gateway; write 'await link("")' as its ` + @@ -1318,10 +1214,8 @@ export const noPerRunJobMessage = ( `exists only when ${formatWordList(pairing)} is also set.`; /** - * A timer creates its own job regardless of the async flags, so none of the - * three keys needs one beside it: a node with its own timer trigger, or a - * host-less `on timer` handler's own start, the element `checkTimerHeadKeys` - * already treats as the one the timer job's settings land on. + * A timer creates its own job regardless of the async flags: a node with its + * own timer trigger, or a host-less `on timer` handler's start. */ function isTimerCarrier(owner: AttributeOwner): boolean { if ('trigger' in owner && owner.trigger === 'timer') return true; @@ -1336,12 +1230,9 @@ function isTimerCarrier(owner: AttributeOwner): boolean { } /** - * `BpmnParse.parseSignalCatchEventDefinition` gives a process's signal start - * and an intermediate signal catch a subscription job priced from the - * catcher's `jobPriority`; the job keeps the default `exclusive`, and the - * retry cycle is read only for a timer or an async element, so the other two - * keys still need a flag. A boundary signal and an event sub-process signal - * start declare no such job, so a handler is not one of these. + * A process signal start and an intermediate signal catch get a subscription + * job priced from `jobPriority`; the other two keys still need a flag. A + * boundary or event sub-process signal declares no such job. */ function isSignalSubscriptionJobCarrier(owner: AttributeOwner): boolean { return ( @@ -1353,14 +1244,9 @@ function isSignalSubscriptionJobCarrier(owner: AttributeOwner): boolean { } /** - * Ids the `astToIr` desugarer synthesizes; a statement name - * matching one produces duplicate-id IR. A process or subprocess body's own - * `StartEvent_` and `EndEvent_` are matched exactly by - * {@link mintedTerminalRole} instead, since a prefix would refuse the - * Modeler's default `StartEvent_1`; a handler body's and a boundary - * escape's terminals are minted off a coordinate no author writes, so only a - * prefix catches them. `_di$`, `BPMNDiagram_` and `BPMNPlane_` are `irToXml`'s - * layout ids, which collide as a duplicate `xs:ID` the same way. + * Ids the desugarer and layout synthesize. A container's own + * `StartEvent_`/`EndEvent_` are matched exactly by + * {@link mintedTerminalRole} so the Modeler's `StartEvent_1` stays legal. */ const RESERVED_ID_PATTERNS: ReadonlyArray = [ /^Gateway_.+_(split|join|fork|loop|race)$/, @@ -1377,7 +1263,6 @@ const RESERVED_ID_PATTERNS: ReadonlyArray = [ /^BPMNPlane_/, ]; -/** Which implicit terminal of `containerId` the desugarer mints `name` for, if either. */ function mintedTerminalRole( name: string, containerId: string, @@ -1387,10 +1272,6 @@ function mintedTerminalRole( return undefined; } -/** - * The patterns as the diagnostic spells them, so the sentence an author reads - * cannot drift from the list that rejected them. - */ const RESERVED_ID_SHAPE_LIST = RESERVED_ID_PATTERNS.map( (pattern) => `'${pattern.source.replace(/^\^/, '').replace(/\$$/, '').replaceAll('.+', '...')}'`, @@ -1441,10 +1322,8 @@ function statementName(stmt: Statement): string | undefined { } /** - * The subscription a process-level start registers, keyed as the engine - * compares it: a message or signal by name, a condition by its rendered text - * (`ConditionalEventDefinition.getConditionAsString`); a plain or timer start - * registers none. + * Keyed as the engine compares: a message or signal by name, a condition by its + * rendered text; a plain or timer start registers none. */ function startSubscriptionKey(start: StartEvent): string | undefined { if (start.trigger === 'condition') { @@ -1459,8 +1338,7 @@ function startSubscriptionKey(start: StartEvent): string | undefined { function conditionTextOf(start: StartEvent): string | undefined { const value = payloadItemOf(start.items)?.value; if (value === undefined || !isExpr(value)) return undefined; - // A partial parse leaves a child slot empty and the render throws; an - // unparsed condition keys nothing, as `duplicateKey` treats a missing part. + // A partial parse leaves a slot empty and the render throws. try { return renderExpression(value); } catch { @@ -1468,12 +1346,7 @@ function conditionTextOf(start: StartEvent): string | undefined { } } -/** - * Parser error recovery leaves a mandatory slot empty, so a `Block` and a name - * are `undefined`-capable however the generated types declare them. A check - * whose message would print a missing name stands down: the parse error - * already named the mistake. - */ +/** Error recovery leaves mandatory slots `undefined` whatever the generated types say. */ function blockStatements(block: Block | undefined): Statement[] { return block?.statements ?? []; } @@ -1498,7 +1371,7 @@ function childBlocks(stmt: Statement): Array { return []; } -/** A handler never joins the main sequence, so a handler-only body counts as empty. */ +/** A handler never joins the main sequence. */ function hasNoFlowStep(statements: Statement[]): boolean { return statements.every(isOnHandler); } @@ -1512,14 +1385,9 @@ function isLinkCatch(node: AstNode): node is IntermediateCatchEvent { } /** - * Whether `stmt`, once reached, always ends or diverts the flow. A compound - * counts only when every branch does, which is exactly when the transform - * prunes its synthesized join to zero incoming flows. An `if` without an - * `else` and a `while` never count: their gateway keeps a non-terminating - * exit. A `do ... while` counts when its body does, since the body runs - * before the gateway is ever reached. An `emit link` counts like a `goto`: - * `BpmnParse.parseSequenceFlow` refuses a flow out of a link throw as an - * invalid source. + * Whether `stmt` always ends or diverts the flow, which is exactly when the + * transform prunes its join. An `if` without `else` and a `while` never do; an + * `emit link` does, since Operaton refuses a flow out of a link throw. */ function statementTerminates(stmt: Statement): boolean { if ( @@ -1530,36 +1398,17 @@ function statementTerminates(stmt: Statement): boolean { ) { return true; } - if (isDoWhileStatement(stmt)) { - return blockTerminates(blockStatements(stmt.body)); - } - if (isIfStatement(stmt) && stmt.elseBlock !== undefined) { - return ( - blockTerminates(blockStatements(stmt.then)) && - stmt.elseIfs.every((elseIf) => - blockTerminates(blockStatements(elseIf.body)), - ) && - blockTerminates(blockStatements(stmt.elseBlock)) - ); - } - if (isParallelStatement(stmt)) { - // With every branch conditioned, the fallback the transform adds runs - // straight to the join, which therefore always keeps an arriving path. - // Beside an `else` the fallback runs onto that branch, and beside a plain - // branch none is reserved, so those cases come down to the branches. - if (stmt.branches.every((b) => b.condition !== undefined)) { - return false; - } - return stmt.branches.every((branch) => - blockTerminates(blockStatements(branch.body)), - ); - } - if (isRaceStatement(stmt)) { - return stmt.branches.every((branch) => - blockTerminates(blockStatements(branch.body)), - ); - } - return false; + // With every parallel branch conditioned, the transform's fallback runs straight to the join. + const counts = isIfStatement(stmt) + ? stmt.elseBlock !== undefined + : isDoWhileStatement(stmt) || + isRaceStatement(stmt) || + (isParallelStatement(stmt) && + stmt.branches.some((branch) => branch.condition === undefined)); + return ( + counts && + childBlocks(stmt).every((block) => blockTerminates(blockStatements(block))) + ); } function hasConditionedBranch(stmt: ParallelStatement): boolean { @@ -1578,7 +1427,6 @@ function blockTerminates(statements: Statement[]): boolean { ); } -/** A handler is a side path off the main flow, not a step in the chain a start may close. */ function previousFlowStatement( statements: Statement[], index: number, @@ -1590,12 +1438,7 @@ function previousFlowStatement( return undefined; } -/** - * The noun for an element whose parens have no payload slot, or `undefined` - * where the unkeyed value is read as a trigger's payload or is already - * refused under a message of its own (a gateway head, a form field, a code - * declaration). - */ +/** `undefined` where the unkeyed value is a payload or has its own message. */ function settingsOnlyOwnerDescription( owner: ParenValue['$container'], ): string | undefined { @@ -1620,10 +1463,7 @@ function settingsOnlyOwnerDescription( : undefined; } -/** - * A composite duplicate key, `undefined` when any part was left unparsed: a - * template literal would stringify the missing slot into a self-colliding key. - */ +/** `undefined` when a part is unparsed, which would stringify into a self-colliding key. */ function duplicateKey( ...parts: ReadonlyArray ): string | undefined { @@ -1656,10 +1496,7 @@ export class BpmnScriptValidator { this.variables = services.references.VariableSymbolProvider; } - /** - * The transform converts only the first process, so a stray second one gets - * a diagnostic here rather than being dropped. - */ + /** The transform converts only the first process. */ checkModel = (model: Model, accept: ValidationAcceptor): void => { forEachDuplicate( model.processes, @@ -1675,15 +1512,9 @@ export class BpmnScriptValidator { }; /** - * A start after a live chain is refused rather than guessed at: Operaton - * accepts a flow into a start (`BpmnParse.parseSequenceFlow` has no arm for - * that destination) and runs it as a pass-through step, so the page would - * state an ambiguity the engine resolves one way at runtime. A subprocess, - * attempt block or handler body takes a start first and nowhere else, since - * the engine allows one start per such scope - * (`BpmnParse.parseScopeStartEvent`); a hosted handler's body lowers inline - * into its host's container, so it is no scope of its own and gets its own - * message. + * Operaton runs a flow into a start as a pass-through, so a start after a live + * chain is refused. A subprocess, attempt or handler body allows one start, + * first; a hosted handler's body lowers into its host's container. */ private checkStartPosition( process: Process, @@ -1735,16 +1566,9 @@ export class BpmnScriptValidator { } /** - * The engine methods behind the three warnings, which do not name them: with - * no plain or timer start, `initial` stays null and starting by key throws - * (`ProcessDefinitionImpl.ensureDefaultInitialExists`); a start form binds - * to `initial` only (`BpmnParse.parseStartFormHandlers`); and - * `BpmnParse.parseProcessDefinitionStartEvent` sets `operaton:initiator` - * off every start in document order, so the last one wins. A body that - * does not open with a `start` gets a plain start minted for it, which is - * in the engine's count like a written one: the candidates are - * `[implicit, ...written plain or timer starts]`, and everything after the - * first is a second default start. + * With no plain or timer start, starting by key throws; a start form binds to + * the initial start only; `initiator` is set off every start, last wins. A + * body not opening with `start` gets one minted, which counts as the first. */ private checkDefaultStart( process: Process, @@ -1811,7 +1635,6 @@ export class BpmnScriptValidator { } } - /** Every check that needs the whole process at once; the symbol table is built once. */ checkProcess = (process: Process, accept: ValidationAcceptor): void => { if (process.name !== undefined && hasNoFlowStep(process.body)) { accept( @@ -1845,16 +1668,10 @@ export class BpmnScriptValidator { }; /** - * Operaton accepts a flow into a link catch (`BpmnParse.parseSequenceFlow` - * gives it an ordinary transition); it is refused here because a modeller's - * link target never has an incoming flow, and an imported catch would then - * print after a dead fall-through the way the diagram drew it. That check - * sits before the `goto` re-rooting so a catch some `goto` names draws only - * the `goto` rule's error; a start after a live chain is - * {@link checkStartPosition}'s to refuse, since a start after a start is - * legal and `reachable` alone cannot tell. The scan is sound rather than - * exhaustive: a dead step may go unreported, a live one is never wrongly - * rejected. + * Operaton accepts a flow into a link catch; it is refused because a modeller's + * link target never has one. Checked before `goto` re-rooting so a named catch + * draws only the `goto` error. Sound, not exhaustive: a dead step may go + * unreported, a live one is never rejected. */ private checkUnreachableStatements( process: Process, @@ -1910,12 +1727,10 @@ export class BpmnScriptValidator { } /** - * A throw resolves against the catch in its own container, else the first - * in document order, so a duplicate catch is reported once rather than - * through every throw beside it. A flow resolves only at its own level - * (`ScopeImpl.findActivityAtLevelOfSubprocess`), so both ends must share a - * flow container, as a `goto` and its target must; a catch with no throw - * deploys, and an imported diagram may carry one, so it only warns. + * A throw resolves to the catch in its own container, else the first in + * document order. Both ends must share a flow container + * (`ScopeImpl.findActivityAtLevelOfSubprocess`); a catch with no throw + * deploys, so it only warns. */ private checkLinkEvents(process: Process, accept: ValidationAcceptor): void { const throws: EmitStatement[] = []; @@ -1996,18 +1811,19 @@ export class BpmnScriptValidator { } } - /** - * A `var`, a `form` field, and a catch binding all bind the same runtime - * process variable, so every declaration of a name must agree on the type. A - * catch binding always fills a `string`. - */ + /** Each binds the same process variable; a catch binding always fills a `string`. */ private checkFormVariableAgreement( process: Process, accept: ValidationAcceptor, ): void { const declaredType = new Map(); - // An unparsed name or type would seed `undefined`, which prints as a name - // and hides the next genuine disagreement. + /** Records a first declaration as a side effect. */ + const clash = (name: string, type: VarType): VarType | undefined => { + const prior = declaredType.get(name); + if (prior === undefined) declaredType.set(name, type); + return prior === type ? undefined : prior; + }; + // An unparsed name or type would seed `undefined` and hide the next disagreement. for (const decl of process.decls) { if ( isVarDecl(decl) && @@ -2021,16 +1837,13 @@ export class BpmnScriptValidator { if (isOnHandler(node)) { for (const binding of caughtBindingsOf(node.items)) { if (binding.variable === undefined) continue; - const prior = declaredType.get(binding.variable); - if (prior === undefined) { - declaredType.set(binding.variable, 'string'); - } else if (prior !== 'string') { - accept( - 'error', - `Catch-binding variable '${binding.variable}' is typed 'string', but '${binding.variable}' is already declared as '${prior}'; the types must agree.`, - { node: binding.node, property: 'value' }, - ); - } + const prior = clash(binding.variable, 'string'); + if (prior === undefined) continue; + accept( + 'error', + `Catch-binding variable '${binding.variable}' is typed 'string', but '${binding.variable}' is already declared as '${prior}'; the types must agree.`, + { node: binding.node, property: 'value' }, + ); } continue; } @@ -2038,25 +1851,20 @@ export class BpmnScriptValidator { for (const form of node.forms) { for (const field of form.fields) { if (field.id === undefined || field.type === undefined) continue; - // A word that is no type is reported by the form block check. const type = formFieldVariableType(field.type); if (type === undefined) continue; - const prior = declaredType.get(field.id); - if (prior === undefined) { - declaredType.set(field.id, type); - } else if (prior !== type) { - accept( - 'error', - `Form field '${field.id}' is typed '${field.type}', but '${field.id}' is already declared as '${prior}'; the types must agree.`, - { node: field, property: 'type' }, - ); - } + const prior = clash(field.id, type); + if (prior === undefined) continue; + accept( + 'error', + `Form field '${field.id}' is typed '${field.type}', but '${field.id}' is already declared as '${prior}'; the types must agree.`, + { node: field, property: 'type' }, + ); } } } } - /** A reserved name would produce duplicate-id IR; the IDE error comes first. */ private checkReservedNames( named: NamedStatement[], accept: ValidationAcceptor, @@ -2086,7 +1894,7 @@ export class BpmnScriptValidator { } } - /** The symbol provider stays last-wins; this check surfaces the conflict to the author. */ + /** The symbol provider stays last-wins; this surfaces the conflict. */ private checkDuplicateVarDecls( process: Process, accept: ValidationAcceptor, @@ -2129,7 +1937,6 @@ export class BpmnScriptValidator { this.checkFlags(process.items, [], 'a process header', accept); } - /** A step name repeated anywhere in the process makes `goto ` ambiguous. */ private checkDuplicateStatementNames( process: Process, named: NamedStatement[], @@ -2149,9 +1956,7 @@ export class BpmnScriptValidator { ), ); - // The document has one `bpmn:process id`; a step of the same name mints a - // second element sharing it, which `irToXml` refuses as a duplicate - // `xs:ID` before layout. + // A step named like the process duplicates the `bpmn:process` id. for (const node of named) { if (node.name !== process.name) continue; accept( @@ -2163,7 +1968,6 @@ export class BpmnScriptValidator { } } - /** Every read of such a variable is refused too; the declaration is where the name is chosen. */ private checkKeywordVarDecls( process: Process, accept: ValidationAcceptor, @@ -2184,9 +1988,7 @@ export class BpmnScriptValidator { symbols: VariableTable, accept: ValidationAcceptor, ): void { - // An `out` source is evaluated in the called process's scope, which the - // caller's symbol table cannot judge, at any nesting depth. `in` stays - // checked. + // An `out` source is evaluated in the called process's scope. const enclosingMapping = AstUtils.getContainerOfType( expr, isVariableMapping, @@ -2197,9 +1999,6 @@ export class BpmnScriptValidator { const container = expr.$container; if (isVarRef(expr)) { - // A name position is exempt from the warning for the same reason a code - // position is: the word names something other than a variable there, and - // the message below is the one the author needs. const nameTrigger = nameTriggerOf(expr); const variableUse = isVariableUse(expr); if (nameTrigger !== undefined) { @@ -2209,12 +2008,8 @@ export class BpmnScriptValidator { { node: expr, property: 'ref' }, ); } else if (variableUse || readsExternalTask(expr)) { - // A bare collection name is written as it is and looked up as a - // variable (`operaton:collection`), so JUEL never scans it; a bare - // `in`/`out` mapping source is exempt the same way: `lowerCallMapping` - // writes it as a plain `source` attribute, and - // `BpmnParse.parseCallableElementProvider` wraps that attribute in a - // `ConstantValueProvider`, never an expression. + // A bare collection name or `in`/`out` source is written as a plain + // attribute the engine looks up or takes as a constant, never evaluated. const isBareLookupName = expr.accessors.length === 0 && (expr.$containerProperty === 'collection' || @@ -2235,8 +2030,7 @@ export class BpmnScriptValidator { if (isRawExpr(expr)) { checkRawTemplateNames(expr.raw, { node: expr, property: 'raw' }, accept); - // A raw's body is spliced into the expression around it, which only one - // template has; the top of a position is rendered as written instead. + // A raw's body is spliced into the surrounding expression; the top is rendered as written. if ( (isExpr(container) || isAccessor(container)) && singleTemplateBody(expr.raw) === undefined @@ -2258,8 +2052,7 @@ export class BpmnScriptValidator { } } - // The grammar cannot refuse this: a payload is one `Expr` slot, and - // `message("OrderReceived")` needs the string literal it also admits here. + // The grammar cannot refuse this: `message("OrderReceived")` needs the string literal. if (isLiteralString(expr) && expr.value.length > 0) { const codeTrigger = codeTriggerOf(expr); if (codeTrigger !== undefined) { @@ -2311,7 +2104,7 @@ export class BpmnScriptValidator { } const type = symbols.get(operand.ref.$refText)?.type; if (type === undefined) { - continue; // Undeclared: handled by the warning, not a type error. + continue; } if (!allowed.has(type)) { accept( @@ -2369,7 +2162,10 @@ export class BpmnScriptValidator { this.checkStartPayload(start, TRIGGER_PAYLOAD[start.trigger]!, accept); }; - /** A host-less `on timer` writes its job keys onto the start event of its body, so a key written on both aims two values at one job and the lowering keeps the head's. */ + /** + * A host-less `on timer` writes its job keys onto its body's start, so a key + * on both collides; the head's wins. + */ private checkTimerHeadKeys( start: StartEvent, owner: AstNode, @@ -2387,11 +2183,7 @@ export class BpmnScriptValidator { } } - /** - * Mirrors {@link checkCatchPayload}. Neither gets - * {@link checkTimerRepeatsOnce}'s warning: a repeating start is a - * legitimate schedule, not a one-shot mistake. - */ + /** No repeat warning: a repeating start is a legitimate schedule. */ private checkStartPayload( start: StartEvent, rule: TriggerPayloadRule, @@ -2406,10 +2198,7 @@ export class BpmnScriptValidator { ); } - // `RAW_TEMPLATE` is anchored at the opening quote, so only a name that - // opens with `${` or `#{` lexes as a raw template; an opener further in - // leaves the name a plain string. Both reach here as text through - // `payloadTextOf`. + // `RAW_TEMPLATE` is anchored at the quote, so an opener further in lexes as a plain string. if ( start.trigger === 'message' && name !== undefined && @@ -2444,8 +2233,8 @@ export class BpmnScriptValidator { return; } - // The scope the engine reads is the enclosing container, so a cancel end - // in an `if` branch of the block still ends the block. + // The engine reads the enclosing container, so a cancel end in an `if` + // branch still ends the block. if ( end.trigger === 'cancel' && !isAttemptBlock(enclosingFlowContainer(end)) @@ -2477,11 +2266,7 @@ export class BpmnScriptValidator { this.checkFormReference(task, accept); }; - /** - * Operaton refuses to deploy a task naming a form both ways, or with no - * binding at all, so each is an error; both land on the task's name, as - * {@link checkBindingVersionExclusion}'s does. - */ + /** Operaton refuses a task naming a form both ways or with no binding. */ private checkFormReference(task: UserTask, accept: ValidationAcceptor): void { const attrs = settingsOf(task.items); const writes = (key: string) => attrs.some((attr) => attr.key === key); @@ -2504,27 +2289,19 @@ export class BpmnScriptValidator { this.checkBindingVersionExclusion(task, 'A user task', accept); } - /** One check for both: the engine runs a send task the way it runs a service task. */ + /** The engine runs a send task as a service task. */ checkServiceTaskAttributes = ( task: ServiceTask | SendTask, accept: ValidationAcceptor, ): void => { this.checkAttributeBlock(task, accept); if (task.name === undefined) return; - - const subject = `${isServiceTask(task) ? 'Service' : 'Send'} task '${task.name}'`; - if ( - this.checkExactlyOneBinding( - settingsOf(task.items), - SERVICE_TASK_BINDING_KEYS, - subject, - { node: task, property: 'name' }, - accept, - ) - ) { - this.checkBuiltinBinding(task, subject, accept); - this.checkResultVariableBinding(task, accept); - } + this.checkImplementationBinding( + task, + `${isServiceTask(task) ? 'Service' : 'Send'} task '${task.name}'`, + SERVICE_TASK_BINDING_KEYS, + accept, + ); }; checkBusinessRuleTask = ( @@ -2534,19 +2311,12 @@ export class BpmnScriptValidator { this.checkAttributeBlock(task, accept); if (task.name !== undefined) { - const subject = `Decision step '${task.name}'`; - if ( - this.checkExactlyOneBinding( - settingsOf(task.items), - BUSINESS_RULE_BINDING_KEYS, - subject, - { node: task, property: 'name' }, - accept, - ) - ) { - this.checkBuiltinBinding(task, subject, accept); - this.checkResultVariableBinding(task, accept); - } + this.checkImplementationBinding( + task, + `Decision step '${task.name}'`, + BUSINESS_RULE_BINDING_KEYS, + accept, + ); } this.checkDecisionModifiers(task, accept); this.checkBindingAttribute(task, accept); @@ -2554,11 +2324,24 @@ export class BpmnScriptValidator { this.checkDecisionResultMapping(task, accept); }; + /** The checks reading the binding's value run only once exactly one binding is written. */ + private checkImplementationBinding( + task: ServiceTask | SendTask | BusinessRuleTask, + subject: string, + keys: readonly string[], + accept: ValidationAcceptor, + ): void { + const settings = settingsOf(task.items); + const target = { node: task, property: 'name' }; + if (this.checkExactlyOneBinding(settings, keys, subject, target, accept)) { + this.checkBuiltinBinding(task, subject, accept); + this.checkResultVariableBinding(task, accept); + } + } + /** - * The deployment refusals `BpmnParse.parseServiceTaskLike` raises for a mail - * or shell task, reported here instead. A field's own shape is - * {@link checkField}'s business and comes first, so a value that is neither - * a literal nor an expression draws that refusal alone. + * `parseServiceTaskLike`'s mail and shell refusals; a field's own shape is + * {@link checkField}'s and comes first. */ private checkBuiltinBinding( task: ServiceTask | SendTask | BusinessRuleTask, @@ -2574,7 +2357,6 @@ export class BpmnScriptValidator { accept('error', TYPE_VALUE_MESSAGE, { node: attr, property: 'value' }); return; } - // A field left nameless by parser recovery has its own diagnostic. const fields = task.params.filter( (param) => isFieldParameter(param) && param.name !== undefined, ); @@ -2612,9 +2394,8 @@ export class BpmnScriptValidator { } /** - * Asked, as {@link checkBuiltinBinding} is, only once exactly one binding is - * written. A `topic` or `type` binding deploys with the variable and never - * writes it, so that pair is a warning rather than the refusal. + * A `topic` or `type` binding deploys with the variable and never writes it, + * so only a warning. */ private checkResultVariableBinding( task: ServiceTask | SendTask | BusinessRuleTask, @@ -2663,7 +2444,6 @@ export class BpmnScriptValidator { } } - /** The value's own shape comes first, so a misspelt mapping draws that refusal alone. */ private checkDecisionResultMapping( task: BusinessRuleTask, accept: ValidationAcceptor, @@ -2698,9 +2478,7 @@ export class BpmnScriptValidator { if (task.name === undefined) return; if (task.body === undefined) { - // An unterminated fence never lexes as FENCED_SCRIPT, so the parser - // recovers into a bodyless ScriptTask. With no CST node for the body, - // the diagnostic has to land on `name`. + // An unterminated fence recovers into a bodyless ScriptTask with no CST node for the body. accept( 'error', `Script task '${task.name}' has a malformed or unterminated fenced ` + @@ -2718,7 +2496,6 @@ export class BpmnScriptValidator { ); }; - /** Agreement with a `var` of the same name lives in {@link checkFormVariableAgreement}. */ private checkFormBlocks( forms: FormBlock[], ownerDescription: string, @@ -2755,13 +2532,8 @@ export class BpmnScriptValidator { } } - /** - * An unknown key is an error, since `FormValidators.createValidator` fails - * the deployment on it; the misfit and shape rules each stand for a - * deployment that succeeds and a form that then fails on every submission. - */ + /** An unknown key fails the deployment; misfits and shapes deploy and fail every submission. */ private checkFormField(field: FormField, accept: ValidationAcceptor): void { - // An unparsed id or type would print as a name; the parser reported it. if (field.id === undefined || field.type === undefined) return; for (const item of field.items) { @@ -2783,7 +2555,6 @@ export class BpmnScriptValidator { }); continue; } - // A type outside the list is reported above; no fit is true against it. if (FORM_FIELD_TYPE_SET.has(field.type)) { const fits = FORM_CONSTRAINT_TYPES[setting.key]; if (setting.key === DATE_PATTERN_KEY && field.type !== 'date') { @@ -2835,8 +2606,7 @@ export class BpmnScriptValidator { property: 'id', }), ); - // A `${...}` default or a bare word is evaluated when the form is - // rendered, so only literal text can be held to the value ids here. + // A `${...}` default or a bare word is evaluated at render, so only literal text is checked. const ids = field.values .map((value) => value.id) .filter((id) => id !== undefined); @@ -2868,7 +2638,7 @@ export class BpmnScriptValidator { this.checkDuplicateParameters(properties, accept); } - /** A property's value is a `value` attribute, so it takes the shapes an injected field's `stringValue` takes. */ + /** A property is a `value` attribute, so it takes the shapes a field's `stringValue` takes. */ private checkPropertyValue( param: IoParameter, accept: ValidationAcceptor, @@ -2895,11 +2665,7 @@ export class BpmnScriptValidator { } } - /** - * A duplicate is the caller's to check, over every setting the parens hold - * rather than these: a second `label` is as much a duplicate as a second - * `assignee`, and a structural key never reaches here. - */ + /** The caller checks duplicates over every setting in the parens. */ private checkAttributeKeys( attrs: readonly Setting[], allowed: ReadonlySet, @@ -2910,13 +2676,7 @@ export class BpmnScriptValidator { this.checkAttributeValues(attrs, allowed, accept); } - /** - * A repeated same key is the duplicate-key check's business, so the count - * is over distinct keys. - * - * @returns Whether exactly one binding is written, so a check reading that - * binding's value can stand down otherwise. - */ + /** Counts distinct keys; returns whether exactly one binding is written. */ private checkExactlyOneBinding( attrs: readonly Setting[], keys: readonly string[], @@ -2954,9 +2714,8 @@ export class BpmnScriptValidator { } /** - * `sequentially` and `local` reach here rather than the parser because they - * are keywords elsewhere in the grammar and so lex inside any parens; - * without this they would be accepted and lower to nothing. + * `sequentially` and `local` are keywords elsewhere, so they lex in any + * parens and would lower to nothing. */ private checkFlags( items: ParenItem[], @@ -2994,11 +2753,8 @@ export class BpmnScriptValidator { } /** - * A boolean flag and an engine-side text field each accept one shape and drop - * the rest without a trace: `asyncBefore: "true"` emits no - * `operaton:asyncBefore` at all, so the step runs with the setting off, and - * `retryCycle = 3` is that slip in reverse. A key this element does not own - * is already an allowed-key error from {@link checkAllowedKeys}. + * A boolean flag or engine text field takes one shape and drops the rest + * silently: `asyncBefore: "true"` emits no attribute at all. */ private checkAttributeValues( attrs: readonly Setting[], @@ -3009,7 +2765,7 @@ export class BpmnScriptValidator { if (!allowed.has(attr.key)) { continue; } - if (BOOLEAN_ATTR_KEYS.has(attr.key) && !isLiteralBool(attr.value)) { + if (BOOLEAN_SETTING_KEYS.has(attr.key) && !isLiteralBool(attr.value)) { accept( 'error', `Setting '${attr.key}' takes an unquoted boolean; ` + @@ -3085,7 +2841,6 @@ export class BpmnScriptValidator { this.checkRunSettings(owner, rule, settings, accept); this.checkJobSettingPairing(owner, rule, settings, accept); this.checkFlags(owner.items, rule.flags, rule.description, accept); - // A `call` block has no `forms` member at all. if ('forms' in owner) { if (rule.forms) { this.checkFormBlocks(owner.forms, rule.description, accept); @@ -3098,7 +2853,6 @@ export class BpmnScriptValidator { this.checkListeners(owner, rule, accept); } - /** A kind that never takes a clause owns no run key, so the unknown-key check answers there. */ private checkRunSettings( owner: AttributeOwner, rule: AttributeBlockRule, @@ -3106,8 +2860,6 @@ export class BpmnScriptValidator { accept: ValidationAcceptor, ): void { if (!rule.repeats) return; - // Only a repeatable kind's grammar rule carries `cardinality` at all, - // exactly the kinds this early return leaves standing. const cardinality = (owner as { cardinality?: Expr }).cardinality; if (cardinality !== undefined && !isRepeatCountValue(cardinality)) { accept('error', REPEAT_COUNT_MESSAGE, { @@ -3136,11 +2888,9 @@ export class BpmnScriptValidator { } /** - * On a repeated carrier the whole-loop job (`retryCycle`/`exclusive`, paired - * with the plain async flags) and the per-run job (`runRetryCycle`/ - * `runExclusive`/`jobPriority`, paired with the `run` flags) are separate; - * elsewhere there is one job and `jobPriority` joins the plain family, - * except on a signal catcher, whose subscription job it prices by itself. + * On a repeated carrier the whole-loop job (plain flags) and the per-run job + * (`run` flags, plus `jobPriority`) are separate; elsewhere there is one job, + * except a signal catcher, whose subscription job `jobPriority` prices alone. */ private checkJobSettingPairing( owner: AttributeOwner, @@ -3280,11 +3030,6 @@ export class BpmnScriptValidator { } } - /** - * A kind without the extras owns no `taskPriority` key, so only a mapping - * needs refusing there; a mapping draws one diagnostic, where it may not - * stand before how it is spelled. - */ private checkExternalExtras( owner: AttributeOwner, rule: AttributeBlockRule, @@ -3298,7 +3043,6 @@ export class BpmnScriptValidator { accept('error', refusal, { node: priority, property: 'key' }); } } - // A `call` block has no mapping member at all. if (!('errorMappings' in owner)) return; for (const mapping of owner.errorMappings) { const refusal = rule.externalExtras @@ -3326,7 +3070,6 @@ export class BpmnScriptValidator { } } - /** The key is namespaced by direction, so the three do not collide. */ private checkDuplicateParameters( params: readonly IoParameter[], accept: ValidationAcceptor, @@ -3343,12 +3086,7 @@ export class BpmnScriptValidator { ); } - /** - * One diagnostic per member: a field with no place to go is the mistake to - * fix before its value shape. Called where the member is written, so the - * block's diagnostics stay in document order; the caller words `refusal`, - * since what to remove differs by what the owner wrote. - */ + /** One diagnostic per member, placement before shape; the caller words `refusal`. */ private checkField( field: IoParameter, refusal: string | undefined, @@ -3397,11 +3135,8 @@ export class BpmnScriptValidator { } /** - * The walk goes to any depth, since a map value may hold a list holding - * another map. A keyless entry compiles to unimportable XML; a script - * anywhere in the tree follows a script task's fence rules, since - * `ScriptingEngines.getGlobalScriptEngine` resolves the tag the same way, - * only when the parameter is first evaluated rather than at deployment. + * A keyless entry compiles to unimportable XML; a nested script follows the + * script task's fence rules, resolved at first evaluation instead of deployment. */ private checkIoValueShapes( param: IoParameter, @@ -3433,15 +3168,12 @@ export class BpmnScriptValidator { } } - /** An unrecognized event word stops that listener's own checks: one mistake, one diagnostic. */ private checkListeners( owner: AttributeOwner, rule: AttributeBlockRule, accept: ValidationAcceptor, ): void { - // `CoreModelElement.addListenerToMap` and `TaskDefinition.addTaskListener` - // both append to a per-event list, so several listeners on one event run - // in document order; nothing here refuses a repeat. + // Operaton appends listeners per event and runs them in order, so a repeat is legal. for (const listener of owner.listeners) { if (!this.checkListenerEvent(listener, rule, accept)) { continue; @@ -3483,11 +3215,8 @@ export class BpmnScriptValidator { } /** - * A listener's block holds injected fields alone: it configures the binding - * the listener names rather than the element the listener runs on, so there - * is nothing an io parameter there could map. Which binding takes a field - * follows the listener's own, not its host's, so a task listener on a user - * task carries one even though its host takes none. + * A listener's block configures its binding, not the element, so it holds + * fields alone; a task listener on a user task may carry one. */ private checkListenerFields( listener: Listener, @@ -3495,8 +3224,7 @@ export class BpmnScriptValidator { ): void { const subject = `The 'on ${listener.event}' listener`; const settings = settingsOf(listener.items); - // A fenced body binds the listener in place of its settings, so it is the - // script that has to go, whatever the settings beside it say. + // A fenced body binds the listener in place of its settings. const refusal = listener.script !== undefined ? scriptListenerFieldMessage(subject) @@ -3525,7 +3253,6 @@ export class BpmnScriptValidator { this.checkDuplicateParameters(fields, accept); } - /** The fenced script replaces the whole brace block, so only braces can bind none or several. */ private checkListenerBinding( listener: Listener, accept: ValidationAcceptor, @@ -3558,11 +3285,8 @@ export class BpmnScriptValidator { } /** - * A loop's parens refuse the join spelling by name, since the fix is the - * unprefixed key. A join every branch closes is pruned by the transform, so - * a setting written for it warns rather than vanishing, and the pairing - * warning stays quiet there since the setting is dead either way; the - * predicate is the one {@link checkUnreachableStatements} reads. + * A join every branch closes is pruned by the transform, so a setting for it + * warns and the pairing warning stays quiet. */ private checkGatewaySettings( stmt: GatewayStatement, @@ -3635,7 +3359,6 @@ export class BpmnScriptValidator { ); } - /** The grammar allows an empty `Block`, so an empty branch is a warning, not an error. */ checkIfStatement = (stmt: IfStatement, accept: ValidationAcceptor): void => { this.checkGatewaySettings(stmt, accept); this.checkEmptyBlock( @@ -3686,18 +3409,15 @@ export class BpmnScriptValidator { emptyLoopBodyMessage('do'), accept, ); - // The body runs before the gateway, so a body that always ends leaves - // the gateway with no incoming flow; the unreachable-step scan only sees - // the statements after it. + // The body runs first, so a terminating body leaves the gateway unreached. if (blockTerminates(blockStatements(stmt.body))) { accept('error', DEAD_LOOP_MESSAGE, { node: stmt, property: 'condition' }); } }; /** - * The grammar admits a parameter or a listener in any body so that one - * `Block` rule serves every statement, but no lowering reads them there: - * only an element's own attribute block is lowered. + * The grammar admits parameters and listeners in any body, but only an + * attribute block is lowered. */ checkBlock = (block: Block, accept: ValidationAcceptor): void => { for (const param of block.params) { @@ -3765,7 +3485,6 @@ export class BpmnScriptValidator { this.checkFallbackBranch(stmt, accept); }; - /** With every branch unconditioned both diagnoses hold; the whole-statement one is the sharper and the one reported. */ private checkFallbackBranch( stmt: ParallelStatement, accept: ValidationAcceptor, @@ -3791,7 +3510,6 @@ export class BpmnScriptValidator { } } - /** A branch header carries exactly what a plain `await` does. */ checkRaceStatement = ( stmt: RaceStatement, accept: ValidationAcceptor, @@ -3832,7 +3550,6 @@ export class BpmnScriptValidator { } } - /** An unresolved `goto` is skipped: the linker already reports it. */ checkGotoStatement = ( goto: GotoStatement, accept: ValidationAcceptor, @@ -3876,10 +3593,6 @@ export class BpmnScriptValidator { this.checkCallMappingDuplicates(call, accept); }; - /** - * A missing `process` has no node to attach to, so the diagnostic lands on - * `name`; an empty one is {@link bindingValueMessage}'s. - */ private checkCallProcessAttribute( call: CallActivity, accept: ValidationAcceptor, @@ -3892,11 +3605,7 @@ export class BpmnScriptValidator { ); } - /** - * `binding: version` reaches here in either spelling: bare `version` parses - * as a variable reference and quoted as a string, and - * {@link bindingValueText} reads the same text out of both. - */ + /** Bare `version` parses as a variable reference, quoted as a string; both read the same text. */ private checkBindingAttribute( owner: VersionPinnedElement, accept: ValidationAcceptor, @@ -3942,10 +3651,7 @@ export class BpmnScriptValidator { } } - /** - * Both pin the same variable-mapping delegate, so the engine's if/else-if - * would drop one without a word; refusing here beats mirroring that at import. - */ + /** The engine's if/else-if would drop one of the two silently. */ private checkCallMapperExclusion( call: CallActivity, accept: ValidationAcceptor, @@ -3959,10 +3665,7 @@ export class BpmnScriptValidator { ); } - /** - * `in` and `out` are independent namespaces, and a bare `*` keys on the - * direction alone: a second `*` collides, `*` beside a named target does not. - */ + /** `in` and `out` are separate; a second `*` collides, `*` beside a named target does not. */ private checkCallMappingDuplicates( call: CallActivity, accept: ValidationAcceptor, @@ -3984,10 +3687,6 @@ export class BpmnScriptValidator { ); } - // One diagnostic per mistake: in the event checks below an unknown trigger - // or field word makes the owning check return immediately. - - /** Sibling duplicates are compared once per process in {@link checkHandlerDuplicates}. */ checkOnHandler = (handler: OnHandler, accept: ValidationAcceptor): void => { this.checkAttributeBlock(handler, accept); @@ -4030,7 +3729,7 @@ export class BpmnScriptValidator { ); }; - /** An empty string in a required slot counts as omitted; there is no "empty means catch-all". */ + /** An empty string counts as omitted; there is no empty catch-all. */ private checkHandlerPayload( handler: OnHandler, rule: TriggerPayloadRule, @@ -4048,8 +3747,8 @@ export class BpmnScriptValidator { } else if (rule.code === 'optional') { checkEmptyCode(code, handler.items, accept); } else if (handler.trigger === 'compensation' && code !== undefined) { - // Timer's forbidden payload folds into the timer branch below, so - // `on timer("banana")` reads as an unreadable time, not a stray code. + // Timer's forbidden payload is the timer branch's, so `on + // timer("banana")` reads as a bad time. accept('error', COMPENSATION_NO_CODE_MESSAGE, { node: payload!, property: 'value', @@ -4084,7 +3783,6 @@ export class BpmnScriptValidator { } } - /** At most one of the three holds for a given node, so they need no ordering between them. */ private checkConditionPayload( node: CatchHeader | OnHandler | StartEvent, rule: TriggerPayloadRule, @@ -4119,11 +3817,8 @@ export class BpmnScriptValidator { } /** - * A timer start's expression is parsed at deployment - * (`BpmnDeployer.adjustStartEventSubscriptions`, - * `TimerDeclarationImpl.createStartTimerInstance`), so a bad shape there is - * an error; every other carrier's is read only when its scope is entered, - * so a bad shape there is a warning. + * A timer start's expression is parsed at deployment, so a bad shape is an + * error; elsewhere it is read on scope entry, so a warning. */ private checkTimerClause( node: CatchHeader | OnHandler | StartEvent, @@ -4156,10 +3851,8 @@ export class BpmnScriptValidator { } /** - * A listener is the one header whose timer is not in the parens: `timeout` - * has no lifecycle transition to fire on, so it writes the particle and the - * time after the event word. Its timer fires when the task is created, so a - * bad shape is a warning as any non-start carrier's is. + * `timeout` has no lifecycle transition, so its timer follows the event word; + * it fires at task creation. */ private checkListenerTimer( listener: Listener, @@ -4204,11 +3897,6 @@ export class BpmnScriptValidator { } } - /** - * A raw template's actual value cannot be checked statically, so it always - * passes; a literal is held to the shape its calendar reads - * ({@link TIMER_SHAPE_BY_PARTICLE}). - */ private checkTimerShape( time: string, particle: TimerParticle, @@ -4223,7 +3911,6 @@ export class BpmnScriptValidator { } } - /** Only an `on` handler's timer can be told to run alongside; the shape checks apply to every carrier alike. */ private checkTimerRepeatsOnce( handler: OnHandler, timer: TimerPayload, @@ -4239,7 +3926,6 @@ export class BpmnScriptValidator { } } - /** The compensation rule fires only where the generic placement passed, so one mistake gives one message. */ private checkHandlerPlacement( handler: OnHandler, accept: ValidationAcceptor, @@ -4283,10 +3969,6 @@ export class BpmnScriptValidator { } } - /** - * A field written twice is a repeated key, which the duplicate-key check - * already reports; only what the binding means is left here. - */ private checkHandlerBindings( handler: OnHandler, accept: ValidationAcceptor, @@ -4301,8 +3983,7 @@ export class BpmnScriptValidator { property: 'key', }); } else if (binding.variable === undefined) { - // `code: "X"` reads as a setting rather than a binding, so the handler - // compiles with nothing bound and a body reading the variable finds none. + // `code: "X"` is a setting, not a binding, so nothing gets bound. accept( 'error', `A catch binding names the variable the caught ${binding.field} lands in, not a value: write '${binding.field}: '.`, @@ -4313,11 +3994,8 @@ export class BpmnScriptValidator { } /** - * Stops at the first violation; an unresolved host is skipped, the linker - * already reports it. The scope provider offers a step inside the handler's - * own body as a host, so the circular case is refused here; the narrower - * `escalation` and `cancel` host sets are Operaton's own - * (`BpmnParse.parseBoundaryEvents`). + * The scope provider offers a step in the handler's own body, so the circular + * case is refused here; the `escalation` and `cancel` host sets are Operaton's. */ private checkHandlerHost( handler: OnHandler, @@ -4379,10 +4057,9 @@ export class BpmnScriptValidator { } /** - * Two handlers registering the same catch on one engine scope - * ({@link subscriptionScopeOf}) are refused whatever their `alongside`, - * and so is an escalation catch-all beside a coded catch of its own kind. - * Runs once per process so a duplicate pair is reported once. + * Two handlers on one engine scope ({@link subscriptionScopeOf}) with the same + * catch are refused whatever their `alongside`, as is an escalation catch-all + * beside a coded one. */ private checkHandlerDuplicates( process: Process, @@ -4391,15 +4068,11 @@ export class BpmnScriptValidator { const candidates: OnHandler[] = []; for (const node of AstUtils.streamAst(process)) { if (!isOnHandler(node)) continue; - // An unrecognized trigger word already draws its own error in - // checkOnHandler; comparing it here would key HANDLER_DUPLICATE_RULE by - // a word it does not have and print "undefined" beside that error. if (!ON_TRIGGERS_SET.has(node.trigger)) continue; - // Timer and conditional handlers key no engine subscription name, and - // two deadlines in one scope is a real pattern, so they never conflict. + // Timer and condition key no subscription name, and two deadlines in one + // scope is a real pattern. if (node.trigger === 'timer' || node.trigger === 'condition') continue; - // An unresolved host would key an empty segment and collide with every - // host-less handler, stacking a second diagnostic on the linker's. + // An unresolved host would collide with every host-less handler. if (node.host !== undefined && node.host.ref === undefined) continue; candidates.push(node); } @@ -4425,8 +4098,7 @@ export class BpmnScriptValidator { { node: handler, property: 'trigger' }, ), ); - // The engine refuses the pair on the later one it parses; the later one - // on the page is reported, whichever of the two carries the code. + // The engine refuses the later of the pair, whichever carries the code. const codedByKind = new Set(); const catchAllByKind = new Set(); for (const handler of siblings) { @@ -4485,11 +4157,7 @@ export class BpmnScriptValidator { } }; - /** - * A link throw gets no activity (`BpmnParse.parseIntermediateThrowEvent` - * returns before `createActivityOnScope`), so a setting or listener on it - * is parsed into nothing; the catch is a real activity and takes both. - */ + /** A link throw gets no activity, so its settings and listeners parse into nothing. */ private checkLinkThrowItems( stmt: EmitStatement, accept: ValidationAcceptor, @@ -4510,7 +4178,6 @@ export class BpmnScriptValidator { } } - /** No host and no body: an awaited event is a step in the flow, not a scope. */ checkIntermediateCatchEvent = ( catchEvent: IntermediateCatchEvent, accept: ValidationAcceptor, @@ -4520,12 +4187,9 @@ export class BpmnScriptValidator { }; /** - * One check for every element, since the parens are one fragment: an unkeyed - * value is the payload slot, and the readers all take the first, so a second - * would be dropped without a word. Where the element has no reader at all, - * the first is dropped the same way and is refused here; a start or end - * with no trigger is the shape a payload written without its trigger word - * takes, `start S("PT30M")`. + * Readers take the first unkeyed value, so a second is dropped silently, as is + * the first where nothing reads it. `start S("PT30M")` is a payload missing its + * trigger word. */ checkParenValue = (value: ParenValue, accept: ValidationAcceptor): void => { const owner = value.$container; @@ -4543,9 +4207,6 @@ export class BpmnScriptValidator { catchEvent: CatchHeader, accept: ValidationAcceptor, ): void { - // A race branch whose leading `ID` a syntax error swallows still reaches - // the AST with no trigger at all; the parser errors already say so, and - // naming 'undefined' as an unknown event kind would only pile on. if (catchEvent.trigger === undefined) return; if (!CATCH_TRIGGERS_SET.has(catchEvent.trigger)) { @@ -4570,7 +4231,6 @@ export class BpmnScriptValidator { ); } - /** Mirrors {@link checkHandlerPayload} without bindings and `alongside`. */ private checkCatchPayload( catchEvent: CatchHeader, rule: TriggerPayloadRule, @@ -4598,9 +4258,8 @@ export class BpmnScriptValidator { } /** - * Codes are keyed per kind: an error and an escalation are separate - * definitions and share nothing by carrying one code. A name a step also - * uses stays legal: a reference resolves by type, so the two never compete. + * Codes are keyed per kind; a name a step also uses stays legal, since + * references resolve by type. */ private checkCodeDecls(process: Process, accept: ValidationAcceptor): void { const declaredNames = new Set(); @@ -4619,10 +4278,7 @@ export class BpmnScriptValidator { this.checkCodeDeclSettings(decl, accept); - // A repeated name is reported once, on the second declaration, as every - // other duplicate check here does. Its code is still checked below: two - // declarations can repeat a name and still collide with a third on a - // `code` setting neither of them shares with the other. + // A repeated name is reported once; its code is still checked against a third declaration. if (declaredNames.has(decl.name)) { accept( 'error', @@ -4640,9 +4296,6 @@ export class BpmnScriptValidator { codeOwners.set(`${decl.kind}:${code}`, decl); continue; } - // A repeated name repeats the code it stands for, and that is the same - // mistake said twice. A code shared with some other declaration is a - // second mistake and is reported even when the name repeats as well. if (owner.name === decl.name) continue; accept( 'error', @@ -4652,10 +4305,6 @@ export class BpmnScriptValidator { } } - /** - * A declaration says what an event is rather than how the engine runs it, so - * its parens take the two event fields and none of the engine settings. - */ private checkCodeDeclSettings( decl: CodeDecl, accept: ValidationAcceptor, @@ -4706,7 +4355,7 @@ function collectExpressions(process: Process): Expr[] { return AstUtils.streamAst(process).filter(isExpr).toArray(); } -/** Exported so the printer can warn about an imported model's id off this one list. */ +/** Exported so the printer can warn about an imported id. */ export function isReservedName(name: string, containerId?: string): boolean { return ( RESERVED_ID_PATTERNS.some((re) => re.test(name)) || @@ -4715,12 +4364,7 @@ export function isReservedName(name: string, containerId?: string): boolean { ); } -/** - * The process or `subprocess`/`attempt` block whose body holds the statement, - * whose name seeds the implicit start and end minted for that body. A handler - * body is not one: its implicit events are seeded from a coordinate no author - * writes. - */ +/** Its name seeds the minted start and end; a handler body's are seeded otherwise. */ function enclosingContainerName(node: NamedStatement): string | undefined { const container = AstUtils.getContainerOfType( node.$container, @@ -4755,7 +4399,6 @@ function checkFencedScript( } export function quotedCodeMessage(trigger: string, text: string): string { - // A code spelled like a `name=ID` slot can be declared under itself. const spellable = ID_TEXT.test(text); const declaration = spellable ? `${trigger} ${text}` @@ -4778,7 +4421,10 @@ function unknownTriggerMessage(word: string, legal: readonly string[]): string { return `Unknown event kind '${word}'; write ${formatWordList(legal)}.`; } -/** A mistyped step keyword followed by a name parses as a declaration and lands here rather than at the parser's declaration-or-step guidance, so the message names both readings. */ +/** + * A mistyped step keyword followed by a name parses as a declaration, so the + * message names both readings. + */ export function unknownDeclarationKindMessage(word: string): string { return ( `Unknown declaration kind '${word}'; write ` + @@ -4818,7 +4464,6 @@ export function startTriggerMessage(word: string): string { return `Unknown event kind '${word}'; a start event supports ${formatWordList(START_TRIGGERS)}.`; } -/** Only the triggers that interrupt by nature reach this; each says why. */ export function alongsideMessage(trigger: string): string { if (trigger === 'compensation') return COMPENSATION_ALONGSIDE_MESSAGE; if (trigger === 'cancel') return CANCEL_ALONGSIDE_MESSAGE; @@ -4901,11 +4546,8 @@ export function catchTriggerMessage(word: string): string { } /** - * The activities an engine token can be "at", which a boundary event may attach - * to. Read off {@link isNamedStatement} rather than listing the kinds a second - * time: the statements carrying a name are the activities and the events, so - * taking the events away leaves the activities. An intermediate catch event - * is no activity either: `BpmnParse.parseBoundaryEvents` attaches only to one. + * What a boundary event may attach to: the named statements minus the events, + * an intermediate catch included. */ export function isActivityStatement(stmt: Statement): boolean { return ( @@ -4919,15 +4561,13 @@ export function isActivityStatement(stmt: Statement): boolean { } /** - * Operaton gates an `escalation` boundary on a subprocess scope, a call - * activity, or a user task (`BpmnParse.parseBoundaryEvents`); both the - * `subprocess` and the `attempt` head are subprocess scopes. + * Operaton allows an `escalation` boundary on a subprocess scope, a call + * activity or a user task. */ export function isEscalationLegalHost(stmt: Statement): boolean { return isSubProcess(stmt) || isCallActivity(stmt) || isUserTask(stmt); } -/** A block written with the `attempt` head: the only one a cancel may give up. */ export function isAttemptBlock(node: AstNode | undefined): node is SubProcess { return node !== undefined && isSubProcess(node) && node.transactional; } @@ -4983,11 +4623,7 @@ export function cancelHandlerWithoutEndMessage(name: string): string { ); } -/** - * Every caller hands a `SubProcess` or a resolved host, which the scope offers - * only for the named kinds, and each of those has a row; the row names an - * `attempt` block for the head its author wrote rather than the rule it shares. - */ +/** Callers pass only named kinds, each with a row; an `attempt` is named for its head. */ function describeStatementKind(stmt: Statement): string { return attributeBlockRuleOf(stmt)!.description; } @@ -5052,7 +4688,6 @@ export const INITIATOR_SHADOWED_MESSAGE = 'last wins, so this setting is never written. Move it to the last ' + 'start, or drop it.'; -/** The triggers whose payload is a subscription name the engine keys per scope. */ const NAME_SCOPED_TRIGGERS: ReadonlySet = new Set([ 'message', 'signal', @@ -5072,26 +4707,18 @@ export const HANDLER_DUPLICATE_RULE: Readonly> = { }; /** - * The scope the engine registers a handler's catch on: the host of a boundary - * event (`BpmnParse.parseBoundaryEvents`), or the container an event - * sub-process sits in (`BpmnParse.parseEventDefinitionForSubprocess`, through - * the sub-process's flow scope). A handler inside `Sub` and one attached to - * `Sub` therefore share a scope. + * The host of a boundary event, or the container of an event sub-process, so a + * handler inside `Sub` and one attached to `Sub` share a scope. */ function subscriptionScopeOf(handler: OnHandler): AstNode | undefined { return handler.host?.ref ?? enclosingFlowContainer(handler); } /** - * A message or signal name is unique per scope among the catches that are not - * process starts (`BpmnParse.hasMultipleEventDefinitionsWithSameName` keys the - * start flag, and `parseEventDefinitionForSubprocess` clears it), so a - * boundary and an event sub-process collide; an escalation, error or cancel - * catch is compared against its own kind alone - * (`BpmnParse.addEscalationEventDefinition` branches on `isSubProcessScope`). - * A boundary on a repeated host subscribes on the multi-instance body, one - * scope above the host's own (`BpmnParse.getMultiInstanceScope`), so a name - * inside such a host stays free. + * A message or signal name is unique per scope among non-start catches, so a + * boundary and an event sub-process collide; escalation, error and cancel + * compare within their kind. A boundary on a repeated host subscribes on the + * multi-instance body, one scope up. */ function catchKindOf(handler: OnHandler): string { if (handler.host?.ref === undefined) return 'body'; @@ -5144,7 +4771,6 @@ function checkEmptyCode( const isFieldParameter = (param: IoParameter): boolean => param.direction === FIELD_DIRECTION; -/** Admitted inside a mapping alone, for the reason {@link EXTERNAL_TASK_EL_NAME} gives. */ function readsExternalTask(ref: VarRef): boolean { return ( ref.ref?.$refText === EXTERNAL_TASK_EL_NAME && @@ -5153,12 +4779,9 @@ function readsExternalTask(ref: VarRef): boolean { } /** - * Whether `ref` reads a process variable, which the undeclared-variable - * warning, a definition lookup and a rename all key on. Only the direct value - * of a {@link NON_VARIABLE_ATTR_KEYS} setting is plain text; a reference - * nested in one is a variable. The answer is read off the container chain - * alone, so it also holds for the stand-in the completion provider builds for - * a reference not yet typed, whose `ref` is empty. + * Only the direct value of a {@link NON_VARIABLE_ATTR_KEYS} setting is text. + * Read off the container chain alone, so it also holds for the completion's + * untyped stand-in. */ export function isVariableUse(ref: VarRef): boolean { const container = ref.$container; @@ -5176,7 +4799,6 @@ export function isVariableUse(ref: VarRef): boolean { ); } -/** An integer, bare or quoted, or a value lowering to `${...}`: a bare name or a raw template. */ function isPriorityValue(value: Expr): boolean { return ( isIntegerValue(value) || @@ -5186,7 +4808,6 @@ function isPriorityValue(value: Expr): boolean { ); } -/** A non-negative count, bare or quoted, or a value evaluated at each run: a bare name or a raw template. */ function isRepeatCountValue(value: Expr): boolean { return ( isLiteralInt(value) || @@ -5196,7 +4817,6 @@ function isRepeatCountValue(value: Expr): boolean { ); } -/** The digits of a deployed version, bare or quoted, or a raw template ({@link VERSION_TEXT}). */ function isVersionValue(value: Expr): boolean { const text = integerLiteralText(value); return ( @@ -5207,12 +4827,7 @@ function isVersionValue(value: Expr): boolean { ); } -/** - * A form field's default is evaluated when the form renders, so only a - * literal default can be held to the shape its type converts; a bare word or - * a raw template is left to fail, if it must, at that render instead - * (`FormFieldHandler.createFormField`). - */ +/** Only a literal default is checked; anything else is evaluated at render. */ function formDefaultShape( field: FormField, hasPattern: boolean, @@ -5262,11 +4877,7 @@ function namesFieldBinding(attrs: readonly Setting[]): boolean { return attrs.some((attr) => FIELD_BINDING_KEY_SET.has(attr.key)); } -/** - * A field's value has one slot per shape in the XML, the `stringValue` - * attribute for a literal and an `` child for a raw - * expression. A list, a map, and an inline script have neither. - */ +/** A literal goes to `stringValue`, an expression to a child; a list, map or script has no slot. */ function isFieldValue(value: IoValue | undefined): boolean { return value !== undefined && (isLiteralString(value) || isRawExpr(value)); } @@ -5330,7 +4941,7 @@ function checkThrowEmitBinding( ); } -/** There is no catch-all on the throwing side, so an omitted and an empty code are the same mistake. */ +/** No throw-side catch-all, so an omitted and an empty code are the same mistake. */ function checkThrowEmitCode( stmt: ThrowStatement | EmitStatement, subject: 'A thrown' | 'An emitted', @@ -5378,11 +4989,7 @@ function bindingValueText(expr: Expr): string | undefined { const variableKeySpelling = (name: string) => `execution.getVariable('${name}')`; -/** - * The bracket spelling of a property, keyed on its object as the author wrote - * it: the source up to the accessor, so `order.line.is-paid` reads back - * `order.line['is-paid']`. - */ +/** `order.line.is-paid` reads back `order.line['is-paid']`. */ function propertyKeySpelling(ref: VarRef, accessor: Accessor): string { const object = ref.$cstNode !== undefined && accessor.$cstNode !== undefined @@ -5393,10 +5000,6 @@ function propertyKeySpelling(ref: VarRef, accessor: Accessor): string { return `${object}['${accessor.prop}']`; } -/** - * The root and every `.prop` of a reference the rendered `${...}` carries by - * name, each held to what JUEL's scanner reads as one identifier. - */ function checkRenderedNames(ref: VarRef, accept: ValidationAcceptor): void { checkRenderedName( ref.ref.$refText, @@ -5428,19 +5031,16 @@ function checkRenderedName( } } -/** Each `${...}` or `#{...}` of a template; a `}` inside a JUEL string literal is string text (`Scanner.nextString`). */ +/** A `}` inside a JUEL string literal is string text. */ const TEMPLATE_BODIES = /[$#]\{((?:'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|[^'"}])*)\}/g; -/** A JUEL string literal, to be skipped, or the `.prop` read to hold to {@link checkRenderedName}'s rule. */ const STRING_OR_PROPERTY_READ = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|\.\s*([A-Za-z_$][\w$]*)/g; /** - * `true`, `false` and `null` are held to the keyword rule too: a raw template - * is plain text, so unlike a rendered accessor it can spell them as a `.prop` - * read, and the scanner still returns their literal tokens there. Text between - * two templates is never scanned as an expression, so a word there is text. + * A raw template can spell `true`/`false`/`null` as a `.prop`, which JUEL still + * scans as literals. Text between templates is not an expression. */ function checkRawTemplateNames( raw: string, @@ -5451,7 +5051,6 @@ function checkRawTemplateNames( for (const match of body!.matchAll(STRING_OR_PROPERTY_READ)) { const word = match[1]; if (word === undefined || !JUEL_RAW_TEMPLATE_WORD_SET.has(word)) continue; - // The object as written, `items[0]` or `order.line`, for the hint. const object = /[\w$.[\]'"]*$/.exec( body!.slice(0, match.index).trimEnd(), )![0]; @@ -5460,7 +5059,6 @@ function checkRawTemplateNames( } } -/** The slot `expr` is the whole condition of, or `undefined` for any other position or a nested operand. */ function conditionSlotOf(expr: Expr): ConditionSlot | undefined { const container = expr.$container; if (expr.$containerProperty === 'completion') return 'until'; @@ -5474,8 +5072,7 @@ function conditionSlotOf(expr: Expr): ConditionSlot | undefined { ) { return 'condition'; } - // A quoted payload is a code string in the condition's place, which - // `checkConditionPayload` already says; it is not a string condition. + // A quoted payload is already `checkConditionPayload`'s. if (isParenValue(container) && !isLiteralString(expr)) { const owner = container.$container; if ( @@ -5491,11 +5088,7 @@ function conditionSlotOf(expr: Expr): ConditionSlot | undefined { return undefined; } -/** - * What a condition statically is when that is known and not a Boolean, or - * `undefined` where it is a Boolean or cannot be told: an accessor's type, an - * undeclared name and a template body are all open. - */ +/** `undefined` where Boolean or unknown: accessors, undeclared names and templates. */ function nonBooleanShapeOf( expr: Expr, symbols: VariableTable, @@ -5512,8 +5105,7 @@ function nonBooleanShapeOf( return 'an arithmetic expression'; } if (isVarRef(expr) && expr.accessors.length === 0) { - // `json` is left out of BOOLEAN_OK here: a `json` variable is a Spin node, - // which `UelExpressionCondition.evaluate` refuses as non-Boolean. + // `json` is a Spin node, which `UelExpressionCondition.evaluate` refuses as non-Boolean. const type = symbols.get(expr.ref.$refText)?.type; return type !== undefined && type !== 'boolean' && type !== 'any' ? `a variable of type '${type}'` @@ -5531,7 +5123,6 @@ function nonBooleanShapeOf( return undefined; } -/** Both branch nodes have `body` as their only `Block`-typed property, so a `Block` directly under one is that branch's body. */ function findEnclosingBranch( node: AstNode, ): { body: Block; keyword: 'parallel' | 'await' } | undefined { @@ -5569,7 +5160,6 @@ export function linkThrowNeverRunsMessage(item: string): string { ); } -/** A resolved cross-reference always carries a name; the `'?'` just keeps this total. */ function targetStatementName(target: Statement): string { return statementName(target) ?? '?'; } diff --git a/packages/language/src/bpmn-script-value-converter.ts b/packages/language/src/bpmn-script-value-converter.ts index 049198e..610f50a 100644 --- a/packages/language/src/bpmn-script-value-converter.ts +++ b/packages/language/src/bpmn-script-value-converter.ts @@ -1,9 +1,6 @@ /** - * Reads a `RAW_TEMPLATE` token the way Langium reads `STRING`, quotes dropped - * and escapes resolved, so `"${fn(\"a\")}"` becomes `${fn("a")}`. Langium's - * `DefaultValueConverter` unquotes only the terminal named `STRING`; kept - * verbatim, the body would reach the engine with a `\` outside a JUEL string - * and gain one more each time the printer quotes it. + * Langium unquotes only `STRING`; a verbatim `RAW_TEMPLATE` would reach the + * engine with a stray `\` and gain another each time the printer quotes it. */ import { diff --git a/packages/language/src/expression-render.ts b/packages/language/src/expression-render.ts index 04a70d9..1df79b6 100644 --- a/packages/language/src/expression-render.ts +++ b/packages/language/src/expression-render.ts @@ -1,7 +1,6 @@ /** - * Renders a parsed JUEL-subset expression back to its `${...}` text. It lives - * here rather than in `transform` because `astToIr` imports it; the dependency - * runs transform -> language and never the reverse. + * Here rather than in `transform` because `astToIr` imports it and the + * dependency runs transform -> language. */ import type { Expr, Accessor } from './generated/ast.js'; @@ -23,12 +22,7 @@ import { isVarRef, } from './generated/ast.js'; -/** - * The digits of an integer literal with its sign, else `undefined`. A bare - * `-5` parses as a `-` unary over `5`, and every setting the engine reads with - * `Integer.parseInt` or `Long.parseLong` takes it as one integer, not an - * expression. - */ +/** A bare `-5` parses as unary minus, but `Integer.parseInt` reads it as one integer. */ export function integerLiteralText(node: Expr): string | undefined { if (isLiteralInt(node)) { return String(node.value); @@ -46,12 +40,10 @@ export function renderExpression(node: Expr): string { return `\${${renderExpressionInner(node)}}`; } -/** The text inside the `${...}` wrapper. Parentheses are emitted only where the author wrote them. */ +/** Parentheses are emitted only where the author wrote them. */ export function renderExpressionInner(node: Expr): string { if (isRawExpr(node)) { - // JUEL has no `${` token inside an expression, so a raw operand is spliced - // in by its body; a composite has no one body and is left for the - // validator to refuse. + // JUEL has no `${` inside an expression, so a raw operand is spliced in by its body. const body = singleTemplateBody(node.raw); return body === undefined ? node.raw : `(${body})`; } @@ -84,9 +76,7 @@ export function renderExpressionInner(node: Expr): string { return String(node.value); } if (isLiteralString(node)) { - // JUEL text, so the backslash is doubled before the quote is escaped, as - // `escapeQuoted` in `@bpmn-script/transform`'s `juel.ts` does: those are - // the only two escapes operaton-juel's `Scanner.nextString` accepts. + // The only two escapes JUEL's `Scanner.nextString` accepts; backslash first. return `"${node.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; } if (isLiteralBool(node) || isLiteralNull(node)) { @@ -102,7 +92,6 @@ function renderAccessor(accessor: Accessor): string { if (accessor.prop !== undefined) { return `.${accessor.prop}`; } - // The grammar guarantees `prop` XOR `index`, which TS cannot prove here. if (accessor.index === undefined) { throw new Error( 'renderAccessor: accessor has neither a `prop` nor an `index` (unexpected accessor shape)', @@ -112,10 +101,8 @@ function renderAccessor(accessor: Accessor): string { } /** - * The body of a raw template that is exactly one template, else `undefined`: - * `${a} and ${b}` and `${a} b}` are composites the engine evaluates to text - * around the template. A `}` or opener inside a JUEL string literal is string - * text (`Scanner.nextString`), so `${map['}']}` is one template. + * `undefined` for a composite such as `${a} and ${b}`, which the engine + * evaluates to text. A `}` inside a JUEL string literal is string text. */ export function singleTemplateBody(raw: string): string | undefined { if (!/^[$#]\{[^]*\}$/.test(raw)) return undefined; diff --git a/packages/language/src/index.ts b/packages/language/src/index.ts index 214a318..afeea65 100644 --- a/packages/language/src/index.ts +++ b/packages/language/src/index.ts @@ -5,11 +5,11 @@ export * from './expression-render.js'; export * from './variable-symbol-provider.js'; export * from './paren-items.js'; export * from './vocabulary.js'; +export { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver-types'; export * from './generated/ast.js'; export * from './generated/grammar.js'; export * from './generated/module.js'; -// Selective, not `export *`: `bpmn-script-scope-provider.js` also exports a -// `FlowContainer` type whose name the transform's IR already uses. +// Not `export *`: the scope provider's `FlowContainer` clashes with the IR's. export { isNamedStatement, type NamedStatement, diff --git a/packages/language/src/paren-items.ts b/packages/language/src/paren-items.ts index 53bfd00..02e75a8 100644 --- a/packages/language/src/paren-items.ts +++ b/packages/language/src/paren-items.ts @@ -1,8 +1,4 @@ -/** - * Readers over an element's parenthesised items. The payload, the settings - * and the flags share one list and are told apart by node type, so every - * reader lives here rather than spelling that union again at each site. - */ +/** The payload, settings and flags share one list, told apart by node type. */ import { DECLARED_CODE_TRIGGERS, @@ -39,19 +35,12 @@ export function flagsOf(items: ParenItem[]): Flag[] { return items.filter(isFlag); } -/** - * The unkeyed value: an event's payload, or a `condition`'s expression. A - * second one is a duplicate the validator reports, so this reads the first. - */ +/** A second one is a duplicate the validator reports. */ export function payloadItemOf(items: ParenItem[]): ParenValue | undefined { return items.find(isParenValue); } -/** - * A `code:`/`message:` setting on a handler, told from an engine setting by - * its key alone. Only a bare name declares a variable, so `code: "LITERAL"` - * leaves `variable` undefined and reads rather than binds. - */ +/** Only a bare name declares a variable; `code: "LITERAL"` reads rather than binds. */ interface CaughtBinding { readonly field: string; readonly variable: string | undefined; @@ -70,12 +59,7 @@ export function caughtBindingsOf(items: ParenItem[]): CaughtBinding[] { })); } -/** - * A key that is a catch binding or a timer clause rather than a setting, so - * the key check passes over it. Only a handler catches a code or a message and - * only a timer names a date or a cycle; anywhere else the words are unknown - * keys. - */ +/** Catch bindings on a handler and timer keys on a timer; unknown keys anywhere else. */ export function isStructuralParenKey(owner: AstNode, key: string): boolean { if (isOnHandler(owner) && EVENT_BINDING_FIELD_SET.has(key)) return true; return ( @@ -84,7 +68,6 @@ export function isStructuralParenKey(owner: AstNode, key: string): boolean { ); } -/** A timer says a duration by writing it bare, and a date or a cycle by key. */ const TIMER_PARTICLE_KEYS: readonly TimerParticle[] = [ TIMER_PARTICLE_BY_KIND.date, TIMER_PARTICLE_BY_KIND.cycle, @@ -99,18 +82,12 @@ export function configuredSettingsOf( } /** - * The trigger word of the event whose bare payload slot `node` sits in, else - * `undefined`. Every identifier in every expression parses as the same - * reference, so this is what separates `error(OUT_OF_STOCK)` from - * `condition(ready)` for the scope provider, the linker, the validator and the - * suppression alike. An operand of `error(a > b)` is contained by the operator - * node, not the `ParenValue`, so a nested identifier answers `undefined` on - * its own. + * Every identifier parses as the same reference, so this separates + * `error(OUT_OF_STOCK)` from `condition(ready)`. A nested operand is contained + * by its operator, so it answers `undefined`. */ function payloadTriggerOf(node: AstNode): string | undefined { - // Only the stand-in Langium's `completionForCrossReference` builds is held - // in a `ref` slot; its container is where the caret sits, the `VarRef` a - // typed prefix has built or the event itself where nothing is written yet. + // Langium's completion stand-in sits in a `ref` slot under the caret's node. if (node.$containerProperty === 'ref' && node.$container !== undefined) { const caret = node.$container; return payloadTriggerOf(caret) ?? triggerWordOf(caret); @@ -127,12 +104,7 @@ export function triggerWordOf(owner: AstNode): string | undefined { : undefined; } -/** - * The trigger word under which `node` names a declared code, else - * `undefined`: the unkeyed value of an `error` or `escalation` event, or a - * mapping's code slot, `error E when ...`, whose own trigger word heads the - * line. - */ +/** The unkeyed value of an `error` or `escalation`, or a mapping's code slot. */ export function codeTriggerOf(node: AstNode): string | undefined { const trigger = isErrorMapping(node) ? mappingTriggerOf(node) @@ -143,10 +115,8 @@ export function codeTriggerOf(node: AstNode): string | undefined { } /** - * The completion stand-in for the code slot is a mapping with no trigger of - * its own, held by the parsed mapping being typed, so its word is read off - * that holder. The generated type says a mapping is held by an element, which - * only the stand-in departs from, hence the widening. + * The completion stand-in is held by the mapping being typed, not an element, + * hence the widening. */ function mappingTriggerOf(mapping: ErrorMapping): string | undefined { const holder: AstNode | undefined = mapping.$container; @@ -160,11 +130,7 @@ function mappingTriggerOf(mapping: ErrorMapping): string | undefined { return mapping.trigger; } -/** - * The trigger word under which `node` writes a name rather than a code: a - * message or a signal keys the engine's subscription by text, so a bare word - * there is a missing pair of quotes. - */ +/** A message or signal keys its subscription by text, so a bare word there is missing quotes. */ export function nameTriggerOf(node: AstNode): string | undefined { const trigger = payloadTriggerOf(node); return trigger !== undefined && @@ -179,10 +145,8 @@ export function isCodePosition(node: AstNode): boolean { } /** - * The code a declaration keys by: its `code` setting, which carries a code no - * name could spell (`error OrderFailed(code: "order.failed")`), else its own - * name. `undefined` for a written setting that is empty or not quoted text, - * both of which the validator reports on its own. + * The `code` setting, else the name. `undefined` for an empty or unquoted + * setting, which the validator reports. */ export function declaredCodeOf(decl: CodeDecl): string | undefined { const setting = settingsOf(decl.items).find( @@ -195,12 +159,7 @@ export function declaredCodeOf(decl: CodeDecl): string | undefined { return setting.value.value; } -/** - * The payload as text: a message or signal name, a code, or a timer duration. - * A code is a plain word naming its declaration and reads back as the same - * text a quoted one would; a condition is an expression, not text, and is read - * through {@link payloadItemOf} instead. - */ +/** A code reads back as the same text a quoted one would; a condition is no text. */ export function payloadTextOf(items: ParenItem[]): string | undefined { const value = payloadItemOf(items)?.value; if (value === undefined) return undefined; @@ -210,13 +169,12 @@ export function payloadTextOf(items: ParenItem[]): string | undefined { return undefined; } -/** Only quoted text is a value written where the position wants something else: a word names a declaration and a `${...}` template is an expression. */ +/** A word names a declaration and a `${...}` is an expression, so only quoted text counts. */ export function hasQuotedPayload(items: ParenItem[]): boolean { const value = payloadItemOf(items)?.value; return value !== undefined && isLiteralString(value); } -/** A timer clause, and the item a diagnostic about it belongs on. */ export interface TimerPayload { readonly particle: TimerParticle; readonly time: string; @@ -237,7 +195,7 @@ export function timerParticleOf( return undefined; } -/** A bare duration answers the `after` particle it would carry elsewhere, so every caller reads one shape. */ +/** A bare duration answers the `after` particle, so callers read one shape. */ export function timerPayloadOf(items: ParenItem[]): TimerPayload | undefined { const keyed = timerParticleOf(items); if (keyed !== undefined) return keyed; @@ -263,7 +221,6 @@ export function hasFlag(items: ParenItem[], word: string): boolean { return flagOf(items, word) !== undefined; } -/** Only a condition's payload is an expression rather than text, which is what separates `condition(a > b)` from `error(OUT_OF_STOCK)`. */ export function hasExpressionPayload(items: ParenItem[]): boolean { return ( payloadItemOf(items) !== undefined && payloadTextOf(items) === undefined diff --git a/packages/language/src/variable-symbol-provider.ts b/packages/language/src/variable-symbol-provider.ts index 4305734..30b80f9 100644 --- a/packages/language/src/variable-symbol-provider.ts +++ b/packages/language/src/variable-symbol-provider.ts @@ -1,7 +1,4 @@ -/** - * Variables live in a flat process scope: a `var` declared anywhere is visible - * from every expression in the process, whatever the source order. - */ +/** A `var` declared anywhere is visible from every expression in the process. */ import { AstUtils, type AstNode } from 'langium'; import type { Process, VarType } from './generated/ast.js'; @@ -27,11 +24,7 @@ export interface VariableSymbol { export type VariableTable = Map; -/** - * One place a variable is declared, as the node and the property holding its - * name so a rename finds the name's own leaf. A catch binding's site is the - * `VarRef` its setting carries, under `ref`. - */ +/** Node and property so a rename finds the name's own leaf. */ export interface DeclaringSite { node: AstNode; property: string; @@ -44,14 +37,13 @@ export interface VariableSymbolProvider { declaringSites(process: Process): DeclaringSite[]; } -/** The repeat-clause slots read here, off whichever statement carries them. */ interface RepeatSlots { cardinality?: unknown; collection?: unknown; element?: string; } -/** `sequential` is `false` on every repeatable statement, written or not, so only the count and the collection say a clause was written. */ +/** `sequential` is `false` whether written or not, so only the count and collection tell. */ export function isRepeated(node: AstNode): node is AstNode & RepeatSlots { return ( ('cardinality' in node && node.cardinality !== undefined) || @@ -60,16 +52,14 @@ export function isRepeated(node: AstNode): node is AstNode & RepeatSlots { } /** - * Every declaring site, in precedence order: a header `var`, a form field, a - * catch binding, then an `input`/`output` parameter or a repeated statement's - * element in source order. A type disagreement between two sites of one name - * is the validator's job. + * In precedence order: `var`, form field, catch binding, then parameters and + * repeat elements in source order. */ export function declaringSites(process: Process): DeclaringSite[] { - const sites: DeclaringSite[] = []; + const declared: DeclaringSite[] = []; for (const decl of process.decls) { if (isVarDecl(decl)) { - sites.push({ + declared.push({ node: decl, property: 'name', name: decl.name, @@ -77,43 +67,45 @@ export function declaringSites(process: Process): DeclaringSite[] { }); } } + const formFields: DeclaringSite[] = []; + const catchBindings: DeclaringSite[] = []; + const mapped: DeclaringSite[] = []; for (const node of AstUtils.streamAst(process)) { - if (!isStartEvent(node) && !isUserTask(node)) continue; - for (const form of node.forms) { - for (const field of form.fields) { - const type = formFieldVariableType(field.type); - if (type !== undefined) { - sites.push({ node: field, property: 'id', name: field.id, type }); + if (isStartEvent(node) || isUserTask(node)) { + for (const form of node.forms) { + for (const field of form.fields) { + const type = formFieldVariableType(field.type); + if (type !== undefined) { + formFields.push({ + node: field, + property: 'id', + name: field.id, + type, + }); + } } } + } else if (isOnHandler(node)) { + for (const { variable, node: setting } of caughtBindingsOf(node.items)) { + if (variable === undefined) continue; + catchBindings.push({ + node: setting.value, + property: 'ref', + name: variable, + type: 'string', + }); + } } - } - // A catch binding declares a `string`: the code or message text it caught. - for (const node of AstUtils.streamAst(process)) { - if (!isOnHandler(node)) continue; - for (const { variable, node: setting } of caughtBindingsOf(node.items)) { - if (variable === undefined) continue; - sites.push({ - node: setting.value, - property: 'ref', - name: variable, - type: 'string', - }); - } - } - // Both hold whatever was mapped or collected, so their type is open. - for (const node of AstUtils.streamAst(process)) { if (isIoParameter(node)) { - // A field is set on the bound delegate and a property is text for - // Tasklist or a worker; neither is a process variable. + // A field or property is not a process variable. if ( node.direction !== FIELD_DIRECTION && node.direction !== PROPERTY_DIRECTION ) { - sites.push({ node, property: 'name', name: node.name, type: 'any' }); + mapped.push({ node, property: 'name', name: node.name, type: 'any' }); } } else if (isRepeated(node) && node.element !== undefined) { - sites.push({ + mapped.push({ node, property: 'element', name: node.element, @@ -121,7 +113,7 @@ export function declaringSites(process: Process): DeclaringSite[] { }); } } - return sites; + return [...declared, ...formFields, ...catchBindings, ...mapped]; } export class DefaultVariableSymbolProvider implements VariableSymbolProvider { @@ -131,14 +123,13 @@ export class DefaultVariableSymbolProvider implements VariableSymbolProvider { collect(process: Process): VariableTable { const table: VariableTable = new Map(); - // The first site of a name wins, in the order `declaringSites` lists. for (const site of this.declaringSites(process)) { if (!table.has(site.name)) { table.set(site.name, { name: site.name, type: site.type }); } } - // Seeded last, so a declared name of the same spelling keeps its type. A - // count alone (`for 3`) sets them too, so this is not read off the sites. + // Seeded last so a declared name keeps its type; `for 3` sets them too, so + // not read off the sites. if (AstUtils.streamAst(process).some(isRepeated)) { for (const name of LOOP_VARIABLES) { if (!table.has(name)) { diff --git a/packages/language/src/vocabulary.ts b/packages/language/src/vocabulary.ts index b2890b1..ed92dcf 100644 --- a/packages/language/src/vocabulary.ts +++ b/packages/language/src/vocabulary.ts @@ -1,12 +1,6 @@ /** - * The words BPMNscript accepts and where each one is legal. The grammar takes - * any word in these positions; the validator rejects one out of place and the - * completion provider offers the ones that fit, both from these tables and in - * list order. - * - * A trigger or constraint list is a literal tuple. A table keyed by one is - * annotated `Record` so any word can be looked up, and - * `satisfies` the tuple so a missing row fails to compile. + * The words BPMNscript accepts and where each is legal; the grammar takes any + * word, the validator and the completion read these tables in list order. */ import type { AstNode, Grammar } from 'langium'; @@ -35,11 +29,7 @@ import type { const RESERVED_WORDS_BY_GRAMMAR = new WeakMap>(); -/** - * A keyword's lexer token is named after its literal value, so these strings - * match a token type name as well as the source text. Operators are left out; - * they cannot be mistaken for a name. - */ +/** Keyword token types are named after their text; operators are left out. */ export function reservedWordsOf(grammar: Grammar): ReadonlySet { let words = RESERVED_WORDS_BY_GRAMMAR.get(grammar); if (!words) { @@ -55,7 +45,6 @@ export function reservedWordsOf(grammar: Grammar): ReadonlySet { return words; } -/** An English clause: `a`, `a or b`, `a, b, or c`; the same with `and`. */ export function formatPlainWordList( words: readonly string[], conjunction: 'or' | 'and' = 'or', @@ -69,15 +58,12 @@ export function formatWordList(words: readonly string[]): string { return formatPlainWordList(words.map((w) => `'${w}'`)); } -/** The two flags that create a continuation job (`BpmnParse.parseAsynchronousContinuation`). */ +/** The flags that create a continuation job. */ export const ASYNC_FLAG_KEYS = ['asyncBefore', 'asyncAfter'] as const; /** - * The three settings that only configure a job something else declares, and - * that a timer job takes off the element declaring the timer: the lock and - * the priority in `BpmnParse.parseTimer`, the retry cycle in - * `DefaultFailedJobParseListener.parseStartEvent`, `parseBoundaryEvent` and - * `parseIntermediateCatchEvent`. + * Settings that configure a job something else declares; a timer job takes + * them off the element declaring the timer (`BpmnParse.parseTimer`). */ export const TIMER_JOB_KEYS = [ 'exclusive', @@ -88,34 +74,19 @@ export type TimerJobKey = (typeof TIMER_JOB_KEYS)[number]; export type EngineKey = (typeof ASYNC_FLAG_KEYS)[number] | TimerJobKey; -/** - * The engine keys `BpmnParse.isAsyncBefore`, `isAsyncAfter` and `isExclusive` - * read with a case-sensitive `"true"` comparison, so any other text deploys - * as the setting turned off. - */ +/** Read with a case-sensitive `"true"` comparison; any other text deploys as off. */ export const BOOLEAN_ENGINE_KEYS: readonly EngineKey[] = [ ...ASYNC_FLAG_KEYS, 'exclusive', ]; -/** - * The settings Operaton reads off any flow node, gateways included: - * `BpmnParse.parseExclusiveGateway`, `parseInclusiveGateway`, - * `parseParallelGateway` and `parseEventBasedGateway` each go through - * `parseAsynchronousContinuationForActivity` and `createActivityOnScope`, and - * `DefaultFailedJobParseListener.parseActivity` reads the retry cycle on the - * same four. - */ +/** Operaton reads these off any flow node, gateways included. */ export const ENGINE_KEYS: readonly EngineKey[] = [ ...ASYNC_FLAG_KEYS, ...TIMER_JOB_KEYS, ]; -/** - * The variables Operaton sets around a repeated step: three counters on the - * repetition and `loopCounter` on each run (`MultiInstanceActivityBehavior`). - * They exist undeclared, so a process that repeats anything has them in scope. - */ +/** Set by `MultiInstanceActivityBehavior`, so in scope undeclared wherever something repeats. */ export const LOOP_VARIABLES = [ 'nrOfInstances', 'nrOfActiveInstances', @@ -142,35 +113,33 @@ export function runSettingKey(key: string): string { return prefixedSettingKey('run', key); } -/** An engine key under each spelling a parens carries it; the value shape is the same under all three. */ export const engineSpellings = (key: string): string[] => [ key, joinSettingKey(key), runSettingKey(key), ]; +export const BOOLEAN_SETTING_KEYS: ReadonlySet = new Set( + BOOLEAN_ENGINE_KEYS.flatMap(engineSpellings), +); + /** - * The settings a repetition writes on its `multiInstanceLoopCharacteristics` - * element, which `BpmnParse.parseAsynchronousContinuationForActivity` and - * `DefaultFailedJobParseListener.parseActivity` read onto each run. There is - * no `runJobPriority`: `BpmnParse.parseActivity` makes the multi-instance body - * the scope before `createActivityOnScope` runs, so the plain `jobPriority` in - * the same parens already prices each run. + * Written on `multiInstanceLoopCharacteristics` and read onto each run. No + * `runJobPriority`: the multi-instance body is the scope, so plain `jobPriority` + * already prices each run. */ export const RUN_ENGINE_KEYS: readonly string[] = ENGINE_KEYS.filter( (key) => key !== 'jobPriority', ).map((key) => runSettingKey(key)); -/** `BpmnParse.parseServiceTaskLike` dispatches on this key's value before it looks at any code attribute. */ +/** `parseServiceTaskLike` dispatches on this before any code attribute. */ export const TYPE_BINDING_KEY = 'type'; export const EXTERNAL_BINDING_KEY = 'topic'; /** - * Exactly one of these binds a service task; `topic` hands the step to an - * external worker polling the engine instead of the engine invoking anything. - * `parseBusinessRuleTask` without a `decisionRef` reaches the same - * `parseServiceTaskLike`, so {@link BUSINESS_RULE_BINDING_KEYS} inherits it. + * Exactly one binds a service task. A business rule task without a decision + * reaches the same `parseServiceTaskLike`, hence {@link BUSINESS_RULE_BINDING_KEYS}. */ export const SERVICE_TASK_BINDING_KEYS: readonly string[] = [ 'class', @@ -180,23 +149,17 @@ export const SERVICE_TASK_BINDING_KEYS: readonly string[] = [ TYPE_BINDING_KEY, ]; -/** A thrown message has no member block, so the fields a built-in `type` requires cannot be written on it. */ +/** A thrown message has no member block for the fields a built-in `type` requires. */ export const THROW_BINDING_KEYS: readonly string[] = SERVICE_TASK_BINDING_KEYS.filter((key) => key !== TYPE_BINDING_KEY); export const THROW_BINDING_TRIGGER = 'message'; -/** The two `operaton:type` values `parseServiceTaskLike` routes to a built-in behaviour. */ export const TYPE_BINDING_VALUES = ['mail', 'shell'] as const; export type BuiltinTaskType = (typeof TYPE_BINDING_VALUES)[number]; -/** - * In declaration order. `ClassDelegateUtil.applyFieldDeclaration` throws - * "Field definition uses unexisting field '' on class ..." for any - * other name, reached through `instantiateDelegate` from both - * `parseEmailServiceTask` and `parseShellServiceTask`. - */ +/** In declaration order; `ClassDelegateUtil.applyFieldDeclaration` throws on any other name. */ export const BUILTIN_FIELD_NAMES: Readonly< Record > = { @@ -217,10 +180,8 @@ export const BUILTIN_FIELD_NAMES: Readonly< ], }; -/** A field group a built-in task must write at least one name of before it deploys. */ export interface RequiredFieldGroup { readonly names: readonly string[]; - /** The message the engine's parse throws when none of the names is written. */ readonly error: string; } @@ -240,7 +201,6 @@ export const BUILTIN_REQUIRED_FIELDS: Readonly< ], }; -/** The `BpmnParse` method that refuses a deployment missing one of the type's required fields. */ export const BUILTIN_FIELD_VALIDATOR: Readonly< Record > = { @@ -249,10 +209,8 @@ export const BUILTIN_FIELD_VALIDATOR: Readonly< }; /** - * The shell fields `validateFieldDeclarationsForShell` requires a fixed - * `true`/`false` value on, case-insensitively. `ShellActivityBehavior.readFields` - * then compares the deployed value with `"true".equals(...)`, case-sensitively, - * so a value like `"True"` deploys and is read back as `false` at runtime. + * Parsing accepts `true`/`false` in any case, but `ShellActivityBehavior` + * compares with `"true".equals`, so `"True"` deploys and runs as `false`. */ export const SHELL_FLAG_FIELDS: readonly string[] = [ 'wait', @@ -260,16 +218,20 @@ export const SHELL_FLAG_FIELDS: readonly string[] = [ 'cleanEnv', ]; -/** The spellings `ShellActivityBehavior.readFields` compares a flag with. */ export const SHELL_FLAG_LITERALS: readonly string[] = ['true', 'false']; -/** The engine requires one, and `decision` wins when both are written. */ +/** One is required; `decision` wins when both are written. */ export const BUSINESS_RULE_BINDING_KEYS: readonly string[] = [ ...SERVICE_TASK_BINDING_KEYS, 'decision', ]; -/** What `operaton:mapDecisionResult` holds: one entry, one row, one column, or every row. */ +export const DECISION_MODIFIER_KEYS: readonly string[] = [ + 'binding', + 'version', + 'mapDecisionResult', +]; + export const DECISION_RESULT_MAPPINGS = [ 'singleEntry', 'singleResult', @@ -277,19 +239,15 @@ export const DECISION_RESULT_MAPPINGS = [ 'resultList', ] as const; -/** Outside this list, a fenced script body binds a listener too. */ +/** A fenced script body binds a listener too. */ export const LISTENER_BINDING_KEYS: readonly string[] = SERVICE_TASK_BINDING_KEYS.filter( (key) => key !== EXTERNAL_BINDING_KEY && key !== TYPE_BINDING_KEY, ); /** - * The bindings Operaton hands a field list to: the class, the bean a - * `delegate` expression resolves, and the two built-in behaviours - * (`BpmnParse.parseEmailServiceTask` and `parseShellServiceTask` go through - * `instantiateDelegate`). An `expression` is built from its expression and - * result variable alone, a `topic` hands the work to a worker the engine - * injects nothing into, and a `decision` runs no implementation. + * The bindings Operaton injects a field list into; an `expression`, a `topic` + * and a `decision` take none. */ export const FIELD_BINDING_KEYS: readonly string[] = [ 'class', @@ -297,34 +255,33 @@ export const FIELD_BINDING_KEYS: readonly string[] = [ TYPE_BINDING_KEY, ]; -/** - * A call's variable-mapping delegate, keyed by the IR mapper kind. It is not - * spelled `class`/`delegate` because Operaton hands it no field list, which - * is what {@link FIELD_BINDING_KEYS} keys on. - */ +/** Not spelled `class`/`delegate`: Operaton hands a call mapper no field list. */ export const CALL_MAPPER_KEY_BY_KIND = { class: 'mapper', delegateExpression: 'mapperDelegate', } as const; -/** How a call or a decision step pins which deployed version the engine runs. */ export const CALL_BINDING_VALUES = ['latest', 'deployment'] as const; export type CallBindingValue = (typeof CALL_BINDING_VALUES)[number]; -/** - * In the order they are offered and printed. `BpmnParse.parseProcess` also - * reads `jobPriority`, `taskPriority` and `isStartableInTasklist` off the - * process element, and `parseScope` a process-level listener and - * `potentialStarter`; those are left out on purpose, not gaps. - */ -export const PROCESS_HEADER_KEYS: readonly string[] = [ - 'label', - 'documentation', +const LABEL_KEYS = ['label', 'documentation'] as const; + +export const PROCESS_ENGINE_HEADER_KEYS = [ 'versionTag', 'historyTimeToLive', 'candidateStarterUsers', 'candidateStarterGroups', +] as const; + +/** + * In offer and print order. The `jobPriority`, `taskPriority`, + * `isStartableInTasklist`, listeners and `potentialStarter` Operaton also reads + * off a process are left out on purpose. + */ +export const PROCESS_HEADER_KEYS: readonly string[] = [ + ...LABEL_KEYS, + ...PROCESS_ENGINE_HEADER_KEYS, ]; export const INPUT_DIRECTION = 'input'; @@ -335,10 +292,7 @@ export const IO_DIRECTIONS: readonly string[] = [ OUTPUT_DIRECTION, ]; -/** - * A user task's settings that are the `operaton:` attribute of the same name, - * value verbatim; the IR field is named the same. In print order. - */ +/** Written verbatim as the `operaton:` attribute of the same name, in print order. */ export const USER_TASK_VERBATIM_KEYS = [ 'assignee', 'formKey', @@ -349,46 +303,30 @@ export const USER_TASK_VERBATIM_KEYS = [ 'priority', ] as const; -/** - * A field names a property of the bound implementation, set once as it is - * instantiated, so it neither reads nor writes a process variable; - * {@link FIELD_BINDING_KEYS} says where one is legal. - */ +/** Set once on the bound implementation; reads and writes no process variable. */ export const FIELD_DIRECTION = 'field'; /** - * Written to `operaton:properties` as text and declares no process variable. - * A form field's are read by `DefaultFormHandler.parseProperties` keyed by - * `id` and handed to Tasklist, an external task's by - * `BpmnParseUtil.parseOperatonExtensionProperties` keyed by `name` and - * handed to the worker. + * Written to `operaton:properties` as text, declaring no variable; Tasklist + * reads a form field's, the worker an external task's. */ export const PROPERTY_DIRECTION = 'property'; -/** - * Read by `BpmnParse.parsePriority` inside `parseExternalServiceTask` and - * nowhere else on a step; an integer or an expression. - */ +/** Read on an external service task alone; an integer or an expression. */ export const TASK_PRIORITY_KEY = 'taskPriority'; /** - * `VariableScopeElResolver` resolves this name to the external task entity in - * an expression evaluated on its execution, which is where a mapping's - * condition runs (`ExternalTaskEntity.evaluateThrowBpmnError`); it is a - * process variable everywhere else, so it is admitted inside a mapping alone. + * Resolves to the external task entity only where a mapping's condition runs + * (`ExternalTaskEntity.evaluateThrowBpmnError`), so it is legal there alone. */ export const EXTERNAL_TASK_EL_NAME = 'externalTask'; -/** The two soft words of a mapping line; the validator holds each to its word. */ export const ERROR_MAPPING_HEAD = 'error'; export const ERROR_MAPPING_WHEN = 'when'; export type TimerKind = 'duration' | 'date' | 'cycle'; -/** - * The particle a timer clause is written with, keyed by the BPMN timer - * definition it selects (`timeDuration`, `timeDate`, `timeCycle`). - */ +/** Keyed by the timer definition each selects (`timeDuration`, `timeDate`, `timeCycle`). */ export const TIMER_PARTICLE_BY_KIND = { duration: 'after', date: 'at', @@ -420,13 +358,9 @@ export function eventBindingFieldsFor(trigger: string): readonly string[] { ); } -/** Legal on every element with a member block, since each lowers to a flow node. */ export const EXECUTION_LISTENER_EVENTS = ['start', 'end'] as const; -/** - * Legal on a user task alone, spelled as `BpmnParse.parseTaskListeners` - * accepts them; `timeout` is the one carrying a timer clause. - */ +/** User tasks alone, spelled as `BpmnParse.parseTaskListeners` accepts them. */ export const TASK_LISTENER_EVENTS = [ 'create', 'assignment', @@ -436,11 +370,20 @@ export const TASK_LISTENER_EVENTS = [ 'timeout', ] as const; +/** In completion order; the keys keep the list in step with the grammar's `VarType`. */ +export const VAR_TYPES = Object.keys({ + string: true, + number: true, + boolean: true, + date: true, + json: true, + any: true, +} satisfies Record) as VarType[]; + /** - * Vendor-neutral spellings: `number` becomes the Operaton `long` at export, - * and `json`/`any` have no `operaton:formField` representation. `enum` is a - * form type and not a `var` type, so the grammar admits it beside `VarType` - * and the validator holds the word to this list. + * `number` exports as Operaton's `long`; `json`/`any` have no form field type. + * `enum` is no `var` type, so the grammar admits any word and the validator + * holds it to this list. */ export const FORM_FIELD_TYPES = [ 'string', @@ -450,21 +393,15 @@ export const FORM_FIELD_TYPES = [ 'enum', ] as const; -/** - * An `enum` stores the chosen value's id as a string - * (`EnumFormType.convertValue`); every other form type is the `VarType` of - * the same name. - */ +/** An `enum` stores the chosen id as a string (`EnumFormType.convertValue`). */ export function formFieldVariableType(type: string): VarType | undefined { const found = FORM_FIELD_TYPES.find((t) => t === type); return found === 'enum' ? 'string' : found; } /** - * The first six are the validators - * `ProcessEngineConfigurationImpl.initFormFieldValidators` registers; - * `FormValidators.createValidator` reads a class name or an expression off - * `validator`'s `config` and fails the deployment on any other name. + * The first six are Operaton's registered validators; `validator` takes a + * class name or an expression, and any other name fails the deployment. */ export const FORM_CONSTRAINT_NAMES = [ 'required', @@ -482,12 +419,7 @@ export function isFormConstraintName( return (FORM_CONSTRAINT_NAMES as readonly string[]).includes(name); } -/** - * `AbstractNumericValidator.validate` throws on any submitted value that is - * not a Java number and `AbstractTextValueValidator.validate` on any that is - * not a string, so the bounds fit one type each; the rest never read the - * value's type. - */ +/** The bounds throw on a submitted value of another type; the rest ignore the type. */ export const FORM_CONSTRAINT_TYPES: Readonly< Record > = { @@ -504,15 +436,11 @@ export const FORM_CONSTRAINT_TYPES: Readonly< >; /** - * `AbstractNumericValidator.validate` parses a `min`/`max` config with - * `Long.parseLong` and `MinLengthValidator.validate` a length with - * `Integer.parseInt`; neither reads an expression, and a decimal bound - * deploys and then fails every submission with - * `FormFieldConfigurationException`. + * Parsed with `Long.parseLong`/`Integer.parseInt`, never as an expression; a + * decimal bound deploys and then fails every submission. */ export const FORM_BOUND_TEXT = /^-?\d+$/; -/** The grammar's `ID` terminal as the lexer runs it, unanchored. */ export const ID_TERMINAL: RegExp = GrammarUtils.terminalRegex( BpmnScriptGrammar().rules.find( (rule): rule is GrammarAST.TerminalRule => @@ -520,28 +448,20 @@ export const ID_TERMINAL: RegExp = GrammarUtils.terminalRegex( )!, ); -/** A whole string the lexer reads as one `ID` token. */ export const ID_TEXT = new RegExp(`^${ID_TERMINAL.source}$`, ID_TERMINAL.flags); /** - * What `StringUtil.isExpression` counts as one: an opening at the start of - * the trimmed text. A body with an opening further in is a constant to the - * engine, and `BpmnParse.parsePriority` fails the deployment on it. + * `StringUtil.isExpression`: an opening at the start only. An opening further + * in is a constant, which `BpmnParse.parsePriority` refuses. */ export const EXPRESSION_OPEN = /^\s*[$#]\{/; -/** - * An opening anywhere in the text. `ExpressionManager.createExpression` hands - * JUEL the string as it stands, and JUEL evaluates a composite such as - * `a-${b}` as text around an expression; either opener marks an expression. - */ +/** JUEL evaluates a composite such as `a-${b}`, so an opening anywhere counts. */ export const EXPRESSION_ANYWHERE = /[$#]\{/; /** - * The words JUEL's `Scanner.addKeyToken` registers as operators besides - * `true`, `false` and `null`, which the grammar spells as literals. A variable - * or property named one of these renders as `${mod > 1}`, which the JUEL - * parser refuses at deployment. + * JUEL operator words besides the literals; a variable of one of these names + * renders as `${mod > 1}`, which JUEL refuses at deployment. */ export const JUEL_RESERVED_WORDS = [ 'empty', @@ -559,19 +479,10 @@ export const JUEL_RESERVED_WORDS = [ 'instanceof', ] as const; -/** - * The grammar spells these as literals, so only a raw template can carry one - * as a name: `${order.true}` reaches `Scanner.nextIdentifier`, which returns - * the keyword token, and `Parser.parseDotToken`'s `consumeToken(IDENTIFIER)` - * refuses it. - */ +/** Keyword tokens to JUEL, so `${order.true}` fails to parse. */ export const JUEL_LITERAL_WORDS = ['true', 'false', 'null'] as const; -/** - * Written to `datePattern`, which `FormTypes.parseFormPropertyType` reads on - * a `date` field alone. A type parameter rather than a constraint, so it is - * not in {@link FORM_CONSTRAINT_NAMES}. - */ +/** Written to `datePattern`, read on a `date` field alone; a type parameter, not a constraint. */ export const DATE_PATTERN_KEY = 'pattern'; export const FORM_FIELD_SETTING_KEYS: readonly string[] = [ @@ -579,10 +490,7 @@ export const FORM_FIELD_SETTING_KEYS: readonly string[] = [ DATE_PATTERN_KEY, ]; -/** - * Soft trigger words: they lex as plain `ID`s rather than keywords, so an - * unrecognized one is a validator diagnostic, not a parse error. - */ +/** Soft words: an unknown one is a validator diagnostic, not a parse error. */ export const ON_TRIGGERS = [ 'error', 'escalation', @@ -594,11 +502,7 @@ export const ON_TRIGGERS = [ 'cancel', ] as const; -/** - * A timer fires off the clock and a condition off data, so neither has - * anything to throw; a cancel is written on the `end` that gives up an - * `attempt` block. - */ +/** A timer or condition has nothing to throw; a cancel is written on an `end`. */ export const THROW_TRIGGERS = [ 'error', 'escalation', @@ -607,11 +511,7 @@ export const THROW_TRIGGERS = [ 'compensation', ] as const; -/** - * An error always ends its path, so it has no continuing form. A link - * continues at its catch rather than at the next statement, and a link throw - * is intermediate, never an end, so `throw` has no link form. - */ +/** An error always ends its path; a link throw is intermediate, so `throw` has no link. */ export const EMIT_TRIGGERS = [ 'escalation', 'message', @@ -620,11 +520,7 @@ export const EMIT_TRIGGERS = [ 'link', ] as const; -/** - * `link` is left out: `BpmnParse.parseIntermediateCatchEvent` refuses a link - * catch behind an event-based gateway, so a plain `await` is the only place - * one may stand. - */ +/** No `link`: Operaton refuses a link catch behind an event-based gateway. */ export const RACE_TRIGGERS = [ 'message', 'timer', @@ -632,19 +528,12 @@ export const RACE_TRIGGERS = [ 'condition', ] as const; -/** - * Error and escalation travel outward, compensation runs through a - * subprocess's own `on compensation` body, and a cancel is caught by - * `on : cancel`, so none of those has an inline catch. - */ +/** Error, escalation, compensation and cancel are caught by an `on` handler alone. */ export const CATCH_TRIGGERS = [...RACE_TRIGGERS, 'link'] as const; /** - * `BpmnParse.parseProcessDefinitionStartEvent` looks only for a timer, - * message, signal or conditional definition; an error, escalation or - * compensation definition on a process-level start is never inspected, so it - * starts as if none were written. Those three stay off rather than emitting - * XML the engine disregards. + * The process-level start parser inspects only timer, message, signal and + * conditional definitions; any other would start as if none were written. */ export const START_TRIGGERS = [ 'message', @@ -653,32 +542,22 @@ export const START_TRIGGERS = [ 'condition', ] as const; -/** - * Carried by an end event rather than raised: a terminate stops every running - * path of its scope at once, a cancel gives up the `attempt` block it sits in. - */ export const END_TRIGGERS = ['terminate', 'cancel'] as const; export interface TriggerPayloadRule { readonly code: 'required' | 'optional' | 'forbidden'; - /** Whether the `particle`/`time` clause is required. */ readonly timer: boolean; readonly parens: 'bindings' | 'condition' | 'forbidden'; /** Whether the event carries a message text beside its code. */ readonly message: boolean; - /** Whether a non-interrupting `alongside` handler is legal. */ readonly alongside: boolean; - /** - * Whether the trigger may attach as a `bpmn:boundaryEvent`. `compensation` - * instead attaches through `bpmn:association`/`isForCompensation`. - */ + /** `compensation` attaches through an association, never as a `bpmn:boundaryEvent`. */ readonly boundary: boolean; /** Whether the trigger may open a host-less handler (an event sub-process). */ readonly hostless: boolean; } export const TRIGGER_PAYLOAD: Readonly> = { - // An error always interrupts. error: { code: 'optional', timer: false, @@ -697,7 +576,6 @@ export const TRIGGER_PAYLOAD: Readonly> = { boundary: true, hostless: true, }, - // A message and a signal are name-keyed subscriptions. message: { code: 'required', timer: false, @@ -734,8 +612,6 @@ export const TRIGGER_PAYLOAD: Readonly> = { boundary: true, hostless: true, }, - // Compensation reverses finished work: nothing to catch by name, no flow to - // run alongside. compensation: { code: 'forbidden', timer: false, @@ -745,7 +621,6 @@ export const TRIGGER_PAYLOAD: Readonly> = { boundary: false, hostless: true, }, - // A cancel is caught on its `attempt` host alone. cancel: { code: 'forbidden', timer: false, @@ -755,8 +630,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { boundary: true, hostless: false, }, - // A link keys the engine's per-file link table by name alone, so it opens - // no `on` handler of any form. + // The engine's link table is keyed by name alone, so a link opens no handler. link: { code: 'required', timer: false, @@ -768,11 +642,15 @@ export const TRIGGER_PAYLOAD: Readonly> = { }, } satisfies Record<(typeof ON_TRIGGERS)[number] | 'link', TriggerPayloadRule>; -/** - * `throw error(OUT_OF_STOCK)` names a code declared in the process header and - * is resolved as a cross-reference; `message("OrderReceived")` names the - * subscription the engine keys on and declares nothing. - */ +export const HANDLER_START_TRIGGERS = ON_TRIGGERS.filter( + (word) => TRIGGER_PAYLOAD[word].hostless, +); + +export const BOUNDARY_TRIGGERS = ON_TRIGGERS.filter( + (word) => TRIGGER_PAYLOAD[word].boundary, +); + +/** A declared code is a cross-reference; a message or signal names its subscription. */ export const DECLARED_CODE_TRIGGERS: ReadonlySet = new Set([ 'error', 'escalation', @@ -784,11 +662,8 @@ export function namesACode(trigger: string): boolean { } /** - * Fence tags and the canonical `scriptFormat` each normalizes to; the printer - * emits the canonical one, so `js` round-trips to `javascript`. `juel` is the - * one engine the jar always registers - * (`ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE`); every other tag resolves a - * JSR-223 engine off the classpath at first evaluation. + * Fence tag -> canonical `scriptFormat`; `juel` is the only engine always + * registered, every other resolves a JSR-223 engine at first evaluation. */ export const SCRIPT_FORMAT_ALIASES: Readonly> = { juel: 'juel', @@ -803,20 +678,14 @@ export const SCRIPT_FORMAT_ALIASES: Readonly> = { feel: 'feel', }; -/** - * Case-insensitive, as `ScriptingEngines.getScriptEngineForLanguage` - * lowercases the language before looking an engine up. - */ +/** Case-insensitive, as Operaton lowercases the language before the engine lookup. */ export function scriptFormatOf(tag: string): string | undefined { return SCRIPT_FORMAT_ALIASES[tag.toLowerCase()]; } /** - * The tag is the run of ASCII letters after the opening fence, one line - * terminator after it is dropped, and the body is otherwise verbatim except - * that every `\r\n` becomes `\n`: the terminal matches `\r` like any other - * character, so a CRLF checkout would otherwise write a carriage return into - * `` that an LF checkout never would. + * One line terminator after the tag is dropped and `\r\n` becomes `\n`, so a + * CRLF checkout writes the same `` as an LF one. */ export function splitFencedScript(raw: string): { tag: string; code: string } { const inner = raw.slice(3, -3); @@ -830,7 +699,6 @@ export function splitFencedScript(raw: string): { tag: string; code: string } { return { tag, code: afterOpeningLine.replace(/\r\n/g, '\n') }; } -/** Each carries `items`, `params`, and `listeners`; all but `call` also carry `forms`. */ export type AttributeOwner = | StartEvent | EndEvent @@ -850,205 +718,145 @@ export type AttributeOwner = | RaceBranch; export interface AttributeBlockRule { - /** The element kind as a noun phrase with article, for diagnostics. */ + /** With article, for diagnostics. */ readonly description: string; /** - * The keys this kind owns. `label` and `documentation` appear wherever the - * element lowers to a BPMN node with a diagram name; a handler, a `throw`, - * an `emit`, an `await` and a branch of one take neither, so either written - * there is an unknown key, not a dropped one. + * `label` and `documentation` only where the element has a diagram name; a + * handler, `throw`, `emit`, `await` or race branch refuses them as unknown. */ readonly own: readonly string[]; - /** {@link own} and the engine settings together, for membership tests. */ readonly keys: ReadonlySet; - /** - * The bare words legal in the parens. A flag word lexes as one wherever - * parens are written, so a kind that takes none needs the empty row to - * refuse them. - */ + /** Flag words lex everywhere, so a kind taking none needs the empty list to refuse them. */ readonly flags: readonly string[]; readonly forms: boolean; - /** Whether the statement takes a `for` clause, and so its parens the {@link RUN_ENGINE_KEYS}. */ readonly repeats: boolean; readonly parameters: boolean; - /** - * Answers for the element's own block: a listener's block follows the - * listener's own binding, so a task listener carries a field on a kind this - * says `false` for. - */ + /** A listener's block follows its own binding, not this. */ readonly fields: boolean; readonly taskListeners: boolean; /** - * Whether the kind takes an external task's extras: `taskPriority` in the - * parens, and `property` and `error ... when` lines in the block, each - * checked against the binding written. A thrown message can bind a topic - * too, but this surface writes none of them for it. + * `taskPriority`, `property` and `error ... when` lines, each checked against + * the binding. A thrown message could bind a topic, but none is written for it. */ readonly externalExtras: boolean; } -function withKeys(spec: Omit): AttributeBlockRule { - return { +/** An omitted list is empty and an omitted switch is off. */ +function withKeys( + spec: Pick & + Partial>, +): AttributeBlockRule { + const rule = { + own: [], + flags: [], + forms: false, + repeats: false, + parameters: false, + fields: false, + taskListeners: false, + externalExtras: false, ...spec, + }; + return { + ...rule, keys: new Set([ ...ENGINE_KEYS, - ...spec.own, - ...(spec.repeats ? RUN_ENGINE_KEYS : []), + ...rule.own, + ...(rule.repeats ? RUN_ENGINE_KEYS : []), ]), }; } -/** - * Required keys (`process` on a call) and pairings (`binding`/`version` - * beside the key they pin) are checked separately; the table holds only what - * is legal. - */ +const SERVICE_TASK_LIKE = { + own: [ + ...LABEL_KEYS, + ...SERVICE_TASK_BINDING_KEYS, + 'resultVariable', + TASK_PRIORITY_KEY, + ], + repeats: true, + parameters: true, + fields: true, + externalExtras: true, +}; + +/** Legal keys only; required keys and pairings are checked separately. */ export const ATTRIBUTE_BLOCK_RULES: Readonly< Record > = { StartEvent: withKeys({ description: 'a start event', - own: ['label', 'documentation', 'initiator'], - flags: [], + own: [...LABEL_KEYS, 'initiator'], forms: true, - repeats: false, - parameters: false, - fields: false, - taskListeners: false, - externalExtras: false, }), EndEvent: withKeys({ description: 'an end event', - own: ['label', 'documentation'], - flags: [], - forms: false, - repeats: false, - parameters: false, - fields: false, - taskListeners: false, - externalExtras: false, + own: LABEL_KEYS, }), UserTask: withKeys({ description: 'a user task', - // Offered in print order, the form-reference triple right after `formKey`. + // In print order. own: [ - 'label', - 'documentation', + ...LABEL_KEYS, ...USER_TASK_VERBATIM_KEYS.flatMap((key) => key === 'formKey' ? [key, 'formRef', 'binding', 'version'] : [key], ), ], - flags: [], forms: true, repeats: true, parameters: true, - fields: false, taskListeners: true, - externalExtras: false, }), ServiceTask: withKeys({ description: 'a service task', - own: [ - 'label', - 'documentation', - ...SERVICE_TASK_BINDING_KEYS, - 'resultVariable', - TASK_PRIORITY_KEY, - ], - flags: [], - forms: false, - repeats: true, - parameters: true, - fields: true, - taskListeners: false, - externalExtras: true, + ...SERVICE_TASK_LIKE, }), ScriptTask: withKeys({ description: 'a script task', - own: ['label', 'documentation', 'resultVariable'], - flags: [], - forms: false, + own: [...LABEL_KEYS, 'resultVariable'], repeats: true, parameters: true, - fields: false, - taskListeners: false, - externalExtras: false, }), GenericTask: withKeys({ description: 'a step', - own: ['label', 'documentation'], - flags: [], - forms: false, + own: LABEL_KEYS, repeats: true, parameters: true, - fields: false, - taskListeners: false, - externalExtras: false, }), SendTask: withKeys({ description: 'a send task', - own: [ - 'label', - 'documentation', - ...SERVICE_TASK_BINDING_KEYS, - 'resultVariable', - TASK_PRIORITY_KEY, - ], - flags: [], - forms: false, - repeats: true, - parameters: true, - fields: true, - taskListeners: false, - externalExtras: true, + ...SERVICE_TASK_LIKE, }), ReceiveTask: withKeys({ description: 'a receive task', - own: ['label', 'documentation', 'message'], - flags: [], - forms: false, + own: [...LABEL_KEYS, 'message'], repeats: true, parameters: true, - fields: false, - taskListeners: false, - externalExtras: false, }), BusinessRuleTask: withKeys({ description: 'a decision step', own: [ - 'label', - 'documentation', + ...LABEL_KEYS, ...BUSINESS_RULE_BINDING_KEYS, - 'binding', - 'version', - 'mapDecisionResult', + ...DECISION_MODIFIER_KEYS, 'resultVariable', TASK_PRIORITY_KEY, ], - flags: [], - forms: false, repeats: true, parameters: true, fields: true, - taskListeners: false, externalExtras: true, }), SubProcess: withKeys({ description: 'a subprocess', - own: ['label', 'documentation'], - flags: [], - forms: false, + own: LABEL_KEYS, repeats: true, parameters: true, - fields: false, - taskListeners: false, - externalExtras: false, }), CallActivity: withKeys({ description: 'a call', own: [ - 'label', - 'documentation', + ...LABEL_KEYS, 'process', 'binding', 'version', @@ -1056,91 +864,39 @@ export const ATTRIBUTE_BLOCK_RULES: Readonly< 'mapper', 'mapperDelegate', ], - flags: [], - forms: false, repeats: true, parameters: true, - fields: false, - taskListeners: false, - externalExtras: false, }), - // A hosted handler lowers to a boundary event, on which - // `BpmnParse.parseBoundaryEvents` refuses a mapping, and a host-less one to - // an event sub-process, which `BpmnParse.checkActivityInputOutputSupported` - // refuses one on. + // A boundary event and an event sub-process both refuse a mapping in Operaton. OnHandler: withKeys({ description: 'an event handler', - own: [], flags: ['alongside'], - forms: false, - repeats: false, - parameters: false, - fields: false, - taskListeners: false, - externalExtras: false, }), - // The binding keys carry the implementation that makes the engine really - // send a thrown message; the validator holds them to the `message` trigger. + // These make the engine really send a thrown message; `message` trigger only. ThrowStatement: withKeys({ description: 'a throw statement', - own: [...THROW_BINDING_KEYS], - flags: [], - forms: false, - repeats: false, - parameters: false, - fields: false, - taskListeners: false, - externalExtras: false, + own: THROW_BINDING_KEYS, }), EmitStatement: withKeys({ description: 'an emit statement', - own: [...THROW_BINDING_KEYS], - flags: [], - forms: false, - repeats: false, - parameters: false, - fields: false, - taskListeners: false, - externalExtras: false, + own: THROW_BINDING_KEYS, }), IntermediateCatchEvent: withKeys({ description: 'an awaited event', - own: [], - flags: [], - forms: false, - repeats: false, - parameters: false, - fields: false, - taskListeners: false, - externalExtras: false, }), - // The same catch element with a body, so it takes the same keys. RaceBranch: withKeys({ description: 'a branch of an await block', - own: [], - flags: [], - forms: false, - repeats: false, - parameters: false, - fields: false, - taskListeners: false, - externalExtras: false, }), }; export interface GatewayStatementRule { readonly description: string; - /** - * Whether the statement synthesizes a join gateway beside its split, and so - * takes the {@link JOIN_ENGINE_KEYS}; a loop is one gateway with a - * back-edge. - */ + /** A synthesized join gateway beside the split; a loop is one gateway with a back-edge. */ readonly join: boolean; - /** The {@link ENGINE_KEYS} the head does not take. */ readonly refuses: readonly string[]; } -export const GATEWAY_STATEMENT_RULES: Readonly< +const GATEWAY_STATEMENT_RULES: Readonly< Record< | 'IfStatement' | 'WhileStatement' @@ -1162,7 +918,7 @@ export const GATEWAY_STATEMENT_RULES: Readonly< join: true, refuses: [], }, - // `BpmnParse.parseEventBasedGateway` refuses `asyncAfter` at deployment. + // `BpmnParse.parseEventBasedGateway` refuses `asyncAfter`. RaceStatement: { description: 'an await block', join: true, @@ -1194,11 +950,7 @@ export function parameterDirectionsFor( ]; } -/** - * Out of {@link ATTRIBUTE_BLOCK_RULES} because that map is keyed by AST type - * and `attempt` shares `SubProcess`; a diagnostic enumerating the kinds a - * setting is legal on adds it back beside the map's rows. - */ +/** Apart because `attempt` shares the `SubProcess` AST type the rules are keyed by. */ export const ATTEMPT_BLOCK_RULE: AttributeBlockRule = { ...ATTRIBUTE_BLOCK_RULES.SubProcess, description: 'an attempt block', diff --git a/packages/language/test/completion.test.ts b/packages/language/test/completion.test.ts index 7c09408..f343047 100644 --- a/packages/language/test/completion.test.ts +++ b/packages/language/test/completion.test.ts @@ -1,10 +1,3 @@ -/** - * `BpmnScriptCompletionProvider` through the real `CompletionProvider` on the - * shared services, so the DI wiring is exercised too. A row pins the whole - * offered list at a caret; a scaffold row accepts an item the way an editor - * does and parses and validates the result. - */ - import { beforeAll, describe, expect, test } from 'vitest'; import { EmptyFileSystem, URI, type LangiumDocument } from 'langium'; import { @@ -74,11 +67,7 @@ function inserted(item: CompletionItem): string { return item.insertText ?? item.label; } -/** - * The text an editor leaves once every tab stop is tabbed past: a choice - * collapses to its first option, a default to itself, a bare stop to - * `fillStop`, and a `\$` escape to the EL `$` it stands for. - */ +/** The text an editor leaves once every tab stop is tabbed past. */ function accepted( item: CompletionItem, fillStop: (stop: string) => string = () => '', @@ -90,11 +79,7 @@ function accepted( .replace(/\\\$/g, '$'); } -/** - * A step typed into every bare tab stop, since each opens a body that is only - * complete once something runs in it; the stop's number keeps two branches of - * one scaffold from colliding on a name. - */ +/** A bare tab stop opens a body, complete only once a step runs in it. */ const typedInto = (stop: string) => `user Step${stop}`; let parseCounter = 0; @@ -112,7 +97,7 @@ async function build(text: string, validation: boolean) { return document; } -/** Both arrays: an unlexable character never reaches the parser, so parser errors alone would report a rejected input as clean. */ +/** An unlexable character never reaches the parser, so lexer errors count too. */ async function parseErrors(text: string): Promise { const { parseResult } = await build(text, false); return [...parseResult.lexerErrors, ...parseResult.parserErrors].map( @@ -120,7 +105,7 @@ async function parseErrors(text: string): Promise { ); } -/** Errors only: a scaffold's example value names a variable the author has yet to declare, and that warning is the editor doing its job. */ +/** Errors only: a scaffold's example value names a variable not yet declared. */ async function validationErrors(text: string): Promise { const document: LangiumDocument = await build(text, true); return withTextMessages(document.diagnostics ?? []) @@ -138,11 +123,7 @@ function caretAt(program: string) { }; } -/** - * One offered completion. The kind is derived, a `Snippet` where the item - * inserts more than its label and a `Keyword` otherwise, so only a - * cross-reference row spells its own. - */ +/** The kind defaults to `Snippet` when the item inserts more than its label, else `Keyword`. */ type Item = readonly [ label: string, detail: string, @@ -179,10 +160,8 @@ const TIMER_KEYS: Item[] = [ ['every', SETTING, 'every: "${1:R/PT10M}"'], ]; -/** Offered in the header, above the first step. */ const START: Item = ['start', CONSTRUCT, 'start ${1:name}']; -/** Every statement a body position offers, in order. */ const STATEMENTS: Item[] = [ START, ['end', CONSTRUCT, 'end ${1:name}'], @@ -259,11 +238,7 @@ const REPEAT_FORMS: Item[] = [ ['for', 'how often the preceding step runs', 'for ${1:3}'], ]; -/** - * What a handler at process level catches, host-less or on a user task. - * `compensation` joins only directly inside a subprocess body, `cancel` only - * on an attempt block as host, and a service task drops `escalation`. - */ +/** `compensation` joins only inside a subprocess body, `cancel` only on an attempt host; a service task drops `escalation`. */ const ON_TRIGGERS: Item[] = [ ['error', EVENT_WORD, 'error'], ['escalation', EVENT_WORD, 'escalation'], @@ -346,7 +321,6 @@ const LISTENER_KEYWORD: Item = [ const FORM_KEYWORD: Item = ['form', KEYWORD, 'form']; -/** In the order the constraints are registered. */ const FORM_FIELD_PARENS: Item[] = [ ['required', SETTING, 'required: true'], ['readonly', SETTING, 'readonly: true'], @@ -377,10 +351,8 @@ const ERROR_MAPPING: Item = [ const BLOCK_MEMBERS: Item[] = [FORM_KEYWORD, ...PARAMETERS, LISTENER_KEYWORD]; -/** A class binding takes injected fields. */ const CLASS_BLOCK_MEMBERS: Item[] = [...PARAMETERS, FIELD, LISTENER_KEYWORD]; -/** A topic binding takes properties and error mappings. */ const TOPIC_BLOCK_MEMBERS: Item[] = [ ...PARAMETERS, PROPERTY, @@ -388,7 +360,6 @@ const TOPIC_BLOCK_MEMBERS: Item[] = [ ERROR_MAPPING, ]; -/** The three ways a listener binds, and so its whole parens. */ const BINDINGS: Item[] = [ ['class', SETTING, 'class: "${1:com.example.Delegate}"'], ['expression', SETTING, 'expression: "${1:\\${bean.method(execution)}}"'], @@ -830,8 +801,7 @@ describe('the completions offered at a caret', () => { 'process p {\n user T {\n on create(class: "com.example.L") {\n |\n }\n }\n}', [FIELD], ], - // The engine refuses a parameter on an event sub-process and a boundary - // event carries none. + // An event sub-process refuses a parameter; a boundary event carries none. [ 'a host-less handler block offers no parameter', 'process p {\n start S\n on error {\n |\n } {\n end Failed\n }\n}', @@ -884,8 +854,7 @@ describe('the completions offered at a caret', () => { ['any', KEYWORD, 'any'], ], ], - // Every identifier in an expression is a reference to a code declaration; - // the scope is what keeps the declared codes out of this list. + // The scope keeps the declared codes out of an expression's offer. [ 'an expression position offers no code declaration, only the literal words', 'process p {\n error PAYMENT_DECLINED(message: "x")\n if (|) {\n user A\n }\n}', @@ -946,8 +915,7 @@ describe('the completions offered at a caret', () => { 'process p {\n error PAYMENT_DECLINED(message: "x")\n service S(class: "c") {\n error |\n }\n}', [], ], - // A bare variable is the prefix of the expression the author goes on to - // write, so these rows pin the offer and not its validity. + // A bare variable is a prefix of the expression, so these pin the offer only. [ 'a condition offers the declared variables beside the literal words', `process p {\n ${VARS}\n if (|) {\n user A\n }\n}`, @@ -1021,8 +989,6 @@ describe('the completions offered at a caret', () => { expected.map(([label, detail, insert]) => [label, detail, insert]), ); for (const [index, item] of items.entries()) { - // Without the snippet format the editor writes the placeholders out - // literally. const scaffolds = inserted(item) !== item.label; expect(item.insertTextFormat === InsertTextFormat.Snippet).toBe( scaffolds, @@ -1036,15 +1002,24 @@ describe('the completions offered at a caret', () => { }); }); -/** The rows of `items` that scaffold something, each with the program it lands in. */ +type Scaffolds = readonly [ + where: string, + labels: string[], + program: (accepted: string, label: string) => string, +]; + function scaffolds( where: string, items: Item[], - program: (accepted: string) => string, -): Array string]> { - return items - .filter(([label, , insertText]) => insertText !== label) - .map(([label]) => [`\`${label}\` in ${where}`, label, program] as const); + program: (accepted: string, label: string) => string, +): Scaffolds { + return [ + where, + items + .filter(([label, , insertText]) => insertText !== label) + .map(([label]) => label), + program, + ]; } /** A binding is checked in a host that writes none, since a second one would collide with it. */ @@ -1085,163 +1060,168 @@ describe('a scaffold parses and validates once accepted', () => { expect(await parseErrors('process p {\n @@@\n}')).not.toEqual([]); }); - test.each([ - ...scaffolds( + test.each([ + scaffolds( 'a process header', [...HEADER_DECLS, START], (decl) => `process p {\n${decl}\n user Anchor\n}`, ), - ...scaffolds( + scaffolds( 'a process body', STATEMENTS.filter((item) => item !== START), (statement) => `process p {\n ${declarationNaming(statement)}\n user Anchor\n${statement}\n}`, ), - ...scaffolds( + scaffolds( 'the position after a step name', REPEAT_FORMS, (clause) => `process p {\n user U ${clause}\n}`, ), - ...scaffolds( + scaffolds( 'the parens of a user task', USER_PARENS.filter((item) => !pinsAForm(item) && !namesAForm(item)), (setting) => `process p {\n user T(${setting})\n}`, ), - ...scaffolds( + scaffolds( 'the parens of a user task', USER_PARENS.filter(pinsAForm), (setting) => `process p {\n user T(formRef: "f", ${setting})\n}`, ), - ...scaffolds( + scaffolds( 'the parens of a user task', USER_PARENS.filter(namesAForm), (setting) => `process p {\n user T(${setting}, binding: latest)\n}`, ), // `taskPriority` rides a `topic` binding alone. - ...scaffolds( + scaffolds( 'the parens of a service task', SERVICE_PARENS.filter((item) => !binds(item)), (setting) => `process p {\n service S(topic: "t", ${setting})\n}`, ), - ...scaffolds( + scaffolds( 'the parens of a service task', SERVICE_PARENS.filter((item) => binds(item) && !bindsBuiltin(item)), (binding) => `process p {\n service S(${binding})\n}`, ), - ...scaffolds( + scaffolds( 'the parens of a service task', SERVICE_PARENS.filter(bindsBuiltin), (binding) => `process p {\n service S(${binding}) {\n field to = "a@b"\n field text = "t"\n }\n}`, ), - ...scaffolds( + scaffolds( 'the parens of a repeated service task', RUN_SETTINGS, (setting) => `process p {\n service S for 3(topic: "t", ${setting})\n}`, ), - ...scaffolds( + scaffolds( 'the parens of a decision step', DECIDE_PARENS.filter((item) => !binds(item) && !pinsADecision(item)), (setting) => `process p {\n decide D(topic: "t", ${setting})\n}`, ), - ...scaffolds( + scaffolds( 'the parens of a decision step', DECIDE_PARENS.filter(pinsADecision), (setting) => `process p {\n decide D(decision: "d", ${setting})\n}`, ), - ...scaffolds( + scaffolds( 'the parens of a decision step', DECIDE_PARENS.filter((item) => binds(item) && !bindsBuiltin(item)), (binding) => `process p {\n decide D(${binding})\n}`, ), - ...scaffolds( + scaffolds( 'the parens of a call', CALL_PARENS.filter((item) => !binds(item)), (setting) => `process p {\n call C(process: "q", ${setting})\n}`, ), - ...scaffolds( + scaffolds( 'the parens of a call', CALL_PARENS.filter(binds), (binding) => `process p {\n call C(${binding})\n}`, ), - ...scaffolds( + scaffolds( 'the parens of a process', PROCESS_PARENS, (setting) => `process p(${setting}) {\n user U\n}`, ), - ...scaffolds( + scaffolds( 'a user block', BLOCK_MEMBERS, (member) => `process p {\n user T {\n${member}\n }\n}`, ), - ...scaffolds( + scaffolds( 'a service block', [FIELD], (member) => `process p {\n service S(class: "com.example.D") {\n${member}\n }\n}`, ), - ...scaffolds( + scaffolds( 'a topic-bound service block', [PROPERTY, ERROR_MAPPING], (member) => `process p {\n error CODE(message: "m")\n service S(topic: "t") {\n${member}\n }\n}`, ), - ...scaffolds( + scaffolds( "a listener's block", [FIELD], (member) => `process p {\n user T {\n on create(class: "com.example.L") {\n${member}\n }\n }\n}`, ), - ...scaffolds( + scaffolds( 'the parens of a listener', LISTENER_PARENS, (binding) => `process p {\n user T {\n on create(${binding})\n }\n}`, ), - ...scaffolds( + scaffolds( 'the listener event position', TASK_EVENTS, (listener) => `process p {\n user T {\n on ${listener}\n }\n}`, ), - ...scaffolds( + scaffolds( 'the trigger position of a handler', ON_TRIGGERS, (trigger) => `process p {\n user U\n on U: ${trigger} {\n user Caught\n }\n}`, ), - ...scaffolds( + scaffolds( 'the trigger position of an await', CATCH_TRIGGERS, (trigger) => `process p {\n user U\n await ${trigger}\n}`, ), - ...FORM_FIELD_PARENS.map( - ([label]) => - [ - `\`${label}\` in the parens of a form field`, - label, - (setting: string) => - `process p {\n start S {\n form {\n f: ${fieldTypeTaking(label)} (${setting})\n }\n }\n}`, - ] as const, + scaffolds( + 'the parens of a form field', + FORM_FIELD_PARENS, + (setting, label) => + `process p {\n start S {\n form {\n f: ${fieldTypeTaking(label)} (${setting})\n }\n }\n}`, ), - ...scaffolds( + scaffolds( "a form field's block", [PROPERTY], (member) => `process p {\n start S {\n form {\n plan: enum {\n basic "Basic"\n${member}\n }\n }\n }\n}`, ), - ])('%s', async (_title, label, program) => { - const { text, line, character } = caretAt(program('|')); - const item = (await completionItems(text, line, character)).find( - (i) => i.label === label, - ); - expect(item).toBeDefined(); - expect(await parseErrors(program(accepted(item!)))).toEqual([]); - expect(await validationErrors(program(accepted(item!, typedInto)))).toEqual( - [], - ); + ])('every scaffold in %s', async (_where, labels, program) => { + expect(labels).not.toEqual([]); + const refused: Array<[label: string, ...messages: string[]]> = []; + for (const label of labels) { + const { text, line, character } = caretAt(program('|', label)); + const item = (await completionItems(text, line, character)).find( + (i) => i.label === label, + ); + if (!item) { + refused.push([label, 'not offered']); + continue; + } + const messages = [ + ...(await parseErrors(program(accepted(item), label))), + ...(await validationErrors(program(accepted(item, typedInto), label))), + ]; + if (messages.length > 0) refused.push([label, ...messages]); + } + expect(refused).toEqual([]); }); }); -/** Keyed by description, the noun phrase a host row spells too. */ const RULE_BY_DESCRIPTION = new Map( [...Object.values(ATTRIBUTE_BLOCK_RULES), ATTEMPT_BLOCK_RULE].map((rule) => [ rule.description, @@ -1274,12 +1254,6 @@ const labels = (items: readonly Item[]): string[] => /** Each continues with a mandatory token, so it cannot parse alone. */ const PREFIX_WORDS: ReadonlySet = new Set(['goto', 'form']); -/** - * One slot per program, so the caret and the spliced item come from the same - * text. A parse error is forgiven for a bare {@link PREFIX_WORDS} item alone, - * a bare event word is spliced with the name its rule requires, and one row - * reports every refused item with the sentence the validator answered. - */ describe('offered is a subset of accepted', () => { test.each string, string[]]>([ [ @@ -1448,8 +1422,7 @@ describe('offered is a subset of accepted', () => { `process p {\n start S\n subprocess Sub {\n start Inner\n on ${x} {\n end Failed\n }\n }\n}`, [...labels(ON_TRIGGERS), 'compensation'], ], - // A host alone is the prefix of `on Host: trigger`, so the row completes - // it. + // A host alone is the prefix of `on Host: trigger`, so the row completes it. [ 'a host-less handler in a subprocess body offers the hosts of that body and none outside it', (x) => diff --git a/packages/language/test/helpers/block-hosts.ts b/packages/language/test/helpers/block-hosts.ts index 7d87b2e..f673f9a 100644 --- a/packages/language/test/helpers/block-hosts.ts +++ b/packages/language/test/helpers/block-hosts.ts @@ -1,20 +1,10 @@ -/** - * The element kinds that take settings, as test fixtures. - * - * The validator suite checks what an element accepts and the completion suite - * checks what it offers, which are two readings of one table. Both drive off - * these rows, so a new kind is added in one place. - */ +// The validator and completion suites both drive off these rows, so a new +// element kind is added in one place. import { ENGINE_KEYS, type EngineKey } from '@bpmn-script/language'; -/** - * A triple-backtick fence, assembled without a literal fence in the test source - * so it can be interpolated into JS template-literal DSL fixtures. - */ export const FENCE = '`' + '`' + '`'; -/** A value each engine key takes, in the shape its lowering reads. */ const VALID_ENGINE_VALUE: Readonly> = { asyncBefore: 'true', asyncAfter: 'true', @@ -23,25 +13,18 @@ const VALID_ENGINE_VALUE: Readonly> = { retryCycle: '"R3/PT10M"', }; -/** `keys` as one parens' worth of items, each spelled through `keyOf`. */ export const engineItems = ( keys: readonly EngineKey[], keyOf: (key: string) => string = (key) => key, ): string => keys.map((key) => `${keyOf(key)}: ${VALID_ENGINE_VALUE[key]}`).join(', '); -/** Every engine execution setting, as one parens' worth of items. */ export const ENGINE_SETTINGS = engineItems(ENGINE_KEYS); /** - * One otherwise-valid program per element kind, with one slot left open: - * `settings` opens the parens and `members` the brace block holding the forms, - * the parameters, and the listeners. Everything else in each program already - * validates, so the only diagnostics a case can produce are the slot's own. - * - * Both slots take non-empty contents. Parens with nothing between them are a - * parse error, and an empty brace block is read as the body on the kinds that - * take one. + * One otherwise-valid program per element kind with its parens (`settings`) or + * member block (`members`) open. Both take non-empty contents: empty parens do + * not parse, and an empty brace block reads as the body. */ export const BLOCK_HOSTS: ReadonlyArray< [ @@ -155,12 +138,7 @@ export const BLOCK_HOSTS: ReadonlyArray< ], ]; -/** - * The element kinds whose block carries `input`/`output` parameters. Neither - * handler form is in the set: the hosted one lowers to a boundary event, which - * carries none, and the host-less one to an event sub-process, which - * `BpmnParse.checkActivityInputOutputSupported` refuses a mapping on. - */ +/** No handler: a boundary event carries no parameters, and `BpmnParse.checkActivityInputOutputSupported` refuses them on an event sub-process. */ export const PARAMETER_HOSTS = new Set([ 'user', 'service', @@ -174,20 +152,12 @@ export const PARAMETER_HOSTS = new Set([ 'call', ]); -/** The element kinds whose block carries a `form` declaration. */ export const FORM_HOSTS = new Set(['start', 'user']); -/** - * The element kinds that take an external task's extras: `taskPriority` in - * the parens, `property` and `error ... when` lines in the block, each legal - * beside a `topic` binding alone. - */ +/** The kinds taking `taskPriority`, `property` and `error ... when`, each only beside `topic`. */ export const EXTERNAL_HOSTS = new Set(['service', 'send', 'decide']); -/** - * The element kinds whose parens take a `label`. The rest lower to a BPMN node - * with no name slot of its own, so a label there would be dropped. - */ +/** The rest lower to a BPMN node with no name slot, which would drop a label. */ export const LABEL_HOSTS = new Set([ 'start', 'end', @@ -203,11 +173,7 @@ export const LABEL_HOSTS = new Set([ 'call', ]); -/** - * A row's program with the caret placed in one of its slots, after `before`. - * Every program writes its element on the first line, so the caret's line is - * always zero. - */ +/** Every program writes its element on the first line, so the caret's line is zero. */ export function caretInSlot( program: (contents: string) => string, before: string, diff --git a/packages/language/test/helpers/diagnostics.ts b/packages/language/test/helpers/diagnostics.ts index 5cc4c22..d3f9ade 100644 --- a/packages/language/test/helpers/diagnostics.ts +++ b/packages/language/test/helpers/diagnostics.ts @@ -1,20 +1,9 @@ -/** - * Diagnostics with the message read as text, shared by the suites that assert - * on wording. - * - * LSP 3.18 allows a diagnostic to carry its message as markup instead of a - * string, so `Diagnostic['message']` is a union. Everything the validator and - * the linker raise is a plain string, and every assertion here reads the - * message as text, so each suite flattens the union once where it collects - * diagnostics rather than at every assertion. - */ +// LSP 3.18 lets a diagnostic message be markup; ours are all plain strings. import { Diagnostic } from 'vscode-languageserver-types'; -/** A diagnostic whose message is plain text. */ export type TextDiagnostic = Omit & { message: string }; -/** `diagnostics`, each with its message read as text. */ export function withTextMessages( diagnostics: readonly Diagnostic[], ): TextDiagnostic[] { @@ -24,8 +13,6 @@ export function withTextMessages( })); } -// Messages shared by more than one suite, spelled once - export const UNREACHABLE = 'This step can never run: an earlier `end`, `throw`, `goto`, `emit link`, ' + 'or an all-terminating `if`/`parallel`/`await` in the same block always ' + diff --git a/packages/language/test/helpers/parse-failure.ts b/packages/language/test/helpers/parse-failure.ts index 7f2c53c..f4a1a64 100644 --- a/packages/language/test/helpers/parse-failure.ts +++ b/packages/language/test/helpers/parse-failure.ts @@ -1,16 +1,7 @@ -/** - * Turning a Langium parse result into a readable failure, shared by the suites - * that treat any parse error as an aborted test rather than an assertion. - */ - import type { LangiumDocument } from 'langium'; import { isModel } from '@bpmn-script/language'; -/** - * Every parse failure in `document` as one human-readable string, or `undefined` - * when it parsed cleanly. Lexer errors are checked first because they fire - * before the parser and would otherwise be masked. - */ +/** Lexer errors come first: they fire before the parser and would otherwise be masked. */ export function formatParseFailure( document: LangiumDocument, ): string | undefined { diff --git a/packages/language/test/hover.test.ts b/packages/language/test/hover.test.ts index 149fc4b..a7e532a 100644 --- a/packages/language/test/hover.test.ts +++ b/packages/language/test/hover.test.ts @@ -1,5 +1,3 @@ -/** Hover through the real `HoverProvider`, with `expectHover` from `langium/test`. */ - import { describe, test, beforeAll } from 'vitest'; import { EmptyFileSystem } from 'langium'; import { expectHover } from 'langium/test'; @@ -11,7 +9,6 @@ beforeAll(() => { hover = expectHover(createBpmnScriptServices(EmptyFileSystem).BpmnScript); }); -/** A program with one `<|>` caret and the hover text there, `undefined` for none. */ type Row = readonly [title: string, text: string, expected: string | undefined]; const ROWS: Row[] = [ @@ -51,7 +48,7 @@ const ROWS: Row[] = [ "an error 'E': Payment failed", ], [ - 'a written code setting is quoted; the fallback to the name is not', + 'hover text quotes an explicit code, not a name used as fallback', 'process p {\n escalation <|>OVERSIZED(code: "big-parcel")\n}', 'an escalation \'OVERSIZED\' with code "big-parcel"', ], diff --git a/packages/language/test/navigation.test.ts b/packages/language/test/navigation.test.ts index 85bb07d..0cd2bd6 100644 --- a/packages/language/test/navigation.test.ts +++ b/packages/language/test/navigation.test.ts @@ -1,10 +1,3 @@ -/** - * Definition, references, rename and document highlight through the real LSP - * providers. `langium/test` has no rename helper, so the rename rows drive - * `RenameProvider` by hand and assert the whole document after the edits: a - * rename that touched the declaration alone cannot pass on the text. - */ - import { beforeAll, describe, expect, test } from 'vitest'; import { EmptyFileSystem, TextDocument } from 'langium'; import { @@ -38,10 +31,7 @@ beforeAll(() => { const REFUSED = 'refused'; -/** - * The whole document after renaming the name under `<|>` to `newName`, or - * `REFUSED` where `prepareRename` and `rename` both answer `undefined`. - */ +/** The whole document after the rename at `<|>`, or `REFUSED`. */ async function renamed(text: string, newName: string): Promise { const { output, indices } = replaceIndices({ text }); const document = await parse(output); @@ -152,7 +142,6 @@ const RENAMES: RenameRow[] = [ ], ]; -/** A program with one `<|>` caret and the `<|...|>` ranges the request answers with. */ type Row = readonly [title: string, text: string]; const DEFINITIONS: Row[] = [ diff --git a/packages/language/test/parsing.test.ts b/packages/language/test/parsing.test.ts index a1bde2e..bd8458a 100644 --- a/packages/language/test/parsing.test.ts +++ b/packages/language/test/parsing.test.ts @@ -1,9 +1,3 @@ -/** - * The grammar, driven through Langium's `parseHelper`. Whether a shape is - * legal is the validator suite's question; where a reference resolves is the - * scoping suite's. - */ - import { beforeAll, describe, expect, test } from 'vitest'; import { EmptyFileSystem } from 'langium'; import { parseHelper } from 'langium/test'; @@ -49,11 +43,7 @@ beforeAll(() => { parse = parseHelper(services.BpmnScript); }); -/** - * A parsed subtree as `Type(prop=value, ...)`, a cross-reference as `->target`. - * An empty slot, an unset flag and an empty list are dropped, so one string - * pins the filled slots, the empty ones, and any node the parser invented. - */ +/** A subtree as `Type(prop=value, ...)`, a cross-reference as `->target`, empty slots dropped. */ function shape(value: unknown): string { if (Array.isArray(value)) { return `[${value.map(shape).join(', ')}]`; @@ -90,21 +80,20 @@ async function parseErrors(source: string): Promise { return [...lexerErrors, ...parserErrors].map((e) => e.message); } -/** `source` parses to one process named `p` whose body is exactly `body`. */ -async function expectBody(source: string, body: string) { +async function expectBody(source: string, body: string | string[]) { const model = await parseModel(source); expect(model.processes).toHaveLength(1); - expect(shape(model.processes[0]!)).toBe(`Process(name="p", body=${body})`); + expect(shape(model.processes[0]!)).toBe( + `Process(name="p", body=${typeof body === 'string' ? body : `[${body.join(', ')}]`})`, + ); } -/** `source` parses to one process shaped exactly like `process`. */ async function expectProcess(source: string, process: string) { const model = await parseModel(source); expect(model.processes).toHaveLength(1); expect(shape(model.processes[0]!)).toBe(process); } -/** The AST of `expr` parsed as the condition of an `if`. */ async function parseCondition(expr: string) { const model = await parseModel(`process p { if (${expr}) { user A } }`); return (model.processes[0]!.body[0] as IfStatement).condition; @@ -115,6 +104,11 @@ async function statementAt(source: string, index = 0): Promise { } type Row = readonly [title: string, source: string, expected: string]; +type BodyRow = readonly [ + title: string, + source: string, + expected: string | string[], +]; describe('Parsing - process header', () => { test.each([ @@ -162,76 +156,41 @@ describe('Parsing - process header', () => { }); describe('Parsing - control flow and containers', () => { - test.each([ + test.each([ [ - 'three bare statements parse into three statements in source order', - `process p { user A user B user C }`, - '[UserTask(name="A"), UserTask(name="B"), UserTask(name="C")]', - ], - [ - 'an if with two else-ifs and an else populates elseIfs and elseBlock', + 'control-flow statements parse into their expected AST shapes', `process p { - if (a) { user A } + user A user B user C + if (a) { user A } else if (b) { user B } else if (c) { user C } else { user D } + if (a) { user A } + while (rejected) { user R } + do { user R } while (again) + parallel { if (amount > 10000) { user A } { service B } else { user C } } + parallel { { user A } { user B } { user C } } + parallel { { if (a) { user A } else { user B } } { user C } } }`, - '[IfStatement(condition=VarRef(ref=->a), then=Block(statements=[UserTask(name="A")]), elseIfs=[ElseIf(condition=VarRef(ref=->b), body=Block(statements=[UserTask(name="B")])), ElseIf(condition=VarRef(ref=->c), body=Block(statements=[UserTask(name="C")]))], elseBlock=Block(statements=[UserTask(name="D")]))]', - ], - [ - 'a plain if leaves elseIfs empty and elseBlock unset', - `process p { if (a) { user A } }`, - '[IfStatement(condition=VarRef(ref=->a), then=Block(statements=[UserTask(name="A")]))]', - ], - [ - 'a while loop carries its condition and a one-statement body', - `process p { while (rejected) { user R } }`, - '[WhileStatement(condition=VarRef(ref=->rejected), body=Block(statements=[UserTask(name="R")]))]', - ], - [ - 'a do-while loop carries its body before its condition', - `process p { do { user R } while (again) }`, - '[DoWhileStatement(body=Block(statements=[UserTask(name="R")]), condition=VarRef(ref=->again))]', - ], - [ - 'a parallel branch head is a condition, nothing, or else', - `process p { parallel { if (amount > 10000) { user A } { service B } else { user C } } }`, - '[ParallelStatement(branches=[ParallelBranch(condition=Relational(left=VarRef(ref=->amount), op=">", right=LiteralInt(value=10000)), body=Block(statements=[UserTask(name="A")])), ParallelBranch(body=Block(statements=[ServiceTask(name="B")])), ParallelBranch(otherwise=true, body=Block(statements=[UserTask(name="C")]))])]', - ], - [ - 'parallel supports more than two branches, each unconditioned', - `process p { parallel { { user A } { user B } { user C } } }`, - '[ParallelStatement(branches=[ParallelBranch(body=Block(statements=[UserTask(name="A")])), ParallelBranch(body=Block(statements=[UserTask(name="B")])), ParallelBranch(body=Block(statements=[UserTask(name="C")]))])]', - ], - [ - 'a branch head and a branch body opening with if are told apart', - `process p { parallel { { if (a) { user A } else { user B } } { user C } } }`, - '[ParallelStatement(branches=[ParallelBranch(body=Block(statements=[IfStatement(condition=VarRef(ref=->a), then=Block(statements=[UserTask(name="A")]), elseBlock=Block(statements=[UserTask(name="B")]))])), ParallelBranch(body=Block(statements=[UserTask(name="C")]))])]', - ], - [ - 'a labeled subprocess carries its body statements', - `process p { subprocess Handle(label: "Handle order") { user Review(assignee: "demo") } }`, - '[SubProcess(name="Handle", items=[Setting(key="label", value=LiteralString(value="Handle order"))], body=Block(statements=[UserTask(name="Review", items=[Setting(key="assignee", value=LiteralString(value="demo"))])]))]', - ], - [ - 'an empty subprocess body parses with no statements and no label', - `process p { subprocess S { } }`, - '[SubProcess(name="S", body=Block)]', - ], - [ - 'attempt shares the subprocess node type and differs only by the flag', - `process p { attempt A { } }`, - '[SubProcess(transactional=true, name="A", body=Block)]', - ], - [ - 'an attempt head takes a repeat clause, its settings and a body', - `process p { attempt A for each line in lines (label: "Book and pay", asyncBefore: true) { user U } }`, - '[SubProcess(transactional=true, name="A", element="line", collection=VarRef(ref=->lines), items=[Setting(key="label", value=LiteralString(value="Book and pay")), Setting(key="asyncBefore", value=LiteralBool(value="true"))], body=Block(statements=[UserTask(name="U")]))]', + [ + 'UserTask(name="A"), UserTask(name="B"), UserTask(name="C")', + 'IfStatement(condition=VarRef(ref=->a), then=Block(statements=[UserTask(name="A")]), elseIfs=[ElseIf(condition=VarRef(ref=->b), body=Block(statements=[UserTask(name="B")])), ElseIf(condition=VarRef(ref=->c), body=Block(statements=[UserTask(name="C")]))], elseBlock=Block(statements=[UserTask(name="D")]))', + 'IfStatement(condition=VarRef(ref=->a), then=Block(statements=[UserTask(name="A")]))', + 'WhileStatement(condition=VarRef(ref=->rejected), body=Block(statements=[UserTask(name="R")]))', + 'DoWhileStatement(body=Block(statements=[UserTask(name="R")]), condition=VarRef(ref=->again))', + 'ParallelStatement(branches=[ParallelBranch(condition=Relational(left=VarRef(ref=->amount), op=">", right=LiteralInt(value=10000)), body=Block(statements=[UserTask(name="A")])), ParallelBranch(body=Block(statements=[ServiceTask(name="B")])), ParallelBranch(otherwise=true, body=Block(statements=[UserTask(name="C")]))])', + 'ParallelStatement(branches=[ParallelBranch(body=Block(statements=[UserTask(name="A")])), ParallelBranch(body=Block(statements=[UserTask(name="B")])), ParallelBranch(body=Block(statements=[UserTask(name="C")]))])', + 'ParallelStatement(branches=[ParallelBranch(body=Block(statements=[IfStatement(condition=VarRef(ref=->a), then=Block(statements=[UserTask(name="A")]), elseBlock=Block(statements=[UserTask(name="B")]))])), ParallelBranch(body=Block(statements=[UserTask(name="C")]))])', + ], ], [ - 'a full call activity carries its attributes and every mapping shape', + 'subprocesses, attempts, and call activities parse their bodies and mappings', `process p { - call Fulfilment( + subprocess Handle(label: "Handle order") { user Review(assignee: "demo") } + subprocess S { } + attempt A { } + attempt A for each line in lines (label: "Book and pay", asyncBefore: true) { user U } + call Fulfilment( label: "Fulfil order", process: "fulfilment-process", binding: deployment, @@ -244,23 +203,20 @@ describe('Parsing - control flow and containers', () => { out shipmentId out shipped = confirmed } + call X(process: "p") + call X { } + call C(process: "p") { in a input b = 1 } }`, - '[CallActivity(name="Fulfilment", items=[Setting(key="label", value=LiteralString(value="Fulfil order")), Setting(key="process", value=LiteralString(value="fulfilment-process")), Setting(key="binding", value=VarRef(ref=->deployment)), Setting(key="businessKey", value=RawExpr(raw="${execution.processBusinessKey}"))], mappings=[VariableMapping(direction="in", all=true), VariableMapping(direction="in", target="orderId"), VariableMapping(direction="in", target="total", source=Additive(left=VarRef(ref=->amount), op="+", right=VarRef(ref=->tax))), VariableMapping(direction="in", local=true, target="vip", source=VarRef(ref=->vipFlag)), VariableMapping(direction="out", target="shipmentId"), VariableMapping(direction="out", target="shipped", source=VarRef(ref=->confirmed))])]', - ], - [ - 'a minimal call with only `process` parses and carries no mappings', - `process p { call X(process: "p") }`, - '[CallActivity(name="X", items=[Setting(key="process", value=LiteralString(value="p"))])]', - ], - [ - "an empty call body parses; a missing `process` is the validator's concern", - `process p { call X { } }`, - '[CallActivity(name="X")]', - ], - [ - 'a call carries variable mappings and parameters together', - `process p { call C(process: "p") { in a input b = 1 } }`, - '[CallActivity(name="C", items=[Setting(key="process", value=LiteralString(value="p"))], mappings=[VariableMapping(direction="in", target="a")], params=[IoParameter(direction="input", name="b", value=LiteralInt(value=1))])]', + [ + 'SubProcess(name="Handle", items=[Setting(key="label", value=LiteralString(value="Handle order"))], body=Block(statements=[UserTask(name="Review", items=[Setting(key="assignee", value=LiteralString(value="demo"))])]))', + 'SubProcess(name="S", body=Block)', + 'SubProcess(transactional=true, name="A", body=Block)', + 'SubProcess(transactional=true, name="A", element="line", collection=VarRef(ref=->lines), items=[Setting(key="label", value=LiteralString(value="Book and pay")), Setting(key="asyncBefore", value=LiteralBool(value="true"))], body=Block(statements=[UserTask(name="U")]))', + 'CallActivity(name="Fulfilment", items=[Setting(key="label", value=LiteralString(value="Fulfil order")), Setting(key="process", value=LiteralString(value="fulfilment-process")), Setting(key="binding", value=VarRef(ref=->deployment)), Setting(key="businessKey", value=RawExpr(raw="${execution.processBusinessKey}"))], mappings=[VariableMapping(direction="in", all=true), VariableMapping(direction="in", target="orderId"), VariableMapping(direction="in", target="total", source=Additive(left=VarRef(ref=->amount), op="+", right=VarRef(ref=->tax))), VariableMapping(direction="in", local=true, target="vip", source=VarRef(ref=->vipFlag)), VariableMapping(direction="out", target="shipmentId"), VariableMapping(direction="out", target="shipped", source=VarRef(ref=->confirmed))])', + 'CallActivity(name="X", items=[Setting(key="process", value=LiteralString(value="p"))])', + 'CallActivity(name="X")', + 'CallActivity(name="C", items=[Setting(key="process", value=LiteralString(value="p"))], mappings=[VariableMapping(direction="in", target="a")], params=[IoParameter(direction="input", name="b", value=LiteralInt(value=1))])', + ], ], ])('%s', async (_title, source, expected) => { await expectBody(source, expected); @@ -268,31 +224,23 @@ describe('Parsing - control flow and containers', () => { }); describe('Parsing - gateway settings', () => { - test.each([ - [ - 'an if head carries the split settings and the join settings; an else if head carries none', - `process p { if (a) (asyncBefore: true, joinJobPriority: 20) { user A } else if (b) { user B } }`, - '[IfStatement(condition=VarRef(ref=->a), items=[Setting(key="asyncBefore", value=LiteralBool(value="true")), Setting(key="joinJobPriority", value=LiteralInt(value=20))], then=Block(statements=[UserTask(name="A")]), elseIfs=[ElseIf(condition=VarRef(ref=->b), body=Block(statements=[UserTask(name="B")]))])]', - ], - [ - 'a while head carries the loop settings after its condition', - `process p { while (a) (asyncAfter: true) { user A } }`, - '[WhileStatement(condition=VarRef(ref=->a), items=[Setting(key="asyncAfter", value=LiteralBool(value="true"))], body=Block(statements=[UserTask(name="A")]))]', - ], - [ - 'a do-while tail carries the loop settings after its condition', - `process p { do { user A } while (a) (exclusive: false) }`, - '[DoWhileStatement(body=Block(statements=[UserTask(name="A")]), condition=VarRef(ref=->a), items=[Setting(key="exclusive", value=LiteralBool(value="false"))])]', - ], + test.each([ [ - 'a parallel head carries the fork settings before its branches; a branch head carries none', - `process p { parallel (jobPriority: 5) { { user A } { user B } } }`, - '[ParallelStatement(items=[Setting(key="jobPriority", value=LiteralInt(value=5))], branches=[ParallelBranch(body=Block(statements=[UserTask(name="A")])), ParallelBranch(body=Block(statements=[UserTask(name="B")]))])]', - ], - [ - "an await head carries the race settings before its branches, apart from each branch's own", - `process p { await (retryCycle: "R3/PT10M") { message("M") { user A } timer("PT1H") { user B } } }`, - '[RaceStatement(items=[Setting(key="retryCycle", value=LiteralString(value="R3/PT10M"))], branches=[RaceBranch(trigger="message", items=[ParenValue(value=LiteralString(value="M"))], body=Block(statements=[UserTask(name="A")])), RaceBranch(trigger="timer", items=[ParenValue(value=LiteralString(value="PT1H"))], body=Block(statements=[UserTask(name="B")]))])]', + 'each control-flow head parses its own split, join, fork, or race settings', + `process p { + if (a) (asyncBefore: true, joinJobPriority: 20) { user A } else if (b) { user B } + while (a) (asyncAfter: true) { user A } + do { user A } while (a) (exclusive: false) + parallel (jobPriority: 5) { { user A } { user B } } + await (retryCycle: "R3/PT10M") { message("M") { user A } timer("PT1H") { user B } } +}`, + [ + 'IfStatement(condition=VarRef(ref=->a), items=[Setting(key="asyncBefore", value=LiteralBool(value="true")), Setting(key="joinJobPriority", value=LiteralInt(value=20))], then=Block(statements=[UserTask(name="A")]), elseIfs=[ElseIf(condition=VarRef(ref=->b), body=Block(statements=[UserTask(name="B")]))])', + 'WhileStatement(condition=VarRef(ref=->a), items=[Setting(key="asyncAfter", value=LiteralBool(value="true"))], body=Block(statements=[UserTask(name="A")]))', + 'DoWhileStatement(body=Block(statements=[UserTask(name="A")]), condition=VarRef(ref=->a), items=[Setting(key="exclusive", value=LiteralBool(value="false"))])', + 'ParallelStatement(items=[Setting(key="jobPriority", value=LiteralInt(value=5))], branches=[ParallelBranch(body=Block(statements=[UserTask(name="A")])), ParallelBranch(body=Block(statements=[UserTask(name="B")]))])', + 'RaceStatement(items=[Setting(key="retryCycle", value=LiteralString(value="R3/PT10M"))], branches=[RaceBranch(trigger="message", items=[ParenValue(value=LiteralString(value="M"))], body=Block(statements=[UserTask(name="A")])), RaceBranch(trigger="timer", items=[ParenValue(value=LiteralString(value="PT1H"))], body=Block(statements=[UserTask(name="B")]))])', + ], ], ])('%s', async (_title, source, expected) => { await expectBody(source, expected); @@ -300,89 +248,52 @@ describe('Parsing - gateway settings', () => { }); describe('Parsing - the event layer', () => { - test.each([ + test.each([ [ - 'a full interrupting handler carries a trigger, a code, two bindings, and a body', + 'event handlers parse their trigger, code, bindings, and condition correctly', `process p { - on error(PAYMENT_FAILED, code: c, message: m) { service R(class: "x.Y") } + on error(PAYMENT_FAILED, code: c, message: m) { service R(class: "x.Y") } + on message("PaymentReceived") { user Review(assignee: "demo") } + on signal("Cancelled", alongside) { } + on escalation(X, code: v, alongside) { } + on error { } + on escalation { } + on timer(every: "R/PT10M", alongside) { } + on condition(amount > 100) { } }`, - '[OnHandler(trigger="error", items=[ParenValue(value=VarRef(ref=->PAYMENT_FAILED)), Setting(key="code", value=VarRef(ref=->c)), Setting(key="message", value=VarRef(ref=->m))], body=Block(statements=[ServiceTask(name="R", items=[Setting(key="class", value=LiteralString(value="x.Y"))])]))]', - ], - [ - 'a message handler takes a name and no particle, condition, or bindings', - `process p { on message("PaymentReceived") { user Review(assignee: "demo") } }`, - '[OnHandler(trigger="message", items=[ParenValue(value=LiteralString(value="PaymentReceived"))], body=Block(statements=[UserTask(name="Review", items=[Setting(key="assignee", value=LiteralString(value="demo"))])]))]', - ], - [ - 'alongside marks a signal handler as non-interrupting', - `process p { on signal("Cancelled", alongside) { } }`, - '[OnHandler(trigger="signal", items=[ParenValue(value=LiteralString(value="Cancelled")), Flag(flag="alongside")], body=Block)]', - ], - [ - 'alongside follows a binding list on an escalation handler', - `process p { on escalation(X, code: v, alongside) { } }`, - '[OnHandler(trigger="escalation", items=[ParenValue(value=VarRef(ref=->X)), Setting(key="code", value=VarRef(ref=->v)), Flag(flag="alongside")], body=Block)]', - ], - [ - 'a catch-all error handler omits the code', - `process p { on error { } }`, - '[OnHandler(trigger="error", body=Block)]', - ], - [ - 'a catch-all escalation handler omits the code', - `process p { on escalation { } }`, - '[OnHandler(trigger="escalation", body=Block)]', - ], - [ - 'a timer names its date or cycle by key, with the flag after it', - `process p { on timer(every: "R/PT10M", alongside) { } }`, - '[OnHandler(trigger="timer", items=[Setting(key="every", value=LiteralString(value="R/PT10M")), Flag(flag="alongside")], body=Block)]', - ], - [ - 'a relational condition handler parses its parens to an expression, not bindings', - `process p { on condition(amount > 100) { } }`, - '[OnHandler(trigger="condition", items=[ParenValue(value=Relational(left=VarRef(ref=->amount), op=">", right=LiteralInt(value=100)))], body=Block)]', - ], - [ - 'a lone identifier in the parens is a condition, not a binding attempt', - `process p { on condition(approved) { } }`, - '[OnHandler(trigger="condition", items=[ParenValue(value=VarRef(ref=->approved))], body=Block)]', - ], - [ - 'a quoted raw template in the parens is a RawExpr condition', - `process p { on condition("\${bean.check()}") { } }`, - '[OnHandler(trigger="condition", items=[ParenValue(value=RawExpr(raw="${bean.check()}"))], body=Block)]', - ], - [ - 'alongside is legal on a condition handler', - `process p { on condition(amount > limit, alongside) { } }`, - '[OnHandler(trigger="condition", items=[ParenValue(value=Relational(left=VarRef(ref=->amount), op=">", right=VarRef(ref=->limit))), Flag(flag="alongside")], body=Block)]', - ], - [ - 'a host-less handler leaves the host slot empty', - `process p { on error(X) { } }`, - '[OnHandler(trigger="error", items=[ParenValue(value=VarRef(ref=->X))], body=Block)]', + [ + 'OnHandler(trigger="error", items=[ParenValue(value=VarRef(ref=->PAYMENT_FAILED)), Setting(key="code", value=VarRef(ref=->c)), Setting(key="message", value=VarRef(ref=->m))], body=Block(statements=[ServiceTask(name="R", items=[Setting(key="class", value=LiteralString(value="x.Y"))])]))', + 'OnHandler(trigger="message", items=[ParenValue(value=LiteralString(value="PaymentReceived"))], body=Block(statements=[UserTask(name="Review", items=[Setting(key="assignee", value=LiteralString(value="demo"))])]))', + 'OnHandler(trigger="signal", items=[ParenValue(value=LiteralString(value="Cancelled")), Flag(flag="alongside")], body=Block)', + 'OnHandler(trigger="escalation", items=[ParenValue(value=VarRef(ref=->X)), Setting(key="code", value=VarRef(ref=->v)), Flag(flag="alongside")], body=Block)', + 'OnHandler(trigger="error", body=Block)', + 'OnHandler(trigger="escalation", body=Block)', + 'OnHandler(trigger="timer", items=[Setting(key="every", value=LiteralString(value="R/PT10M")), Flag(flag="alongside")], body=Block)', + 'OnHandler(trigger="condition", items=[ParenValue(value=Relational(left=VarRef(ref=->amount), op=">", right=LiteralInt(value=100)))], body=Block)', + ], ], [ - 'a hosted handler carries a code, bindings and a body as a host-less one does', + 'a condition handler parses its parenthesized expression apart from bindings', `process p { - user Pack + on condition(approved) { } + on condition("\${bean.check()}") { } + on condition(amount > limit, alongside) { } + on error(X) { } + user Pack on Pack: error(OUT_OF_STOCK, code: c, message: m) { service R(class: "x.Y") } + user Review on Review: condition(amount > 100, alongside) { } + user Review on Review: message("Cancelled", alongside) { } }`, - '[UserTask(name="Pack"), OnHandler(host=->Pack, trigger="error", items=[ParenValue(value=VarRef(ref=->OUT_OF_STOCK)), Setting(key="code", value=VarRef(ref=->c)), Setting(key="message", value=VarRef(ref=->m))], body=Block(statements=[ServiceTask(name="R", items=[Setting(key="class", value=LiteralString(value="x.Y"))])]))]', - ], - [ - 'a hosted handler takes a parenthesized condition, and alongside after it', - `process p { user Review on Review: condition(amount > 100, alongside) { } }`, - '[UserTask(name="Review"), OnHandler(host=->Review, trigger="condition", items=[ParenValue(value=Relational(left=VarRef(ref=->amount), op=">", right=LiteralInt(value=100))), Flag(flag="alongside")], body=Block)]', - ], - [ - 'alongside is legal on a hosted handler with a code', - `process p { user Review on Review: message("Cancelled", alongside) { } }`, - '[UserTask(name="Review"), OnHandler(host=->Review, trigger="message", items=[ParenValue(value=LiteralString(value="Cancelled")), Flag(flag="alongside")], body=Block)]', + [ + 'OnHandler(trigger="condition", items=[ParenValue(value=VarRef(ref=->approved))], body=Block)', + 'OnHandler(trigger="condition", items=[ParenValue(value=RawExpr(raw="${bean.check()}"))], body=Block)', + 'OnHandler(trigger="condition", items=[ParenValue(value=Relational(left=VarRef(ref=->amount), op=">", right=VarRef(ref=->limit))), Flag(flag="alongside")], body=Block)', + 'OnHandler(trigger="error", items=[ParenValue(value=VarRef(ref=->X))], body=Block)', + 'UserTask(name="Pack"), OnHandler(host=->Pack, trigger="error", items=[ParenValue(value=VarRef(ref=->OUT_OF_STOCK)), Setting(key="code", value=VarRef(ref=->c)), Setting(key="message", value=VarRef(ref=->m))], body=Block(statements=[ServiceTask(name="R", items=[Setting(key="class", value=LiteralString(value="x.Y"))])]))', + 'UserTask(name="Review"), OnHandler(host=->Review, trigger="condition", items=[ParenValue(value=Relational(left=VarRef(ref=->amount), op=">", right=LiteralInt(value=100))), Flag(flag="alongside")], body=Block)', + 'UserTask(name="Review"), OnHandler(host=->Review, trigger="message", items=[ParenValue(value=LiteralString(value="Cancelled")), Flag(flag="alongside")], body=Block)', + ], ], - // The host and the trigger are both bare IDs, so every trigger word is - // pinned on its own. ...( [ 'error', @@ -399,128 +310,75 @@ describe('Parsing - the event layer', () => { `[OnHandler(host=->Review, trigger="${trigger}", body=Block)]`, ]), [ - 'throw is a terminal statement carrying its code in the parens', - `process p { throw error("C") }`, - '[ThrowStatement(trigger="error", items=[ParenValue(value=LiteralString(value="C"))])]', - ], - [ - 'an explicit id sits between the trigger and the parens on a throw', - `process p { throw error Failed("C") }`, - '[ThrowStatement(trigger="error", name="Failed", items=[ParenValue(value=LiteralString(value="C"))])]', - ], - [ - "emit takes the same header; the impossible verb/kind pair is the validator's job", - `process p { emit error Ping("C") }`, - '[EmitStatement(trigger="error", name="Ping", items=[ParenValue(value=LiteralString(value="C"))])]', - ], - [ - 'a bare `throw compensation` carries no name and no code', - `process p { throw compensation }`, - '[ThrowStatement(trigger="compensation")]', - ], - [ - 'a bare `emit compensation` carries no name and no code', - `process p { emit compensation }`, - '[EmitStatement(trigger="compensation")]', - ], - [ - 'a named `throw compensation` carries a name but no code', - `process p { throw compensation Undo }`, - '[ThrowStatement(trigger="compensation", name="Undo")]', - ], - [ - 'a named `emit compensation` carries a name but no code', - `process p { emit compensation Ping }`, - '[EmitStatement(trigger="compensation", name="Ping")]', - ], - [ - 'a code-less throw parses for a trigger that normally takes one', - `process p { throw error }`, - '[ThrowStatement(trigger="error")]', - ], - [ - 'a code-less emit parses for a trigger that normally takes one', - `process p { emit signal }`, - '[EmitStatement(trigger="signal")]', - ], - [ - "an unknown trigger word parses; word legality is the validator's job", - `process p { throw banana }`, - '[ThrowStatement(trigger="banana")]', - ], - [ - 'a statement following a code-less throw is not swallowed as its name', - `process p { throw compensation service R(class: "x.Y") }`, - '[ThrowStatement(trigger="compensation"), ServiceTask(name="R", items=[Setting(key="class", value=LiteralString(value="x.Y"))])]', - ], - [ - '`on compensation` parses with no code, particle, bindings or condition', - `process p { on compensation { } }`, - '[OnHandler(trigger="compensation", body=Block)]', - ], - [ - '`on compensation("X")` parses; the validator rejects it later', - `process p { on compensation("X") { } }`, - '[OnHandler(trigger="compensation", items=[ParenValue(value=LiteralString(value="X"))], body=Block)]', - ], - [ - '`on compensation(alongside)` parses; the validator rejects it later', - `process p { on compensation(alongside) { } }`, - '[OnHandler(trigger="compensation", items=[Flag(flag="alongside")], body=Block)]', - ], - [ - '`await message` takes a required name string', - `process p { await message("Invoice Received") }`, - '[IntermediateCatchEvent(trigger="message", items=[ParenValue(value=LiteralString(value="Invoice Received"))])]', - ], - [ - '`await signal` takes a required name string', - `process p { await signal("Ready") }`, - '[IntermediateCatchEvent(trigger="signal", items=[ParenValue(value=LiteralString(value="Ready"))])]', - ], - [ - '`await timer` names its date by key, and the key is never read as a name', - `process p { await timer(at: "2026-08-01T09:00:00") }`, - '[IntermediateCatchEvent(trigger="timer", items=[Setting(key="at", value=LiteralString(value="2026-08-01T09:00:00"))])]', - ], - [ - '`await condition` parses the same expression AST an if condition does', - `process p { await condition(amount > 100) }`, - '[IntermediateCatchEvent(trigger="condition", items=[ParenValue(value=Relational(left=VarRef(ref=->amount), op=">", right=LiteralInt(value=100)))])]', - ], - [ - '`await` takes an optional name between the trigger and the payload, as `emit` does', - `process p { await message Named("M") user U }`, - '[IntermediateCatchEvent(trigger="message", name="Named", items=[ParenValue(value=LiteralString(value="M"))]), UserTask(name="U")]', - ], - [ - 'a race carries one trigger header per branch and does not swallow what follows', - `process p { await { message("M") { service S } timer("P3D") { user U } } user W }`, - '[RaceStatement(branches=[RaceBranch(trigger="message", items=[ParenValue(value=LiteralString(value="M"))], body=Block(statements=[ServiceTask(name="S")])), RaceBranch(trigger="timer", items=[ParenValue(value=LiteralString(value="P3D"))], body=Block(statements=[UserTask(name="U")]))]), UserTask(name="W")]', - ], - [ - 'the first brace after a branch header is a member block only when a second one follows', - `process p { await { signal("S") { form { } } { service P } message("M") { } condition (x) { end E } } }`, - '[RaceStatement(branches=[RaceBranch(trigger="signal", items=[ParenValue(value=LiteralString(value="S"))], forms=[FormBlock], body=Block(statements=[ServiceTask(name="P")])), RaceBranch(trigger="message", items=[ParenValue(value=LiteralString(value="M"))], body=Block), RaceBranch(trigger="condition", items=[ParenValue(value=VarRef(ref=->x))], body=Block(statements=[EndEvent(name="E")]))])]', + 'throw and emit statements parse their trigger, name, and code correctly', + `process p { + throw error("C") + throw error Failed("C") + emit error Ping("C") + throw compensation + emit compensation + throw compensation Undo + emit compensation Ping + throw error +}`, + [ + 'ThrowStatement(trigger="error", items=[ParenValue(value=LiteralString(value="C"))])', + 'ThrowStatement(trigger="error", name="Failed", items=[ParenValue(value=LiteralString(value="C"))])', + 'EmitStatement(trigger="error", name="Ping", items=[ParenValue(value=LiteralString(value="C"))])', + 'ThrowStatement(trigger="compensation")', + 'EmitStatement(trigger="compensation")', + 'ThrowStatement(trigger="compensation", name="Undo")', + 'EmitStatement(trigger="compensation", name="Ping")', + 'ThrowStatement(trigger="error")', + ], ], [ - 'an end event takes a terminate word and a label setting, and no code', - `process p { end E terminate(label: "All stop") }`, - '[EndEvent(name="E", trigger="terminate", items=[Setting(key="label", value=LiteralString(value="All stop"))])]', + 'unknown trigger words and code-less triggers still parse, validation aside', + `process p { + emit signal + throw banana + throw compensation service R(class: "x.Y") + on compensation { } + on compensation("X") { } + on compensation(alongside) { } + await message("Invoice Received") + await signal("Ready") +}`, + [ + 'EmitStatement(trigger="signal")', + 'ThrowStatement(trigger="banana")', + 'ThrowStatement(trigger="compensation"), ServiceTask(name="R", items=[Setting(key="class", value=LiteralString(value="x.Y"))])', + 'OnHandler(trigger="compensation", body=Block)', + 'OnHandler(trigger="compensation", items=[ParenValue(value=LiteralString(value="X"))], body=Block)', + 'OnHandler(trigger="compensation", items=[Flag(flag="alongside")], body=Block)', + 'IntermediateCatchEvent(trigger="message", items=[ParenValue(value=LiteralString(value="Invoice Received"))])', + 'IntermediateCatchEvent(trigger="signal", items=[ParenValue(value=LiteralString(value="Ready"))])', + ], ], [ - 'a triggered start does not swallow the statement following it', + 'await, race, and end statements parse their headers without swallowing what follows', `process p { - start S timer("PT1H") + await timer(at: "2026-08-01T09:00:00") + await condition(amount > 100) + await message Named("M") user U + await { message("M") { service S } timer("P3D") { user U } } user W + await { signal("S") { form { } } { service P } message("M") { } condition (x) { end E } } + end E terminate(label: "All stop") + start S timer("PT1H") user U end E terminate + on banana("X") { } }`, - '[StartEvent(name="S", trigger="timer", items=[ParenValue(value=LiteralString(value="PT1H"))]), UserTask(name="U"), EndEvent(name="E", trigger="terminate")]', - ], - [ - "an unknown trigger word on a handler parses; legality is the validator's job", - `process p { on banana("X") { } }`, - '[OnHandler(trigger="banana", items=[ParenValue(value=LiteralString(value="X"))], body=Block)]', + [ + 'IntermediateCatchEvent(trigger="timer", items=[Setting(key="at", value=LiteralString(value="2026-08-01T09:00:00"))])', + 'IntermediateCatchEvent(trigger="condition", items=[ParenValue(value=Relational(left=VarRef(ref=->amount), op=">", right=LiteralInt(value=100)))])', + 'IntermediateCatchEvent(trigger="message", name="Named", items=[ParenValue(value=LiteralString(value="M"))]), UserTask(name="U")', + 'RaceStatement(branches=[RaceBranch(trigger="message", items=[ParenValue(value=LiteralString(value="M"))], body=Block(statements=[ServiceTask(name="S")])), RaceBranch(trigger="timer", items=[ParenValue(value=LiteralString(value="P3D"))], body=Block(statements=[UserTask(name="U")]))]), UserTask(name="W")', + 'RaceStatement(branches=[RaceBranch(trigger="signal", items=[ParenValue(value=LiteralString(value="S"))], forms=[FormBlock], body=Block(statements=[ServiceTask(name="P")])), RaceBranch(trigger="message", items=[ParenValue(value=LiteralString(value="M"))], body=Block), RaceBranch(trigger="condition", items=[ParenValue(value=VarRef(ref=->x))], body=Block(statements=[EndEvent(name="E")]))])', + 'EndEvent(name="E", trigger="terminate", items=[Setting(key="label", value=LiteralString(value="All stop"))])', + 'StartEvent(name="S", trigger="timer", items=[ParenValue(value=LiteralString(value="PT1H"))]), UserTask(name="U"), EndEvent(name="E", trigger="terminate")', + 'OnHandler(trigger="banana", items=[ParenValue(value=LiteralString(value="X"))], body=Block)', + ], ], ])('%s', async (_title, source, expected) => { await expectBody(source, expected); @@ -647,9 +505,8 @@ describe('Parsing - expressions', () => { expect(shape(await parseCondition(expr))).toBe(expected); }); - // A raw template at the top prints as read; under an operator a single - // template is spliced in as a parenthesised operand and a composite is left - // as written for the validator. + // Under an operator a single template is spliced in parenthesised; a + // composite is left as written for the validator. test.each([ ['amount > 1000', '${amount > 1000}'], ['a == null', '${a == null}'], @@ -681,151 +538,88 @@ describe('Parsing - expressions', () => { }); describe('Parsing - element settings and member blocks', () => { - test.each([ - [ - 'a service class written as a dotted bareword is a VarRef, not a string', - `process p { service A(class: com.example.invoice.AutoApproveDelegate) }`, - '[ServiceTask(name="A", items=[Setting(key="class", value=VarRef(ref=->com, accessors=[Accessor(prop="example"), Accessor(prop="invoice"), Accessor(prop="AutoApproveDelegate")]))])]', - ], - [ - 'duplicate attribute keys stay visible as two AST attribute nodes', - `process p { user T(assignee: "a", assignee: "b") }`, - '[UserTask(name="T", items=[Setting(key="assignee", value=LiteralString(value="a")), Setting(key="assignee", value=LiteralString(value="b"))])]', - ], + test.each([ [ - 'a task with no parens and no block carries neither settings nor members', - `process p { user T }`, - '[UserTask(name="T")]', - ], - [ - 'a start event and a user task each accept a form block', + 'element settings, form fields, and attribute keys parse leniently, validation aside', `process p { - start Begin { form { amount: number "Amount" = 0 } } + service A(class: com.example.invoice.AutoApproveDelegate) + user T(assignee: "a", assignee: "b") + user T + start Begin { form { amount: number "Amount" = 0 } } user Approve(assignee: "demo") { form { ok: boolean "OK?" } } + start S { form { plan: enum "Plan" = "basic" (required: true, minlength: 2) { basic "Basic" plus property description = "Sets the fee" } } } + start S { form { plan: enum { family property x = "y" property "Property" } } } + start S { form { blob: whatever "Blob" } } + user T(wibble: 1) }`, - '[StartEvent(name="Begin", forms=[FormBlock(fields=[FormField(id="amount", type="number", label="Amount", defaultValue=LiteralInt(value=0))])]), UserTask(name="Approve", items=[Setting(key="assignee", value=LiteralString(value="demo"))], forms=[FormBlock(fields=[FormField(id="ok", type="boolean", label="OK?")])])]', - ], - [ - 'a form field takes a default, settings parens, enum values and property lines', - `process p { start S { form { plan: enum "Plan" = "basic" (required: true, minlength: 2) { basic "Basic" plus property description = "Sets the fee" } } } }`, - '[StartEvent(name="S", forms=[FormBlock(fields=[FormField(id="plan", type="enum", label="Plan", defaultValue=LiteralString(value="basic"), items=[Setting(key="required", value=LiteralBool(value="true")), Setting(key="minlength", value=LiteralInt(value=2))], values=[EnumValue(id="basic", label="Basic"), EnumValue(id="plus")], params=[IoParameter(direction="property", name="description", value=LiteralString(value="Sets the fee"))])])])]', - ], - [ - 'a bare enum value before a property line, and one named `property`, are told apart', - `process p { start S { form { plan: enum { family property x = "y" property "Property" } } } }`, - '[StartEvent(name="S", forms=[FormBlock(fields=[FormField(id="plan", type="enum", values=[EnumValue(id="family"), EnumValue(id="property", label="Property")], params=[IoParameter(direction="property", name="x", value=LiteralString(value="y"))])])])]', - ], - [ - "any word parses in a form field's type slot; which words are types is the validator's job", - `process p { start S { form { blob: whatever "Blob" } } }`, - '[StartEvent(name="S", forms=[FormBlock(fields=[FormField(id="blob", type="whatever", label="Blob")])])]', - ], - [ - "an unknown attribute key parses; key legality is the validator's job", - `process p { user T(wibble: 1) }`, - '[UserTask(name="T", items=[Setting(key="wibble", value=LiteralInt(value=1))])]', - ], - [ - 'a bare `binding: version` parses as a value reference', - `process p { call C(process: "x", binding: version) }`, - '[CallActivity(name="C", items=[Setting(key="process", value=LiteralString(value="x")), Setting(key="binding", value=VarRef(ref=->version))])]', + [ + 'ServiceTask(name="A", items=[Setting(key="class", value=VarRef(ref=->com, accessors=[Accessor(prop="example"), Accessor(prop="invoice"), Accessor(prop="AutoApproveDelegate")]))])', + 'UserTask(name="T", items=[Setting(key="assignee", value=LiteralString(value="a")), Setting(key="assignee", value=LiteralString(value="b"))])', + 'UserTask(name="T")', + 'StartEvent(name="Begin", forms=[FormBlock(fields=[FormField(id="amount", type="number", label="Amount", defaultValue=LiteralInt(value=0))])]), UserTask(name="Approve", items=[Setting(key="assignee", value=LiteralString(value="demo"))], forms=[FormBlock(fields=[FormField(id="ok", type="boolean", label="OK?")])])', + 'StartEvent(name="S", forms=[FormBlock(fields=[FormField(id="plan", type="enum", label="Plan", defaultValue=LiteralString(value="basic"), items=[Setting(key="required", value=LiteralBool(value="true")), Setting(key="minlength", value=LiteralInt(value=2))], values=[EnumValue(id="basic", label="Basic"), EnumValue(id="plus")], params=[IoParameter(direction="property", name="description", value=LiteralString(value="Sets the fee"))])])])', + 'StartEvent(name="S", forms=[FormBlock(fields=[FormField(id="plan", type="enum", values=[EnumValue(id="family"), EnumValue(id="property", label="Property")], params=[IoParameter(direction="property", name="x", value=LiteralString(value="y"))])])])', + 'StartEvent(name="S", forms=[FormBlock(fields=[FormField(id="blob", type="whatever", label="Blob")])])', + 'UserTask(name="T", items=[Setting(key="wibble", value=LiteralInt(value=1))])', + ], ], [ - 'throw, emit and await each carry trailing settings', + 'settings values, including lists and maps, parse and nest correctly', `process p { - throw error("X", asyncBefore: true) + call C(process: "x", binding: version) + throw error("X", asyncBefore: true) emit signal Sig(asyncAfter: true) await message("M", exclusive: false) + emit signal Sig subprocess S { user A } + await message subprocess S { user A } + service S { input a = [1, 2] } + service S { input a = { k: 1 } } + service S { input a = [{ k: 1 }, { k: [2, 3] }] } + service S { input a = [] output b = { } } }`, - '[ThrowStatement(trigger="error", items=[ParenValue(value=LiteralString(value="X")), Setting(key="asyncBefore", value=LiteralBool(value="true"))]), EmitStatement(trigger="signal", name="Sig", items=[Setting(key="asyncAfter", value=LiteralBool(value="true"))]), IntermediateCatchEvent(trigger="message", items=[ParenValue(value=LiteralString(value="M")), Setting(key="exclusive", value=LiteralBool(value="false"))])]', - ], - [ - 'a statement following an emit with a trailing block still parses', - `process p { emit signal Sig subprocess S { user A } }`, - '[EmitStatement(trigger="signal", name="Sig"), SubProcess(name="S", body=Block(statements=[UserTask(name="A")]))]', - ], - [ - 'a statement following a bare await still parses', - `process p { await message subprocess S { user A } }`, - '[IntermediateCatchEvent(trigger="message"), SubProcess(name="S", body=Block(statements=[UserTask(name="A")]))]', - ], - [ - 'a list value keeps its items in order', - `process p { service S { input a = [1, 2] } }`, - '[ServiceTask(name="S", params=[IoParameter(direction="input", name="a", value=ListLiteral(items=[LiteralInt(value=1), LiteralInt(value=2)]))])]', - ], - [ - 'a map value keeps its keyed entries', - `process p { service S { input a = { k: 1 } } }`, - '[ServiceTask(name="S", params=[IoParameter(direction="input", name="a", value=MapLiteral(entries=[MapEntry(key="k", value=LiteralInt(value=1))]))])]', - ], - [ - 'lists and maps nest inside one another', - `process p { service S { input a = [{ k: 1 }, { k: [2, 3] }] } }`, - '[ServiceTask(name="S", params=[IoParameter(direction="input", name="a", value=ListLiteral(items=[MapLiteral(entries=[MapEntry(key="k", value=LiteralInt(value=1))]), MapLiteral(entries=[MapEntry(key="k", value=ListLiteral(items=[LiteralInt(value=2), LiteralInt(value=3)]))])]))])]', - ], - [ - 'an empty list and an empty map parse', - `process p { service S { input a = [] output b = { } } }`, - '[ServiceTask(name="S", params=[IoParameter(direction="input", name="a", value=ListLiteral), IoParameter(direction="output", name="b", value=MapLiteral)])]', - ], - [ - 'a quoted map key carries its bare text', - `process p { service S { input a = { "k-1": 1 } } }`, - '[ServiceTask(name="S", params=[IoParameter(direction="input", name="a", value=MapLiteral(entries=[MapEntry(key="k-1", value=LiteralInt(value=1))]))])]', + [ + 'CallActivity(name="C", items=[Setting(key="process", value=LiteralString(value="x")), Setting(key="binding", value=VarRef(ref=->version))])', + 'ThrowStatement(trigger="error", items=[ParenValue(value=LiteralString(value="X")), Setting(key="asyncBefore", value=LiteralBool(value="true"))]), EmitStatement(trigger="signal", name="Sig", items=[Setting(key="asyncAfter", value=LiteralBool(value="true"))]), IntermediateCatchEvent(trigger="message", items=[ParenValue(value=LiteralString(value="M")), Setting(key="exclusive", value=LiteralBool(value="false"))])', + 'EmitStatement(trigger="signal", name="Sig"), SubProcess(name="S", body=Block(statements=[UserTask(name="A")]))', + 'IntermediateCatchEvent(trigger="message"), SubProcess(name="S", body=Block(statements=[UserTask(name="A")]))', + 'ServiceTask(name="S", params=[IoParameter(direction="input", name="a", value=ListLiteral(items=[LiteralInt(value=1), LiteralInt(value=2)]))])', + 'ServiceTask(name="S", params=[IoParameter(direction="input", name="a", value=MapLiteral(entries=[MapEntry(key="k", value=LiteralInt(value=1))]))])', + 'ServiceTask(name="S", params=[IoParameter(direction="input", name="a", value=ListLiteral(items=[MapLiteral(entries=[MapEntry(key="k", value=LiteralInt(value=1))]), MapLiteral(entries=[MapEntry(key="k", value=ListLiteral(items=[LiteralInt(value=2), LiteralInt(value=3)]))])]))])', + 'ServiceTask(name="S", params=[IoParameter(direction="input", name="a", value=ListLiteral), IoParameter(direction="output", name="b", value=MapLiteral)])', + ], ], [ - 'a fenced script is a parameter value', + 'listeners and their settings parse in every block and binding shape', `process p { - service S { input a = ${FENCE}groovy + service S { input a = { "k-1": 1 } } + service S { input a = ${FENCE}groovy 1 + 1 ${FENCE} } -}`, - '[ServiceTask(name="S", params=[IoParameter(direction="input", name="a", value=ScriptLiteral(body="```groovy\\n1 + 1\\n```"))])]', - ], - [ - 'the start and end statement keywords are usable as listener events', - `process p { service S { on start(class: "A") on end(expression: "\${b.m()}") } }`, - '[ServiceTask(name="S", listeners=[Listener(event="start", items=[Setting(key="class", value=LiteralString(value="A"))]), Listener(event="end", items=[Setting(key="expression", value=RawExpr(raw="${b.m()}"))])])]', - ], - [ - 'a timeout listener carries a timer particle and its time', - `process p { user T { on timeout after "PT1H" (class: "X") } }`, - '[UserTask(name="T", listeners=[Listener(event="timeout", particle="after", time="PT1H", items=[Setting(key="class", value=LiteralString(value="X"))])])]', - ], - [ - 'a listener binds by an inline fenced script instead of a block', - `process p { - user T { on end ${FENCE}groovy + service S { on start(class: "A") on end(expression: "\${b.m()}") } + user T { on timeout after "PT1H" (class: "X") } + user T { on end ${FENCE}groovy execution.setVariable("x", 1) ${FENCE} } -}`, - '[UserTask(name="T", listeners=[Listener(event="end", script="```groovy\\nexecution.setVariable(\\"x\\", 1)\\n```")])]', - ], - [ - 'a listener carries its own member block, and one bound by a script carries none', - `process p { - service S { + service S { on start(class: "com.acme.L") { field greeting = "hello" } on end ${FENCE}groovy execution.setVariable("x", 1) ${FENCE} } -}`, - '[ServiceTask(name="S", listeners=[Listener(event="start", items=[Setting(key="class", value=LiteralString(value="com.acme.L"))], params=[IoParameter(direction="field", name="greeting", value=LiteralString(value="hello"))]), Listener(event="end", script="```groovy\\nexecution.setVariable(\\"x\\", 1)\\n```")])]', - ], - [ - 'a listener sits in the member block before a subprocess body', - `process p { subprocess S { on start(class: "X") } { user U } }`, - '[SubProcess(name="S", listeners=[Listener(event="start", items=[Setting(key="class", value=LiteralString(value="X"))])], body=Block(statements=[UserTask(name="U")]))]', - ], - [ - 'a listener rides a call activity and an end event', - `process p { - call C(process: "q") { on start(class: "X") } + subprocess S { on start(class: "X") } { user U } + call C(process: "q") { on start(class: "X") } end E { on end(delegate: "\${bean}") } }`, - '[CallActivity(name="C", items=[Setting(key="process", value=LiteralString(value="q"))], listeners=[Listener(event="start", items=[Setting(key="class", value=LiteralString(value="X"))])]), EndEvent(name="E", listeners=[Listener(event="end", items=[Setting(key="delegate", value=RawExpr(raw="${bean}"))])])]', + [ + 'ServiceTask(name="S", params=[IoParameter(direction="input", name="a", value=MapLiteral(entries=[MapEntry(key="k-1", value=LiteralInt(value=1))]))])', + 'ServiceTask(name="S", params=[IoParameter(direction="input", name="a", value=ScriptLiteral(body="```groovy\\n1 + 1\\n```"))])', + 'ServiceTask(name="S", listeners=[Listener(event="start", items=[Setting(key="class", value=LiteralString(value="A"))]), Listener(event="end", items=[Setting(key="expression", value=RawExpr(raw="${b.m()}"))])])', + 'UserTask(name="T", listeners=[Listener(event="timeout", particle="after", time="PT1H", items=[Setting(key="class", value=LiteralString(value="X"))])])', + 'UserTask(name="T", listeners=[Listener(event="end", script="```groovy\\nexecution.setVariable(\\"x\\", 1)\\n```")])', + 'ServiceTask(name="S", listeners=[Listener(event="start", items=[Setting(key="class", value=LiteralString(value="com.acme.L"))], params=[IoParameter(direction="field", name="greeting", value=LiteralString(value="hello"))]), Listener(event="end", script="```groovy\\nexecution.setVariable(\\"x\\", 1)\\n```")])', + 'SubProcess(name="S", listeners=[Listener(event="start", items=[Setting(key="class", value=LiteralString(value="X"))])], body=Block(statements=[UserTask(name="U")]))', + 'CallActivity(name="C", items=[Setting(key="process", value=LiteralString(value="q"))], listeners=[Listener(event="start", items=[Setting(key="class", value=LiteralString(value="X"))])]), EndEvent(name="E", listeners=[Listener(event="end", items=[Setting(key="delegate", value=RawExpr(raw="${bean}"))])])', + ], ], [ 'settings, a form block, parameters and listeners mix on one element', @@ -890,45 +684,34 @@ describe('Parsing - task kinds, service bindings and fenced scripts', () => { ['decide', 'BusinessRuleTask'], ] as const; - test.each([ + test.each([ ...KINDS.map(([keyword, type]): Row => [ `\`${keyword}\` after \`start S\` opens its own statement rather than filling the start's trigger slot`, `process p { start S ${keyword} X user U end E }`, `[StartEvent(name="S"), ${type}(name="X"), UserTask(name="U"), EndEvent(name="E")]`, ]), [ - 'a topic binding parses without a label', - `process p { service ship(topic: "shipping") }`, - '[ServiceTask(name="ship", items=[Setting(key="topic", value=LiteralString(value="shipping"))])]', - ], - [ - 'a fenced script task captures the whole fence, tag and all', + 'fenced scripts are captured verbatim alongside other binding shapes', `process p { - script total ${FENCE}js + service ship(topic: "shipping") + script total ${FENCE}js x = 1 ${FENCE} -}`, - '[ScriptTask(name="total", body="```js\\nx = 1\\n```")]', - ], - [ - 'a fence coexists with a class bareword and a raw template without lex ambiguity', - `process p { - service Auto(class: com.acme.X) + service Auto(class: com.acme.X) user Review(assignee: "\${bean.pick()}") script total ${FENCE}js y = 2 ${FENCE} -}`, - '[ServiceTask(name="Auto", items=[Setting(key="class", value=VarRef(ref=->com, accessors=[Accessor(prop="acme"), Accessor(prop="X")]))]), UserTask(name="Review", items=[Setting(key="assignee", value=RawExpr(raw="${bean.pick()}"))]), ScriptTask(name="total", body="```js\\ny = 2\\n```")]', - ], - [ - 'DSL-looking text inside a fence is captured, not parsed as DSL', - `process p { - script guard ${FENCE}js + script guard ${FENCE}js if (a) { } ${FENCE} }`, - '[ScriptTask(name="guard", body="```js\\nif (a) { }\\n```")]', + [ + 'ServiceTask(name="ship", items=[Setting(key="topic", value=LiteralString(value="shipping"))])', + 'ScriptTask(name="total", body="```js\\nx = 1\\n```")', + 'ServiceTask(name="Auto", items=[Setting(key="class", value=VarRef(ref=->com, accessors=[Accessor(prop="acme"), Accessor(prop="X")]))]), UserTask(name="Review", items=[Setting(key="assignee", value=RawExpr(raw="${bean.pick()}"))]), ScriptTask(name="total", body="```js\\ny = 2\\n```")', + 'ScriptTask(name="guard", body="```js\\nif (a) { }\\n```")', + ], ], ])('%s', async (_title, source, expected) => { await expectBody(source, expected); @@ -936,54 +719,36 @@ ${FENCE} }); describe('Parsing - repeat clause', () => { - test.each([ - [ - '`for each line in lines` binds the element and the collection and leaves the rest unset', - `process p { user U for each line in lines }`, - '[UserTask(name="U", element="line", collection=VarRef(ref=->lines))]', - ], + test.each([ [ - '`for each in lines` takes the collection with no element name', - `process p { user U for each in lines }`, - '[UserTask(name="U", collection=VarRef(ref=->lines))]', - ], - [ - 'a count runs alone', - `process p { user U for 3 }`, - '[UserTask(name="U", cardinality=LiteralInt(value=3))]', - ], - [ - 'a count runs alongside a collection', - `process p { user U for 2 each line in lines }`, - '[UserTask(name="U", cardinality=LiteralInt(value=2), element="line", collection=VarRef(ref=->lines))]', - ], - [ - '`sequentially` is set only when written', - `process p { user U for each line in lines sequentially }`, - '[UserTask(name="U", element="line", collection=VarRef(ref=->lines), sequential=true)]', - ], - [ - '`until (...)` parses the same expression AST an if condition parses to', - `process p { user U for each line in lines until (nrOfCompletedInstances >= 2) }`, - '[UserTask(name="U", element="line", collection=VarRef(ref=->lines), completion=Relational(left=VarRef(ref=->nrOfCompletedInstances), op=">=", right=LiteralInt(value=2)))]', - ], - [ - 'the clause and the settings parse together in that order', - `process p { user U for each line in "\${order.lines}" sequentially until (done) (label: "Label", assignee: "demo") }`, - '[UserTask(name="U", element="line", collection=RawExpr(raw="${order.lines}"), sequential=true, completion=VarRef(ref=->done), items=[Setting(key="label", value=LiteralString(value="Label")), Setting(key="assignee", value=LiteralString(value="demo"))])]', - ], - [ - 'the clause on the statement after a start event is not swallowed', - `process p { start S user U for each line in lines end E }`, - '[StartEvent(name="S"), UserTask(name="U", element="line", collection=VarRef(ref=->lines)), EndEvent(name="E")]', + 'a repeat clause parses its element, collection, count, and condition correctly', + `process p { + user U for each line in lines + user U for each in lines + user U for 3 + user U for 2 each line in lines + user U for each line in lines sequentially + user U for each line in lines until (nrOfCompletedInstances >= 2) + user U for each line in "\${order.lines}" sequentially until (done) (label: "Label", assignee: "demo") + start S user U for each line in lines end E +}`, + [ + 'UserTask(name="U", element="line", collection=VarRef(ref=->lines))', + 'UserTask(name="U", collection=VarRef(ref=->lines))', + 'UserTask(name="U", cardinality=LiteralInt(value=3))', + 'UserTask(name="U", cardinality=LiteralInt(value=2), element="line", collection=VarRef(ref=->lines))', + 'UserTask(name="U", element="line", collection=VarRef(ref=->lines), sequential=true)', + 'UserTask(name="U", element="line", collection=VarRef(ref=->lines), completion=Relational(left=VarRef(ref=->nrOfCompletedInstances), op=">=", right=LiteralInt(value=2)))', + 'UserTask(name="U", element="line", collection=RawExpr(raw="${order.lines}"), sequential=true, completion=VarRef(ref=->done), items=[Setting(key="label", value=LiteralString(value="Label")), Setting(key="assignee", value=LiteralString(value="demo"))])', + 'StartEvent(name="S"), UserTask(name="U", element="line", collection=VarRef(ref=->lines)), EndEvent(name="E")', + ], ], ])('%s', async (_title, source, expected) => { await expectBody(source, expected); }); - // Every row carries settings, so the clause has to survive them; `script` - // and `subprocess` are the two rules where a further brace-opened body - // follows, the only shape a lookahead ambiguity could surface in. + // `script` and `subprocess` are followed by a brace-opened body, the only + // shape a lookahead ambiguity could surface in. const SET = '(asyncBefore: true)'; const REPEATABLE: Array< @@ -1052,8 +817,6 @@ const VAR_PLACEMENT_GUIDANCE = 'the process, with the other declarations. Move it above the first ' + 'statement.'; -// The whole error list, so a row also proves the guidance replaced the stock -// wording rather than joining it. describe('Parsing - sources the parser rejects', () => { test.each([ [ @@ -1220,11 +983,6 @@ const LABEL_CARRIERS: readonly LabelCarrier[] = [ ['call', 'process p { call C', '}'], ]; -/** - * A slot has one spelling: a row pairs a shape the grammar refuses with the - * one it takes, and asserts the combined lexer-and-parser error list is - * non-empty for the first and empty for the second. - */ describe('Parsing - a slot has one spelling', () => { test.each([ ...LABEL_CARRIERS.map( diff --git a/packages/language/test/recovery-fuzz.test.ts b/packages/language/test/recovery-fuzz.test.ts index 03a8ef3..2e77a42 100644 --- a/packages/language/test/recovery-fuzz.test.ts +++ b/packages/language/test/recovery-fuzz.test.ts @@ -1,14 +1,7 @@ -/** - * Validation stays total on a broken document: no check crashes, and none - * reports an element by a name the parser never filled in. A hand-written - * table varies one axis per row (a keyword in an identifier slot reaches - * different empty slots than a deleted token), so the corpus is mutated - * mechanically instead: tokenized by the language's own lexer, then - * substituted, duplicated and deleted at a fixed seed. Changing `SEED` - * explores elsewhere; past roughly 150_000 mutants the documents the - * validation helper retains exhaust the default heap, so a wider run wants - * `NODE_OPTIONS=--max-old-space-size=8192`. - */ +// Validation stays total on mutated documents: no check crashes and none names +// an element the parser never filled in. Past roughly 150_000 mutants the +// retained documents exhaust the default heap +// (`NODE_OPTIONS=--max-old-space-size=8192`). import { beforeAll, describe, expect, test } from 'vitest'; import { AstUtils, EmptyFileSystem, GrammarAST } from 'langium'; @@ -21,23 +14,15 @@ import { withTextMessages } from './helpers/diagnostics.js'; const SEED = 0x5eed; const MUTANTS = 2000; -/** - * A mutant costs about 0.5ms warm and fifteen times that on a cold, contended - * CPU; the budget only catches a runaway. - */ +/** Only catches a runaway: a mutant costs about 0.5ms warm, fifteen times that cold. */ const TIMEOUT_MS = MUTANTS * 30; const FENCE = '`' + '`' + '`'; /** - * Well-formed sources covering every construct a check reads a slot off. Where - * the reader is a second construct referring to the first (a handler's trigger - * is only printed when a `goto` crosses its boundary, a field's type only when - * a `var` of the same name disagrees), the corpus carries the pair. A - * substitution draws from the grammar's vocabulary and never reproduces an - * identifier the source spells, so the last entry collides outright on all - * three process-scoped duplicates, and its mutants reach the duplicate walks - * with one half of each pair torn up. + * Where a slot is only read back through a second construct (a `goto` crossing + * a handler, a disagreeing `var`), the corpus carries the pair; the last entry + * collides on all three process-scoped duplicates. */ const CORPUS = [ `process p { error E(code: "c", message: "m") start S throw error(E) }`, @@ -62,7 +47,7 @@ const CORPUS = [ `process p(label: "x", label: "y") { var a: number var a: number start S user U user U end E }`, ]; -/** Park-Miller, so a failure names a mutant that replays, and free of the bitwise operators the lint config bans. */ +/** Park-Miller: seeded so a failure replays, and free of the bitwise operators the lint bans. */ function seededRandom(seed: number): () => number { const modulus = 2147483647; let state = seed % modulus; @@ -81,8 +66,7 @@ beforeAll(() => { validate = validationHelper(services); tokenize = (text) => services.parser.Lexer.tokenize(text).tokens.map((t) => t.image); - // Every keyword the grammar declares; the three terminal samples cannot be - // read out of a regular expression, so they are the one hand-written part. + // The terminal samples cannot be read out of a regular expression. const keywords = new Set(['name', '"s"', '1']); for (const node of AstUtils.streamAllContents(services.Grammar)) { if (GrammarAST.isKeyword(node)) keywords.add(node.value); diff --git a/packages/language/test/scoping.test.ts b/packages/language/test/scoping.test.ts index fe4558a..a39f602 100644 --- a/packages/language/test/scoping.test.ts +++ b/packages/language/test/scoping.test.ts @@ -1,13 +1,5 @@ -/** - * The scope provider and the linker, driven through `parseHelper` with - * `{ validation: true }` so linking runs. Each row is one program and the - * whole oracle read off it: every reference in document order with what it - * reaches, then every diagnostic, so a row pins the target, the exact boundary - * wording, and that nothing stacks a second diagnostic on a replaced one. - * {@link codeResolutionsOf} lists every identifier and warnings too, since a - * scope reaching past a code position shows up as a resolved identifier where - * the undeclared-variable warning belongs. - */ +// A row pins every reference in document order with its target, then every +// diagnostic, so no second diagnostic can stack on a replaced one. import { beforeAll, describe, expect, test } from 'vitest'; import { @@ -17,6 +9,7 @@ import { type Reference, } from 'langium'; import { parseHelper } from 'langium/test'; +import { DiagnosticSeverity } from 'vscode-languageserver-types'; import type { Model, OnHandler } from '@bpmn-script/language'; import { createBpmnScriptServices, @@ -34,8 +27,6 @@ import { withTextMessages, } from './helpers/diagnostics.js'; -const SEVERITY_ERROR = 1; - let services: ReturnType; let parse: ReturnType>; @@ -50,7 +41,6 @@ type Row = readonly [ expected: readonly string[], ]; -/** `on : ()`, each part where written, since a handler has no name. */ function headerOf(handler: OnHandler): string { const head = handler.host ? `on ${handler.host.$refText}: ${handler.trigger}` @@ -59,7 +49,6 @@ function headerOf(handler: OnHandler): string { return code ? `${head}(${code})` : head; } -/** `container/.../name:Type`, so two same-named steps in different containers read apart. */ function pathOf(node: AstNode): string { const segments: string[] = []; for (let n: AstNode | undefined = node.$container; n; n = n.$container) { @@ -77,7 +66,6 @@ function targetOf(reference: Reference): string { return reference.ref ? pathOf(reference.ref) : 'unresolved'; } -/** Every `goto` target and handler host in document order, then every error. */ async function resolutionsOf(source: string): Promise { const document = await parse(source, { validation: true }); expect(document.parseResult.parserErrors.map((e) => e.message)).toEqual([]); @@ -91,7 +79,7 @@ async function resolutionsOf(source: string): Promise { } } for (const diagnostic of withTextMessages(document.diagnostics ?? [])) { - if (diagnostic.severity === SEVERITY_ERROR) { + if (diagnostic.severity === DiagnosticSeverity.Error) { lines.push(`error: ${diagnostic.message}`); } } @@ -106,7 +94,7 @@ async function checkRow( expect(await resolutionsOf(source)).toEqual(expected); } -/** Every identifier and mapping code slot (`error E -> ...`) in document order, then every diagnostic of either severity. */ +/** Warnings count here: a scope reaching past a code position hides the undeclared-variable warning. */ async function codeResolutionsOf(source: string): Promise { const document = await parse(source, { validation: true }); expect(document.parseResult.parserErrors.map((e) => e.message)).toEqual([]); @@ -123,7 +111,7 @@ async function codeResolutionsOf(source: string): Promise { } for (const diagnostic of withTextMessages(document.diagnostics ?? [])) { const severity = - diagnostic.severity === SEVERITY_ERROR ? 'error' : 'warning'; + diagnostic.severity === DiagnosticSeverity.Error ? 'error' : 'warning'; lines.push(`${severity}: ${diagnostic.message}`); } return lines; @@ -160,7 +148,6 @@ function missingHost(name: string): string { return `error: No step named '${name}' in this process to attach to.`; } -/** Every two-process fixture draws this; both processes are what the row needs. */ const MULTI_PROCESS = 'error: Only one process is supported per file. Move additional processes into separate files.'; diff --git a/packages/language/test/semantic-tokens.test.ts b/packages/language/test/semantic-tokens.test.ts index 1b2a58d..718f1d1 100644 --- a/packages/language/test/semantic-tokens.test.ts +++ b/packages/language/test/semantic-tokens.test.ts @@ -1,9 +1,3 @@ -/** - * `BpmnScriptSemanticTokenProvider` through the real semantic-tokens request: - * a soft word is a `keyword` exactly where it carries meaning, and plain - * where it names a variable or a step. - */ - import { beforeAll, describe, expect, test } from 'vitest'; import { EmptyFileSystem } from 'langium'; import { @@ -21,14 +15,9 @@ beforeAll(() => { }); const KEYWORD = SemanticTokenTypes.keyword; -/** No token covers the marked range at all. */ const PLAIN = 'plain'; -/** - * The token type covering each `<|...|>` range, in source order, as one list: - * a range nothing covers reads as `plain`, and a range two tokens claim reads - * as both joined, so neither a missing nor a doubled token can pass as one. - */ +/** The token type covering each `<|...|>` range; two tokens on one range read joined. */ function markedTokenTypes( result: DecodedSemanticTokensWithRanges, ): readonly string[] { @@ -58,114 +47,95 @@ const SOFT_WORDS_AS_OPERAND = ['priority', 'code', 'compensation', 'after']; describe('Semantic tokens - soft event words', () => { test.each([ [ - 'the trigger word of an `on` handler is a keyword', - 'on <|error|>("X") { }', - [KEYWORD], - ], - [ - 'the trigger word of a `throw` is a keyword', - 'throw <|escalation|>("C")', - [KEYWORD], - ], - [ - 'a catch binding highlights its field word, not the variable it introduces', - 'on error("X", <|code|>: <|c|>) { }', - [KEYWORD, PLAIN], - ], - [ - 'a condition handler highlights its trigger word, not the variable it tests', - 'on <|condition|> (<|amount|> > 100) { }', - [KEYWORD, PLAIN], - ], - [ - 'an awaited event highlights its trigger word, not the time it carries', - 'await <|timer|>("<|PT1H|>")', - [KEYWORD, PLAIN], - ], - [ - 'every branch header of a race highlights its trigger word', - `await { + 'trigger words highlight as keywords, distinct from the names they carry', + `on <|error|>("X") { } + throw <|escalation|>("C") + on error("X", <|code|>: <|c|>) { } + on <|condition|> (<|amount|> > 100) { } + await <|timer|>("<|PT1H|>") + await { <|message|>("M") { user A } <|timer|>("PT1H") { user B } - }`, - [KEYWORD, KEYWORD], - ], - [ - 'a start event highlights its trigger word, not its name', - 'start <|S|> <|message|>("M")\n user A', - [PLAIN, KEYWORD], - ], - [ - 'a terminating end highlights its trigger word', - 'start S\n user A\n end E <|terminate|>', - [KEYWORD], - ], - [ - 'a start and an end with no trigger carry nothing on their names', - 'start <|S|>\n end <|E|>', - [PLAIN, PLAIN], - ], - [ - 'a declaration highlights both its kind and the key of its message', - '<|error|> X(<|message|>: "m")', - [KEYWORD, KEYWORD], - ], - [ - 'a code declaration highlights its kind, not the name it declares', - '<|escalation|> <|MANUAL_REVIEW|>', - [KEYWORD, PLAIN], - ], - [ - 'a compensation emit highlights its trigger word, not the activity it names', - 'emit <|compensation|> <|Undo|>', - [KEYWORD, PLAIN], - ], - [ - 'a handler attached to an activity highlights the trigger, not the host', - 'user Review\n on <|Review|>: <|timer|>("PT2H") { }', - [PLAIN, KEYWORD], - ], - [ - 'a setting key is a keyword, its value is not', - 'user T(<|assignee|>: "<|demo|>")', - [KEYWORD, PLAIN], - ], - [ - 'a parameter direction is a keyword, the parameter name is not', - 'service S { <|input|> <|amount|> = 1 }', - [KEYWORD, PLAIN], - ], - [ - 'a timeout particle is a keyword, the duration it takes is not', - 'user T { on timeout <|after|> "<|PT1H|>" (class: "com.acme.L") }', - [KEYWORD, PLAIN], - ], - [ - 'a listener event and its binding key are both keywords', - 'user T { on <|create|>(<|class|>: "com.acme.L") }', - [KEYWORD, KEYWORD], - ], - [ - 'an error mapping highlights its two words, not the code or the condition', - 'error E\n service V(topic: "t") { <|error|> <|E|> <|when|> <|ready|> }', - [KEYWORD, PLAIN, KEYWORD, PLAIN], - ], - ['a step named after a soft word stays plain', 'user <|input|>', [PLAIN]], - [ - "a form field's type word is a keyword, its id is not", - 'start S { form { <|amount|>: <|number|> "Weight" } }', - [PLAIN, KEYWORD], + } + start <|S|> <|message|>("M") + user A + start S + user A + end E <|terminate|> + start <|S|> + end <|E|>`, + [ + KEYWORD, + KEYWORD, + KEYWORD, + PLAIN, + KEYWORD, + PLAIN, + KEYWORD, + PLAIN, + KEYWORD, + KEYWORD, + PLAIN, + KEYWORD, + KEYWORD, + PLAIN, + PLAIN, + ], + ], + [ + 'soft keywords highlight correctly, distinct from the identifiers beside them', + `<|error|> X(<|message|>: "m") + <|escalation|> <|MANUAL_REVIEW|> + emit <|compensation|> <|Undo|> + user Review + on <|Review|>: <|timer|>("PT2H") { } + user T(<|assignee|>: "<|demo|>") + service S { <|input|> <|amount|> = 1 } + user T { on timeout <|after|> "<|PT1H|>" (class: "com.acme.L") } + user T { on <|create|>(<|class|>: "com.acme.L") } + error E + service V(topic: "t") { <|error|> <|E|> <|when|> <|ready|> } + user <|input|> + start S { form { <|amount|>: <|number|> "Weight" } }`, + [ + KEYWORD, + KEYWORD, + KEYWORD, + PLAIN, + KEYWORD, + PLAIN, + PLAIN, + KEYWORD, + KEYWORD, + PLAIN, + KEYWORD, + PLAIN, + KEYWORD, + PLAIN, + KEYWORD, + KEYWORD, + KEYWORD, + PLAIN, + KEYWORD, + PLAIN, + PLAIN, + PLAIN, + KEYWORD, + ], + ], + [ + 'a soft word as a variable name carries no token, declared or read', + [ + ...SOFT_WORDS_AS_VAR_NAME.map((word) => `var <|${word}|>: string`), + ...SOFT_WORDS_AS_OPERAND.filter( + (word) => !SOFT_WORDS_AS_VAR_NAME.includes(word), + ).map((word) => `var ${word}: string`), + ...SOFT_WORDS_AS_OPERAND.map( + (word, i) => `if (<|${word}|> == "x") { end Done${i} }`, + ), + ].join('\n '), + [...SOFT_WORDS_AS_VAR_NAME, ...SOFT_WORDS_AS_OPERAND].map(() => PLAIN), ], - ...SOFT_WORDS_AS_VAR_NAME.map((word): Row => [ - `\`var ${word}\` carries no token on the name`, - `var <|${word}|>: string`, - [PLAIN], - ]), - ...SOFT_WORDS_AS_OPERAND.map((word): Row => [ - `\`if (${word} ...)\` carries no token on the operand`, - `var ${word}: string\n if (<|${word}|> == "x") {\n end Done\n }`, - [PLAIN], - ]), ])('%s', async (_title, body, expected) => { const result = await highlight(`process p {\n ${body}\n}\n`); expect(markedTokenTypes(result)).toEqual(expected); diff --git a/packages/language/test/symbols.test.ts b/packages/language/test/symbols.test.ts index 58ccb37..cd6476e 100644 --- a/packages/language/test/symbols.test.ts +++ b/packages/language/test/symbols.test.ts @@ -1,9 +1,3 @@ -/** - * The outline of the `boundary-events.bpmnscript` golden, flattened to one - * `Kind name` line per symbol and indented by depth, so a handler whose steps - * floated up to the process would show. - */ - import { readFileSync } from 'node:fs'; import { describe, expect, test } from 'vitest'; import { EmptyFileSystem } from 'langium'; @@ -24,8 +18,7 @@ function flatten(symbols: DocumentSymbol[], depth: number): string[] { ]); } -// `BookCarrier`'s `in`/`out` mapping lines are absent, a mapping having no -// name to be a symbol by. +// A mapping has no name, so `BookCarrier`'s mapping lines are no symbols. const EXPECTED = [ 'Module parcel-dispatch', ' Constant ADDRESS_REJECTED', diff --git a/packages/language/test/validating.test.ts b/packages/language/test/validating.test.ts index bf05545..dff046b 100644 --- a/packages/language/test/validating.test.ts +++ b/packages/language/test/validating.test.ts @@ -1,14 +1,9 @@ -/** - * Validation of the BPMNscript AST. - * - * A case is one source program and the complete list of diagnostics it raises, - * in the order they are reported, so it fails both when a check stops firing - * and when one fires that should not. A warning carries a `warning:` prefix; - * everything else is an error, whether from a check, the linker, or the parser. - */ +// A case pins a program's whole diagnostic list in report order; a warning +// carries a `warning:` prefix. import { beforeAll, describe, expect, test } from 'vitest'; import { EmptyFileSystem } from 'langium'; +import { DiagnosticSeverity } from 'vscode-languageserver-types'; import { validationHelper, type ValidationResult } from 'langium/test'; import type { FormField, Model } from '@bpmn-script/language'; import { @@ -169,8 +164,6 @@ import { withTextMessages, } from './helpers/diagnostics.js'; -const SEVERITY_WARNING = 2; - let validate: (input: string) => Promise>; beforeAll(() => { @@ -181,7 +174,9 @@ beforeAll(() => { async function diagnosticsOf(source: string): Promise { const { diagnostics } = await validate(source); return withTextMessages(diagnostics).map((d) => - d.severity === SEVERITY_WARNING ? `warning: ${d.message}` : d.message, + d.severity === DiagnosticSeverity.Warning + ? `warning: ${d.message}` + : d.message, ); } @@ -199,23 +194,24 @@ function checks(concern: string, cases: Case[]): void { const capitalized = (text: string) => text[0]!.toUpperCase() + text.slice(1); -/** An enum gets a value so its own empty-values warning stays out of the list. */ +/** One form, a field `f` per row; an enum gets a value to keep its empty-values warning out. */ function formFieldRows( concern: string, - pairs: Array<[setting: string, type: string, expected: string[]]>, -): Case[] { - return pairs.map(([setting, type, expected]) => [ - `${concern}: ${setting} on ${type}`, - `process p { start S { form { f: ${type} (${setting})${type === 'enum' ? ' { a }' : ''} } } }`, - expected, - ]); + rows: Array< + [setting: string, type: string, expected: (id: string) => string[]] + >, +): Case { + const fields = rows.map( + ([setting, type], i) => + `f${i}: ${type} (${setting})${type === 'enum' ? ' { a }' : ''}`, + ); + return [ + concern, + `process p { start S { form { ${fields.join(' ')} } } }`, + rows.flatMap(([, , expected], i) => expected(`f${i}`)), + ]; } -// The messages, each spelled once -// -// A message the validator exports is imported; a helper below adapts the few -// that take an AST node or a slot to the strings a case spells. - const nonBooleanCondition = (shape: string) => nonBooleanConditionMessage(shape, 'condition'); const fieldBinding = (subject: string, written?: string) => @@ -250,7 +246,6 @@ const noFlowSteps = (name: string) => `Process '${name}' has no flow steps: a process needs at least one step on its main flow (handlers alone do not start a process).`; const blockNoFlowSteps = (kind: string, name: string) => `${capitalized(kind)} named '${name}' has no flow steps: ${kind} needs at least one step on its main flow (handlers alone do not start it).`; -/** The shapes are derived from the patterns, so this pins the pair. */ const RESERVED_SHAPES = `'Gateway_..._(split|join|fork|loop|race)', 'Flow_..._...', 'Throw_', ` + `'EventSubProcess_', 'Boundary_', 'Catch_', 'EndEvent_Boundary_', ` + @@ -274,8 +269,6 @@ const catchBindingTypeClash = (name: string, prior: string) => const formFieldTypeClash = (id: string, type: string, prior: string) => `Form field '${id}' is typed '${type}', but '${id}' is already declared as '${prior}'; the types must agree.`; -// Element settings. - const duplicateSetting = (key: string) => `Duplicate setting '${key}'.`; const notValidOn = (key: string, description: string) => `Setting '${key}' is not valid on ${description}.`; @@ -295,7 +288,7 @@ const unquotedText = (key: string) => 'put the value in quotes.'; const SERVICE_BINDINGS = `'class', 'expression', 'delegate', 'topic', or 'type'`; const DECISION_BINDINGS = `'class', 'expression', 'delegate', 'topic', 'type', or 'decision'`; -/** A thrown message has no member block for the fields a built-in behaviour needs. */ +/** No `type`: a thrown message has no member block for a built-in behaviour's fields. */ const THROW_BINDINGS = `'class', 'expression', 'delegate', or 'topic'`; const LISTENER_BINDINGS = `'class', 'expression', or 'delegate'`; const emptyBinding = (key: string, noun: string) => @@ -308,10 +301,6 @@ const MAP_DECISION_RESULT = `Setting 'mapDecisionResult' must be 'singleEntry', 'singleResult', ` + `'collectEntries', or 'resultList'.`; -// The process header. - -// Forms. - const noFormBlock = (description: string) => `${capitalized(description)} cannot declare a 'form' block; forms belong on start events and user tasks.`; const oneFormBlock = (description: string) => @@ -320,8 +309,6 @@ const duplicateFormField = (id: string) => `Duplicate form field '${id}'.`; const formFieldType = (id: string, type: string) => `Form field '${id}' has type '${type}', which a form cannot use. Use string, number, boolean, date, or enum.`; -// Parameters and listeners. - const noParameters = (description: string) => `${capitalized(description)} cannot declare an 'input' or 'output' parameter; ${PARAMETER_HOSTS_MESSAGE}`; const duplicateParameter = (direction: string, name: string) => @@ -336,13 +323,9 @@ const unknownListenerEvent = (event: string, events: string) => const USER_LISTENER_EVENTS = `'start', 'end', 'create', 'assignment', 'complete', 'update', 'delete', or 'timeout'`; const LISTENER_PARTICLE_ONLY = "Only 'on timeout' takes a particle."; -// The extras of a step handed to an external worker. - const codeNotDeclared = (trigger: string, name: string) => `'${name}' is not declared. Add '${trigger} ${name}' to the process.`; -// Fenced scripts. - const unsupportedScriptTag = (subject: string, tag: string) => `${subject} has an unsupported language tag '${tag}'. ` + "Use 'juel', 'js', 'javascript', 'ecmascript', 'groovy', 'py', 'python', " + @@ -352,12 +335,7 @@ const unterminatedScript = (name: string) => `Script task '${name}' has a malformed or unterminated fenced script body; ` + 'a script must be a closed ``` ... ``` block.'; -/** - * `juel` (the engine's own default), a mixed-case alias, and `ecmascript` - * resolve the same as their canonical spelling; every other tag is refused, - * `ScriptingEngines.getScriptEngineForLanguage` lowercasing the language - * before it looks an engine up. - */ +/** `ScriptingEngines.getScriptEngineForLanguage` lowercases the tag, so a mixed-case alias resolves. */ const SCRIPT_TAG_MATRIX: ReadonlyArray< readonly [tag: string, refused: boolean] > = [ @@ -378,8 +356,6 @@ function scriptTagCases( ]); } -// Control flow. - const emptyBranch = (what: string) => `The ${what} has no steps.`; const emptyNumberedBranch = (index: number, keyword: string) => `Branch ${index} of the '${keyword}' statement has no steps.`; @@ -398,8 +374,6 @@ const reservedWord = (word: string) => `'${word}' is a reserved word and cannot be used as a plain name here. ` + `To refer to a variable named '${word}', write it as a quoted raw expression: "\${${word}}".`; -// Calls. - const CALL_PROCESS_REQUIRED = 'A call must name the process it starts: add process: "".'; const BINDING_VALUE = `Setting 'binding' must be 'latest' or 'deployment'.`; @@ -411,8 +385,6 @@ const duplicateMapping = (direction: string, target: string) => const duplicateAllMapping = (direction: string) => `Duplicate '${direction} *' mapping; a direction can copy every variable only once.`; -// Start events. - const startNotFirst = (name: string) => `'start ${name}' must be a top-level statement of its process, or the ` + 'first statement of its subprocess, attempt block, or event-handler body. ' + @@ -445,18 +417,12 @@ const startNameRequired = (trigger: string) => `A ${trigger} start needs the ${trigger}'s name: the engine matches ${trigger}s by name.`; const START_PARTICLE_ONLY = 'Only a timer start takes a particle.'; -// Timers. - const unknownParticle = (word: string) => `Unknown timer particle '${word}'; write 'after', 'at', or 'every'.`; const REPEATING_INTERRUPTS = 'A repeating timer that interrupts its scope fires at most once: ' + "add 'alongside' to let it repeat, or give it a duration instead."; -// End events. - -// Event handlers. - const HANDLER_PLACEMENT = 'An event handler belongs directly in the body of a process, a subprocess, ' + 'an attempt block, or another event handler: it handles events for that ' + @@ -482,8 +448,6 @@ const eventName = (value: string) => `name '${value}'`; const escalationCatchAllBesideCoded = (scope: string) => `An 'on escalation' handler with no code cannot sit beside one with a code on scope '${scope}': the code-less one would catch every escalation, and Operaton refuses the pair (BpmnParse.addEscalationEventDefinition). Give both a code, or keep one.`; -// Boundary hosts. - const illegalHost = (name: string, kind: string) => 'A boundary event can only attach to an activity: a user, service, script, ' + 'send, or receive task, a step, a decision step, a subprocess, an attempt ' + @@ -492,20 +456,14 @@ const escalationHost = (name: string, kind: string) => 'An escalation boundary can only attach to a subprocess, an attempt block, ' + `a call, or a user task; '${name}' is ${kind}.`; -// Compensation. - const throwCompensationNames = (keyword: 'throw' | 'emit') => 'Compensation undoes completed work: there is nothing to name; ' + `write '${keyword} compensation'.`; -// Cancel. - const cancelHost = (name: string, kind: string) => `A cancel handler can only attach to an 'attempt' block: it catches that ` + `block being given up; '${name}' is ${kind}.`; -// Throw, emit, and await. - const codeRequired = ( subject: 'A thrown' | 'An emitted', trigger: string, @@ -524,8 +482,6 @@ const AWAIT_CONDITION_ONLY = "Only 'await condition' takes a condition expression."; const AWAIT_PARTICLE_ONLY = "Only 'await timer' takes a particle."; -// Link events. - const linkNoCatch = (name: string) => `No 'await link("${name}")' catches this link, and the engine refuses to ` + 'deploy an emitted link with no catch of its name. Write one where the ' + @@ -546,8 +502,6 @@ const gotoToLink = (target: string) => `'goto ${target}' cannot target an awaited link: a link catch is entered ` + "by 'emit link' of the same name, not by a sequence flow."; -// Code declarations. - const declarationNotAString = (kind: string, key: string) => `An ${kind} declaration's ${key} must be a quoted string.`; const declarationEmpty = (kind: string, key: string) => @@ -557,59 +511,37 @@ const alreadyDeclared = (kind: string, name: string) => const duplicateDeclaredCode = (kind: string, code: string, owner: string) => `${capitalized(kind)} code '${code}' is already declared by '${owner}'; two declarations cannot share a code.`; -// Cases - checks('Validation - variables in expressions', [ [ - 'an undeclared variable in a condition warns and names it', - `process p { if (amount > 1000) { user A } }`, - [warn(undeclared('amount'))], - ], - [ - 'a declared variable used compatibly is clean', - `process p { var amount: number if (amount > 1000) { user A } }`, - [], - ], - [ - 'a dotted formKey names a form, not a variable', - `process p { user T(formKey: forms.review) }`, - [], - ], - [ - 'a bareword expression is an EL binding, not a variable', - `process p { service S(expression: someBareword) }`, - [], - ], - [ - 'a dotted class names a Java class, not a variable', - `process p { service S(class: com.example.X) }`, - [], - ], - [ - 'a string-typed variable compared with a number is a type error', - `process p { var name: string if (name > 1000) { user A } }`, - [typeMismatch('name', 'string', 'an ordered comparison', '>')], - ], - [ - 'a number-typed variable in an ordered comparison is clean', - `process p { var amount: number if (amount >= 1000) { user A } }`, - [], - ], - [ - 'a boolean-typed variable in arithmetic is a type error', - `process p { var flag: boolean if (flag + 1 > 0) { user A } }`, - [typeMismatch('flag', 'boolean', 'an arithmetic expression', '+')], + 'expressions type-check variables against their declared types', + `process p { + var name: string + var flag: boolean + var x: any + if (amount > 1000) { user A } + user T(formKey: forms.review) + service S(expression: someBareword) + service S2(class: com.example.X) + if (name > 1000) { user A2 } + if (flag + 1 > 0) { user A3 } + if (x > 1000) { user A4 } +}`, + [ + warn(undeclared('amount')), + typeMismatch('name', 'string', 'an ordered comparison', '>'), + typeMismatch('flag', 'boolean', 'an arithmetic expression', '+'), + ], ], [ - 'a number-typed variable in a logical expression is a type error', - `process p { var amount: number if (amount && true) { user A } }`, + "a variable's declared type gates which operators accept it", + `process p { + var amount: number + if (amount > 1000) { user A } + if (amount >= 1000) { user A2 } + if (amount && true) { user A3 } +}`, [typeMismatch('amount', 'number', 'a logical expression', '&&')], ], - [ - 'an any-typed variable never mismatches', - `process p { var x: any if (x > 1000) { user A } }`, - [], - ], [ "an undeclared variable in an 'on condition' expression warns the same way", `process p { on condition(amount > 100) { user A } }`, @@ -632,121 +564,70 @@ checks('Validation - variables in expressions', [ checks('Validation - barewords in engine-side attribute values', [ [ - 'a bareword candidateGroups reaches the engine as written', - `process p { user U(candidateGroups: approvers) }`, - [], - ], - [ - 'a bareword candidateUsers reaches the engine as written', - `process p { user U(candidateUsers: approvers) }`, - [], - ], - [ - 'a bareword assignee is the user id, as the compiler and the engine read it', - `process p { user U(assignee: demo, candidateUsers: demo) }`, - [], - ], - [ - 'a hyphenated assignee is a user id too, never scanned as a subtraction', - `process p { user U(assignee: john-doe) }`, - [], - ], - [ - 'a bareword resultVariable names the variable to fill, not one to read', - `process p { service V(expression: "\${bean.run()}", resultVariable: approvers) }`, - [], - ], - [ - 'a bareword dueDate is a date the engine cannot parse, so it asks for quotes', - `process p { user U(dueDate: approvers) }`, - [unquotedText('dueDate')], - ], - [ - 'a bareword followUpDate asks for quotes the same way', - `process p { user U(followUpDate: approvers) }`, - [unquotedText('followUpDate')], - ], - [ - 'a bareword retryCycle asks for quotes the same way', - `process p { user U(asyncBefore: true, retryCycle: approvers) }`, - [unquotedText('retryCycle')], - ], - [ - 'a bareword priority lowers to an expression, so it warns when undeclared', - `process p { user U(priority: deadline) }`, - [warn(undeclared('deadline'))], - ], - [ - 'a bareword jobPriority warns the same way', - `process p { user U(asyncBefore: true, jobPriority: deadline) }`, - [warn(undeclared('deadline'))], - ], - [ - 'a bareword businessKey warns the same way', - `process p { call C(process: "q", businessKey: deadline) }`, - [warn(undeclared('deadline'))], + 'each engine-side attribute treats a bareword as text, a variable, or a bad date consistently', + `process p { + user U(candidateGroups: approvers) + user U2(candidateUsers: approvers) + user U3(assignee: demo, candidateUsers: demo) + user U4(assignee: john-doe) + service V(expression: "\${bean.run()}", resultVariable: approvers) + user U5(dueDate: approvers) + user U6(followUpDate: approvers) + user U7(asyncBefore: true, retryCycle: approvers) + user U8(priority: deadline) + user U9(asyncBefore: true, jobPriority: deadline) + call C(process: "q", businessKey: deadline) +}`, + [ + warn(undeclared('deadline')), + warn(undeclared('deadline')), + warn(undeclared('deadline')), + unquotedText('dueDate'), + unquotedText('followUpDate'), + unquotedText('retryCycle'), + ], ], ]); checks('Validation - a JUEL keyword or a hyphen in a rendered name', [ - ...JUEL_RESERVED_WORDS.flatMap((word): Case[] => [ + ...JUEL_RESERVED_WORDS.map((word): Case => [ + `'${word}' is an operator to the engine as a variable and as a property, in and outside a raw template`, + `process p { var ${word}: boolean var order: json if (${word}) { user A } if (order.${word}) { user B } if ("\${order.${word}}") { user C } }`, [ - `'${word}' as a variable is an operator to the engine, at the declaration and at the use`, - `process p { var ${word}: boolean if (${word}) { user A } }`, - [ - juelKeywordMessage(word, `execution.getVariable('${word}')`), - juelKeywordMessage(word, `execution.getVariable('${word}')`), - ], - ], - [ - `'${word}' as a property is an operator to the engine`, - `process p { var order: json if (order.${word}) { user A } }`, - [juelKeywordMessage(word, `order['${word}']`)], - ], - [ - `'${word}' as a property inside a raw template is the same operator`, - `process p { if ("\${order.${word}}") { user A } }`, - [juelKeywordMessage(word, `order['${word}']`)], + juelKeywordMessage(word, `execution.getVariable('${word}')`), + juelKeywordMessage(word, `execution.getVariable('${word}')`), + juelKeywordMessage(word, `order['${word}']`), + juelKeywordMessage(word, `order['${word}']`), ], ]), [ - "'true' as a property inside a raw template is the same operator", - `process p { if ("\${order.true}") { user A } }`, - [juelKeywordMessage('true', "order['true']")], - ], - [ - 'a keyword read off a bracketed object in a raw template is keyed on that object', - `process p { if ("\${items[0].and}") { user A } }`, - [juelKeywordMessage('and', "items[0]['and']")], - ], - [ - 'a keyword inside a JUEL string literal or between two templates is text, not a read', - `process p { user A(assignee: "\${map['x.and'] == 'a.or'} or.and \${b}") }`, - [], - ], - [ - 'a keyword read in a listener timeout template is the same operator', - `process p { user A { on timeout after "\${order.and}"(class: "c.X") } }`, - [juelKeywordMessage('and', "order['and']")], + 'a JUEL keyword or hyphen inside a template scans the same way everywhere', + `process p { + var order: json + if ("\${order.true}") { user A } + if ("\${items[0].and}") { user A2 } + user A3(assignee: "\${map['x.and'] == 'a.or'} or.and \${b}") + if (order.line.is-paid) { user A5 } + if (my-flag) { user A6 } + user U for each x in order.line-items + service S(class: com.example.mod) service T(topic: order-events) + user A4 { on timeout after "\${order.and}"(class: "c.X") } +}`, + [ + juelKeywordMessage('true', "order['true']"), + juelKeywordMessage('and', "items[0]['and']"), + hyphenNameMessage('is-paid', "order.line['is-paid']"), + hyphenNameMessage('my-flag', "execution.getVariable('my-flag')"), + warn(undeclared('my-flag')), + hyphenNameMessage('line-items', "order['line-items']"), + juelKeywordMessage('and', "order['and']"), + ], ], [ 'a hyphenated variable scans as a subtraction', `process p { var my-flag: boolean if (my-flag) { user A } }`, [hyphenNameMessage('my-flag', "execution.getVariable('my-flag')")], ], - [ - 'a hyphenated property scans the same way, keyed on its object', - `process p { var order: json if (order.line.is-paid) { user A } }`, - [hyphenNameMessage('is-paid', "order.line['is-paid']")], - ], - [ - 'an undeclared hyphenated variable keeps its warning beside the error', - `process p { if (my-flag) { user A } }`, - [ - hyphenNameMessage('my-flag', "execution.getVariable('my-flag')"), - warn(undeclared('my-flag')), - ], - ], [ 'a bare collection name is looked up as a variable, not evaluated', `process p { var check-close: json user U for each x in check-close }`, @@ -757,16 +638,6 @@ checks('Validation - a JUEL keyword or a hyphen in a rendered name', [ `process p { var check-close: json call C(process: "q") { in y = check-close } }`, [], ], - [ - 'a collection under an accessor is evaluated, so its spelling counts', - `process p { var order: json user U for each x in order.line-items }`, - [hyphenNameMessage('line-items', "order['line-items']")], - ], - [ - 'a class name and a topic reach the engine as written', - `process p { service S(class: com.example.mod) service T(topic: order-events) }`, - [], - ], ]); const CONDITION_POSITIONS: Array<[title: string, at: (c: string) => string]> = [ @@ -812,152 +683,98 @@ checks('Validation - a condition must be boolean', [ [nonBooleanCondition("a variable of type 'number'")], ]), [ - "an 'until' clause cites the multi-instance behaviour", - `process p { user U for 3 until (nrOfCompletedInstances) }`, - [nonBooleanConditionMessage("a variable of type 'number'", 'until')], - ], - [ - 'a string literal', - `process p { if ("yes") { user A } }`, - [nonBooleanCondition('a string')], - ], - [ - 'a json variable is a Spin node, never a Boolean', - `process p { var order: json if (order) { user A } }`, - [nonBooleanCondition("a variable of type 'json'")], - ], - [ - 'a null literal cites the null check', - `process p { if (null) { user A } }`, + 'a condition accepts only boolean-shaped expressions, whatever the syntax', + `process p { + var order: json + var n: number + var flag: boolean + var f: boolean + user U for 3 until (nrOfCompletedInstances) + if ("yes") { user A } + if (order) { user A2 } + if (null) { user A3 } + if (1) { user A4 } + if (n + 1) { user A5 } + if (-n) { user A6 } + if (n > 1 ? "a" : 2) { user A7 } + if ("\${a} and \${b}") { user A8 } + if (("yes")) { user A9 } + if (flag) { user A10 } if (flag && "\${a.b}") { user B } user U2 for 3 until (nrOfCompletedInstances > 1) + if (x) { user A11 } + if (order.paid) { user A12 } if (f ? f : 1) { user B2 } +}`, [ + nonBooleanConditionMessage("a variable of type 'number'", 'until'), + nonBooleanCondition('a string'), + nonBooleanCondition("a variable of type 'json'"), "A condition must be boolean, but this one is null: the engine throws 'condition expression returns null' when it evaluates it (UelExpressionCondition.evaluate).", - ], - ], - [ - 'a number literal', - `process p { if (1) { user A } }`, - [nonBooleanCondition('a number')], - ], - [ - 'an arithmetic expression', - `process p { var n: number if (n + 1) { user A } }`, - [nonBooleanCondition('an arithmetic expression')], - ], - [ - 'a negation', - `process p { var n: number if (-n) { user A } }`, - [nonBooleanCondition('an arithmetic expression')], - ], - [ - 'a ternary with no boolean arm', - `process p { var n: number if (n > 1 ? "a" : 2) { user A } }`, - [nonBooleanCondition('a ternary with no boolean arm')], - ], - [ - 'text around a template evaluates to a string', - `process p { if ("\${a} and \${b}") { user A } }`, - [ + nonBooleanCondition('a number'), + nonBooleanCondition('an arithmetic expression'), + nonBooleanCondition('an arithmetic expression'), + nonBooleanCondition('a ternary with no boolean arm'), nonBooleanCondition( 'text around a template, which evaluates to a string', ), + nonBooleanCondition('a string'), + warn(undeclared('x')), ], ], - [ - 'parentheses do not change the shape', - `process p { if (("yes")) { user A } }`, - [nonBooleanCondition('a string')], - ], - [ - 'a boolean variable, a comparison and a template body are clean', - `process p { var flag: boolean if (flag) { user A } if (flag && "\${a.b}") { user B } user U for 3 until (nrOfCompletedInstances > 1) }`, - [], - ], - [ - 'an undeclared variable keeps its warning and nothing more', - `process p { if (x) { user A } }`, - [warn(undeclared('x'))], - ], - [ - 'a property and a ternary with one boolean arm have no known type', - `process p { var order: json var f: boolean if (order.paid) { user A } if (f ? f : 1) { user B } }`, - [], - ], ]); checks('Validation - a composite raw template cannot be an operand', [ [ - 'negated', - `process p { if (!"\${a} and \${b}") { user A } }`, - [COMPOSITE_OPERAND_MESSAGE], - ], - [ - 'compared', - `process p { if ("\${a} b" == "x") { user A } }`, - [COMPOSITE_OPERAND_MESSAGE], - ], - [ - 'as an index', - `process p { var m: json if (m["\${a} \${b}"]) { user A } }`, - [COMPOSITE_OPERAND_MESSAGE], - ], - [ - 'in a ternary arm', - `process p { var f: boolean if (f ? "\${a} \${b}" : true) { user A } }`, - [COMPOSITE_OPERAND_MESSAGE], - ], - [ - 'one template under an operator splices in', - `process p { if (!"\${a.b}") { user A } }`, - [], - ], - [ - 'a brace or opener inside a JUEL string literal is string text (Scanner.nextString), so "${map[\'}\']}" and "${fn(\'${\')}" are one template', - `process p { if (!"\${map['}']}" && !"\${fn('\${')}") { user A } }`, - [], - ], - [ - 'a composite at the top of its position is carried as written', - `process p { service S(class: "com.acme.D") { field subject = "\${a} and \${b}" } }`, - [], + 'a composite raw template is refused as an operand in every position', + `process p { + var m: json + var f: boolean + if (!"\${a} and \${b}") { user A } + if ("\${a} b" == "x") { user A2 } + if (m["\${a} \${b}"]) { user A3 } + if (f ? "\${a} \${b}" : true) { user A4 } + if (!"\${a.b}") { user A5 } + if (!"\${map['}']}" && !"\${fn('\${')}") { user A6 } + service S(class: "com.acme.D") { field subject = "\${a} and \${b}" } +}`, + [ + COMPOSITE_OPERAND_MESSAGE, + COMPOSITE_OPERAND_MESSAGE, + COMPOSITE_OPERAND_MESSAGE, + COMPOSITE_OPERAND_MESSAGE, + ], ], ]); checks('Validation - binding values', [ [ - 'a quoted delegate on a service task never resolves', - `process p { service S(delegate: "bean") }`, - [literalElBindingMessage('delegate')], - ], - [ - 'a quoted expression on a listener evaluates to its own text', - `process p { user U { on start(expression: "bean.run()") } }`, - [literalElBindingMessage('expression')], - ], - [ - 'a quoted mapperDelegate on a call never resolves', - `process p { call C(process: "q", mapperDelegate: "bean") }`, - [literalElBindingMessage('mapperDelegate')], + "a binding value's shape decides whether it resolves as a class, name, or text", + `process p { + service S(delegate: "bean") + call C(process: "q", mapperDelegate: "bean") + service S2(class: "\${cls}") + call C2(process: "q", mapper: "\${cls}") + decide D(decision: "") + call C3(process: "q", mapper: "") call D2(process: "q", mapperDelegate: "") + service S3(delegate: bean) service T(expression: bean.method) service U3(class: "com.acme.D3") call C4(process: "q", mapper: com.acme.M) + user U { on start(expression: "bean.run()") } + user U2 { on start(class: "\${cls}") } +}`, + [ + literalElBindingMessage('delegate'), + literalElBindingMessage('mapperDelegate'), + templateAsClassMessage('class', 'delegate'), + templateAsClassMessage('mapper', 'mapperDelegate'), + emptyBinding('decision', 'decision table to evaluate'), + emptyBinding('mapper', 'mapping class to load'), + emptyBinding('mapperDelegate', 'mapping delegate to resolve'), + literalElBindingMessage('expression'), + templateAsClassMessage('class', 'delegate'), + ], ], [ 'a quoted expression on a thrown message evaluates to its own text', `process p { start S throw message("Ack", expression: "b") }`, [literalElBindingMessage('expression')], ], - [ - 'a template as a class is loaded as a class name', - `process p { service S(class: "\${cls}") }`, - [templateAsClassMessage('class', 'delegate')], - ], - [ - 'a template as a listener class is loaded the same way', - `process p { user U { on start(class: "\${cls}") } }`, - [templateAsClassMessage('class', 'delegate')], - ], - [ - 'a template as a mapper is loaded the same way', - `process p { call C(process: "q", mapper: "\${cls}") }`, - [templateAsClassMessage('mapper', 'mapperDelegate')], - ], ...( [ ['class', 'class to load'], @@ -981,69 +798,57 @@ checks('Validation - binding values', [ `process p { user U { on start(${key}: " ") } }`, [emptyBinding(key, noun)], ]), - [ - 'an empty decision on a decide step', - `process p { decide D(decision: "") }`, - [emptyBinding('decision', 'decision table to evaluate')], - ], - [ - 'an empty mapper and mapperDelegate on a call', - `process p { call C(process: "q", mapper: "") call D(process: "q", mapperDelegate: "") }`, - [ - emptyBinding('mapper', 'mapping class to load'), - emptyBinding('mapperDelegate', 'mapping delegate to resolve'), - ], - ], - [ - 'a bare or dotted name and a template are the shapes the keys take', - `process p { service S(delegate: bean) service T(expression: bean.method) service U(class: "com.acme.D") call C(process: "q", mapper: com.acme.M) }`, - [], - ], ]); checks('Validation - an escaped literal as a field value', [ [ - 'quoted text opening with an escaped template has no field slot', - `process p { service S(class: "com.acme.D") { field x = "\\\${y" } }`, - [escapedFieldLiteralMessage('x')], - ], - [ - 'the hash opener and leading whitespace are the same slot', - `process p { service S(class: "com.acme.D") { field x = "\\#{y}" field z = " \\\${y}" } }`, - [escapedFieldLiteralMessage('x'), escapedFieldLiteralMessage('z')], - ], - [ - 'on a listener as well', - `process p { user U { on start(class: "com.acme.L") { field x = "\\\${y" } } }`, - [escapedFieldLiteralMessage('x')], - ], - [ - 'an opener further in is plain text', - `process p { service S(class: "com.acme.D") { field x = "cost: \\\${y}" } }`, - [], - ], - [ - 'an empty literal has neither a fixed value nor an expression', - `process p { service S(class: "com.acme.D") { field x = "" } }`, - [emptyFieldMessage('x')], - ], - [ - 'an empty literal on a listener as well', - `process p { user U { on start(class: "com.acme.L") { field x = "" } } }`, - [emptyFieldMessage('x')], + 'an escaped template in a field value is text, not an expression, in every host', + `process p { + service S(class: "com.acme.D") { field x = "\\\${y" } + service S2(class: "com.acme.D2") { field x = "\\#{y}" field z = " \\\${y}" } + service S3(class: "com.acme.D3") { field x = "cost: \\\${y}" } + service S4(class: "com.acme.D4") { field x = "" } + user U { on start(class: "com.acme.L") { field x = "\\\${y" } } + user U2 { on start(class: "com.acme.L2") { field x = "" } } +}`, + [ + escapedFieldLiteralMessage('x'), + escapedFieldLiteralMessage('x'), + escapedFieldLiteralMessage('z'), + emptyFieldMessage('x'), + escapedFieldLiteralMessage('x'), + emptyFieldMessage('x'), + ], ], ]); checks('Validation - attribute keys and value shapes', [ [ - 'a repeated key in one block is one error naming it', - `process p { user T(assignee: "a", assignee: "b") }`, - [duplicateSetting('assignee')], - ], - [ - 'a second unkeyed value is dropped, so it is refused', - `process p { var x: number start S await message("M", x > 1) }`, - [SECOND_PAREN_VALUE_MESSAGE], + "an attribute's declared value shape is enforced regardless of element kind", + `process p { + var x: number + start S await message("M", x > 1) + user T(assignee: "a", assignee: "b") + user U(assignee: "demo", formKey: "f", candidateGroups: "approvers", candidateUsers: "ada", dueDate: "2026-09-01T09:00:00", followUpDate: "2026-08-30T09:00:00", priority: 10) + script T2(resultVariable: "total") ${FENCE}js\n1 + 1\n${FENCE} + user U2(asyncBefore: true, exclusive: "true") + user U3(asyncBefore: 1) + user U4(asyncBefore: flag) + user U5(asyncBefore: "\${flag}") + user U6(asyncBefore: true, retryCycle: "R3/PT10M", dueDate: "\${due}", followUpDate: "\${due}") + user U7(asyncBefore: true, retryCycle: 3) + user U8(asyncBefore: true, jobPriority: "\${weight}") +}`, + [ + warn(undeclared('flag')), + SECOND_PAREN_VALUE_MESSAGE, + duplicateSetting('assignee'), + quotedBoolean('exclusive'), + quotedBoolean('asyncBefore'), + quotedBoolean('asyncBefore'), + quotedBoolean('asyncBefore'), + unquotedText('retryCycle'), + ], ], [ 'the refusal counts one per extra value, whatever the element', @@ -1081,8 +886,11 @@ checks('Validation - attribute keys and value shapes', [ [settingsOnlyMessage('a process header')], ], [ - 'assignee on a service task is not valid there', - `process p { service S(assignee: "x") }`, + 'assignee and initiator are each valid only on their own element kind', + `process p { + start S2(initiator: claimant) + service S(assignee: "x") +}`, [ notValidOn('assignee', 'a service task'), bindingRequired(`Service task 'S'`, SERVICE_BINDINGS), @@ -1108,61 +916,11 @@ checks('Validation - attribute keys and value shapes', [ `process p { ${body} }`, [notValidOn(key, description)], ]), - [ - 'a user task accepts every key it owns', - `process p { user U(assignee: "demo", formKey: "f", candidateGroups: "approvers", candidateUsers: "ada", dueDate: "2026-09-01T09:00:00", followUpDate: "2026-08-30T09:00:00", priority: 10) }`, - [], - ], - [ - 'a script task accepts resultVariable', - `process p { script T(resultVariable: "total") ${FENCE}js\n1 + 1\n${FENCE} }`, - [], - ], - [ - 'a quoted boolean is one error naming the unquoted form', - `process p { user U(asyncBefore: true, exclusive: "true") }`, - [quotedBoolean('exclusive')], - ], - [ - 'a number in a boolean attribute is one error', - `process p { user U(asyncBefore: 1) }`, - [quotedBoolean('asyncBefore')], - ], - [ - 'a bareword in a boolean attribute is one error, and still reads as a variable', - `process p { user U(asyncBefore: flag) }`, - [warn(undeclared('flag')), quotedBoolean('asyncBefore')], - ], - [ - 'an expression in a boolean attribute is one error, since the lowering drops it', - `process p { user U(asyncBefore: "\${flag}") }`, - [quotedBoolean('asyncBefore')], - ], - [ - 'every engine-side text attribute takes a quoted string or an expression', - `process p { user U(asyncBefore: true, retryCycle: "R3/PT10M", dueDate: "\${due}", followUpDate: "\${due}") }`, - [], - ], - [ - 'a number in retryCycle asks for quotes', - `process p { user U(asyncBefore: true, retryCycle: 3) }`, - [unquotedText('retryCycle')], - ], [ 'a bareword starter list names a principal, not a variable', `process p(candidateStarterUsers: demo, candidateStarterGroups: adjusters) { start S }`, [], ], - [ - 'a bareword initiator names the variable the engine writes, not one in scope', - `process p { start S(initiator: claimant) }`, - [], - ], - [ - 'an expression in a numeric attribute carries no value-shape rule', - `process p { user U(asyncBefore: true, jobPriority: "\${weight}") }`, - [], - ], [ 'a key the element does not own is reported once, not once per rule', `process p { start S(dueDate: 3) }`, @@ -1172,81 +930,63 @@ checks('Validation - attribute keys and value shapes', [ checks('Validation - service, send, and decision bindings', [ [ - 'a service task with no binding names the five attributes', - `process p { service S { } }`, - [bindingRequired(`Service task 'S'`, SERVICE_BINDINGS)], - ], - [ - 'a service task with two bindings names both', - `process p { service S(class: com.example.X, expression: "\${bean.method(execution)}") }`, - [ - bindingConflict( - `Service task 'S'`, - 'class, expression', - SERVICE_BINDINGS, - ), - ], - ], - [ - 'an expression, a delegate, or a topic binding alone is enough', - `process p { service S(expression: "\${bean.method(execution)}") service T(delegate: "\${beanName}") service U(topic: "shipping") }`, - [], - ], - [ - 'keys that bind nothing leave a service task without a binding', - `process p { service V(resultVariable: "r", asyncBefore: true) }`, - [bindingRequired(`Service task 'V'`, SERVICE_BINDINGS)], - ], - [ - 'a service task cannot carry resultVariable beside class: the engine refuses to deploy it', - `process p { service V(class: com.example.X, resultVariable: "outcome") }`, - [resultVariableBindingMessage('A service task', 'class', 'serviceTask')], - ], - [ - 'a service task cannot carry resultVariable beside delegate either', - `process p { service V(delegate: "\${bean}", resultVariable: "outcome") }`, - [resultVariableBindingMessage('A service task', 'delegate', 'serviceTask')], - ], - [ - 'a send task bound with class refuses resultVariable under its own element name', - `process p { send N(class: com.example.X, resultVariable: "outcome") }`, - [resultVariableBindingMessage('A send task', 'class', 'sendTask')], - ], - [ - 'a decision step bound with delegate refuses resultVariable under its own element name', - `process p { decide D(delegate: "\${bean}", resultVariable: "outcome") }`, + "a service, send, or decision task's binding decides which keys it may carry", + `process p { + service S { } + service S2(expression: "\${bean.method(execution)}") service T(delegate: "\${beanName}") service U(topic: "shipping") + service V(resultVariable: "r", asyncBefore: true) + service V2(class: com.example.X, resultVariable: "outcome") + service V3(delegate: "\${bean}", resultVariable: "outcome") + send N(class: com.example.X2, resultVariable: "outcome") + decide D(delegate: "\${bean}", resultVariable: "outcome") + service V4(expression: "\${bean.method(execution)}", resultVariable: "outcome") + decide D2(decision: riskRating) + decide D3(decision: "riskRating", binding: latest, version: 3) + decide D4(decision: "riskRating", binding: version) + decide D5(decision: "riskRating", mapDecisionResult: nonsense) + receive R(message: "OrderPaid") +}`, [ + bindingRequired(`Service task 'S'`, SERVICE_BINDINGS), + bindingRequired(`Service task 'V'`, SERVICE_BINDINGS), + resultVariableBindingMessage('A service task', 'class', 'serviceTask'), + resultVariableBindingMessage('A service task', 'delegate', 'serviceTask'), + resultVariableBindingMessage('A send task', 'class', 'sendTask'), resultVariableBindingMessage( 'A decision step', 'delegate', 'businessRuleTask', ), + bindingVersionClash('A decision step'), + BINDING_IS_VERSION, + MAP_DECISION_RESULT, ], ], [ - 'resultVariable beside an expression binding is where the engine stores the return value', - `process p { service V(expression: "\${bean.method(execution)}", resultVariable: "outcome") }`, - [], - ], - [ - 'a send task with no binding names the five attributes', - `process p { send N { } }`, - [bindingRequired(`Send task 'N'`, SERVICE_BINDINGS)], - ], - [ - 'a send task with two bindings names both', - `process p { send N(class: "com.example.Send", topic: "t") }`, - [bindingConflict(`Send task 'N'`, 'class, topic', SERVICE_BINDINGS)], - ], - [ - 'a decision step with no binding names the six attributes', - `process p { decide D { } }`, - [bindingRequired(`Decision step 'D'`, DECISION_BINDINGS)], + 'a doubled or missing binding on a service, send, or decision task is fully named', + `process p { + service S(class: com.example.X, expression: "\${bean.method(execution)}") + send N { } + decide D { } +}`, + [ + bindingConflict( + `Service task 'S'`, + 'class, expression', + SERVICE_BINDINGS, + ), + bindingRequired(`Send task 'N'`, SERVICE_BINDINGS), + bindingRequired(`Decision step 'D'`, DECISION_BINDINGS), + ], ], [ - 'a decision step bound to both a decision and code names both', - `process p { decide D(decision: "riskRating", class: "com.example.Rate") }`, + 'a send task or a decision step names both bindings when it carries two', + `process p { + send N(class: "com.example.Send", topic: "t") + decide D(decision: "riskRating", class: "com.example.Rate") +}`, [ + bindingConflict(`Send task 'N'`, 'class, topic', SERVICE_BINDINGS), bindingConflict( `Decision step 'D'`, 'decision, class', @@ -1254,48 +994,24 @@ checks('Validation - service, send, and decision bindings', [ ), ], ], - [ - 'a bareword decision names a decision table, not a variable', - `process p { decide D(decision: riskRating) }`, - [], - ], - [ - 'a decision step combining binding and version is one error', - `process p { decide D(decision: "riskRating", binding: latest, version: 3) }`, - [bindingVersionClash('A decision step')], - ], - [ - "'binding: version' on a decision step points at the version setting", - `process p { decide D(decision: "riskRating", binding: version) }`, - [BINDING_IS_VERSION], - ], - [ - 'an unrecognized mapDecisionResult names the four mappings', - `process p { decide D(decision: "riskRating", mapDecisionResult: nonsense) }`, - [MAP_DECISION_RESULT], - ], - [ - 'a receive task takes a message name', - `process p { receive R(message: "OrderPaid") }`, - [], - ], ]); checks('Validation - settings the engine reads under one binding alone', [ [ - 'resultVariable beside a topic warns that nothing writes it', - `process p { service V(topic: "t", resultVariable: "r") }`, - [warn(resultVariableUnreadMessage('topic'))], - ], - [ - 'resultVariable beside a topic on a decision step warns the same way', - `process p { decide D(topic: "t", resultVariable: "r") }`, - [warn(resultVariableUnreadMessage('topic'))], - ], - [ - 'mapDecisionResult with no resultVariable is built and never applied', - `process p { decide D(decision: "riskRating", mapDecisionResult: singleEntry) }`, - [warn(MAP_DECISION_RESULT_UNREAD_MESSAGE)], + 'resultVariable and mapDecisionResult beside a topic binding are each refused once', + `process p { + service V(topic: "t", resultVariable: "r") + decide D(topic: "t", resultVariable: "r") + decide D2(decision: "riskRating", mapDecisionResult: singleEntry) + decide D3(topic: "t", binding: latest, mapDecisionResult: singleEntry) +}`, + [ + warn(resultVariableUnreadMessage('topic')), + warn(resultVariableUnreadMessage('topic')), + warn(MAP_DECISION_RESULT_UNREAD_MESSAGE), + decisionModifierMessage('binding'), + decisionModifierMessage('mapDecisionResult'), + ], ], ...['singleEntry', 'singleResult', 'collectEntries', 'resultList'].map( (mapping): Case => [ @@ -1313,115 +1029,125 @@ checks('Validation - settings the engine reads under one binding alone', [ `process p { decide D(class: "com.example.Rate", ${setting}) }`, [decisionModifierMessage(key!)], ]), - [ - 'two modifiers beside a topic are each refused once', - `process p { decide D(topic: "t", binding: latest, mapDecisionResult: singleEntry) }`, - [ - decisionModifierMessage('binding'), - decisionModifierMessage('mapDecisionResult'), - ], - ], ]); const userWith = (setting: string) => `process p { user U(${setting}) }`; -const VERSION_CARRIERS: Array<[carrier: string, at: (v: string) => string]> = [ - ['a call', (v) => `process p { call C(process: "q", version: ${v}) }`], - [ - 'a decision step', - (v) => `process p { decide D(decision: "riskRating", version: ${v}) }`, - ], - [ - 'a form reference', - (v) => userWith(`formRef: "review-form", version: ${v}`), - ], +type ValueShape = [ + key: string, + program: (value: string) => string, + parsed: string[], + refused: string[], + message: string, ]; -checks('Validation - values the engine parses when the step runs', [ - ...['7', '"7"', '"${p}"', 'weight'].map((value): Case => [ - `priority ${value} parses or evaluates to an integer`, - `process p { var weight: number user U(priority: ${value}) }`, - [], - ]), - ...['1.5', '"high"'].map((value): Case => [ - `priority ${value} fails the task creation, so it is refused`, - userWith(`priority: ${value}`), - [priorityShapeMessage('priority')], - ]), - ...VERSION_CARRIERS.flatMap(([carrier, at]): Case[] => [ - ...['2', '"2"', '"${v}"'].map((value): Case => [ - `version ${value} on ${carrier} converts to an integer`, - at(value), - [], - ]), - ...['1.5', '"abc"', '-1', '0', 'v'].map((value): Case => [ - `version ${value} on ${carrier} fails at the step, so it is refused`, - at(value), - [VERSION_SHAPE_MESSAGE], - ]), - ]), - ...['"P2D"', '"2026-01-01T00:00:00"', '"${d}"'].map((value): Case => [ - `dueDate ${value} is a shape the business calendar resolves`, - userWith(`dueDate: ${value}`), - [], - ]), - [ - 'a due date the calendar cannot resolve is refused on both keys', - userWith('dueDate: "tomorrow", followUpDate: "next week"'), - [dueDateShapeMessage('dueDate'), dueDateShapeMessage('followUpDate')], - ], - ...['"PT10M"', '"R3/PT10M"', '"PT5M,PT10M"', '"${r}"'].map((value): Case => [ - `retryCycle ${value} is a shape the retry parser resolves`, - userWith(`asyncBefore: true, retryCycle: ${value}`), - [], +const VALUE_SHAPES: ValueShape[] = [ + [ + 'priority', + (v) => `process p { var weight: number user U(priority: ${v}) }`, + ['7', '"7"', '"${p}"', 'weight'], + ['1.5', '"high"'], + priorityShapeMessage('priority'), + ], + ...Object.entries({ + 'a call': (v: string) => + `process p { call C(process: "q", version: ${v}) }`, + 'a decision step': (v: string) => + `process p { decide D(decision: "riskRating", version: ${v}) }`, + 'a form reference': (v: string) => + userWith(`formRef: "review-form", version: ${v}`), + }).map(([carrier, at]): ValueShape => [ + `version on ${carrier}`, + at, + ['2', '"2"', '"${v}"'], + ['1.5', '"abc"', '-1', '0', 'v'], + VERSION_SHAPE_MESSAGE, ]), [ - 'a retryCycle the parser cannot resolve is dropped, so it only warns', - userWith('asyncBefore: true, retryCycle: "bogus"'), - [warn(RETRY_CYCLE_SHAPE_MESSAGE)], - ], -]); - -checks('Validation - the mail and shell bindings', [ - [ - 'a mail task with a recipient and a text body is clean', - `process p { service N(type: "mail") { field to = "a@b" field text = "t" } }`, - [], + 'dueDate', + (v) => userWith(`dueDate: ${v}`), + ['"P2D"', '"2026-01-01T00:00:00"', '"${d}"'], + ['"tomorrow"'], + dueDateShapeMessage('dueDate'), ], [ - 'a shell send task with a command, an argument, an output variable, and a flag is clean', - `process p { send S(type: "shell") { field command = "echo" field arg1 = "x" field outputVariable = "o" field wait = "true" } }`, - [], + 'followUpDate', + (v) => userWith(`followUpDate: ${v}`), + ['"P2D"'], + ['"next week"'], + dueDateShapeMessage('followUpDate'), ], [ - 'a shell decision step with a command alone is clean', - `process p { decide D(type: "shell") { field command = "true" } }`, - [], - ], - [ - 'the type is read bare as it is read quoted', - `process p { service N(type: mail) { field to = "a@b" field text = "t" } }`, - [], - ], - [ - 'an upper-case type is refused: one printed form keeps the round trip stable', - `process p { service N(type: "MAIL") }`, - [TYPE_VALUE_MESSAGE], + 'retryCycle', + (v) => userWith(`asyncBefore: true, retryCycle: ${v}`), + ['"PT10M"', '"R3/PT10M"', '"PT5M,PT10M"', '"${r}"'], + ['"bogus"'], + warn(RETRY_CYCLE_SHAPE_MESSAGE), ], +]; + +describe('Validation - values the engine parses when the step runs', () => { + test.each(VALUE_SHAPES)( + '%s is refused exactly when the engine cannot parse it', + async (_key, program, parsed, refused, message) => { + const values = [...parsed, ...refused]; + expect( + await Promise.all(values.map((v) => diagnosticsOf(program(v)))), + ).toEqual(values.map((v) => (refused.includes(v) ? [message] : []))); + }, + ); +}); + +checks('Validation - the mail and shell bindings', [ [ - 'a type the engine has no behaviour for names the two it has', - `process p { service N(type: "ftp") }`, - [TYPE_VALUE_MESSAGE], + "mail and shell task fields validate against each behaviour's own declared shape", + `process p { + start S2 emit message("Ack", type: "shell") + service N(type: "mail") { field to = "a@b" field text = "t" } + send S(type: "shell") { field command = "echo" field arg1 = "x" field outputVariable = "o" field wait = "true" } + decide D(type: "shell") { field command = "true" } + service N2(type: mail) { field to = "a@b" field text = "t" } + service N3(type: "MAIL") + service N4(type: "ftp") + service N5(type: "mail") { field to = "a@b" field text = "t" field html = "

t

" } + service R(type: "shell") { field command = "\${cmd}" } + service R2(type: "shell") { field command = "ls" field wait = "True" } + service N6(type: "mail") { field to = "a@b" field text = "t" field recipient = "x" } + service R3(type: "shell") { field command = "ls" field args = "x" } + service R4(type: "shell") { field command = ["ls"] } + user U(type: "mail") + service R5(type: "shell", resultVariable: "r") { field command = "ls" } +}`, + [ + notValidOn('type', 'an emit statement'), + TYPE_VALUE_MESSAGE, + TYPE_VALUE_MESSAGE, + shellFieldExpressionMessage('command'), + shellFlagValueMessage('wait'), + unknownBuiltinFieldMessage('recipient', 'mail'), + unknownBuiltinFieldMessage('args', 'shell'), + fieldValueMessage('command'), + notValidOn('type', 'a user task'), + warn(resultVariableUnreadMessage('type')), + ], ], [ - 'a cc does not stand in for the recipient', - `process p { service N(type: "mail") { field cc = "c@d" field text = "t" } }`, + 'a mail cc is not a recipient, and a shell command is required', + `process p { + service N(type: "mail") { field cc = "c@d" field text = "t" } + send S(type: "shell") { field wait = "true" } +}`, [ missingBuiltinFieldMessage( `Service task 'N'`, 'mail', BUILTIN_REQUIRED_FIELDS.mail[0]!, ), + missingBuiltinFieldMessage( + `Send task 'S'`, + 'shell', + BUILTIN_REQUIRED_FIELDS.shell[0]!, + ), ], ], [ @@ -1435,11 +1161,6 @@ checks('Validation - the mail and shell bindings', [ ), ], ], - [ - 'a mail task with both bodies is clean', - `process p { service N(type: "mail") { field to = "a@b" field text = "t" field html = "

t

" } }`, - [], - ], [ 'a mail task with no field at all draws one refusal per requirement', `process p { service N(type: "mail") }`, @@ -1456,42 +1177,6 @@ checks('Validation - the mail and shell bindings', [ ), ], ], - [ - 'a shell task without a command names the engine check', - `process p { send S(type: "shell") { field wait = "true" } }`, - [ - missingBuiltinFieldMessage( - `Send task 'S'`, - 'shell', - BUILTIN_REQUIRED_FIELDS.shell[0]!, - ), - ], - ], - [ - 'a shell field written as an expression fails the deployment, so it is refused', - `process p { service R(type: "shell") { field command = "\${cmd}" } }`, - [shellFieldExpressionMessage('command')], - ], - [ - 'a shell flag the engine would read as false is refused', - `process p { service R(type: "shell") { field command = "ls" field wait = "True" } }`, - [shellFlagValueMessage('wait')], - ], - [ - 'a field the mail behaviour does not declare names the ones it does', - `process p { service N(type: "mail") { field to = "a@b" field text = "t" field recipient = "x" } }`, - [unknownBuiltinFieldMessage('recipient', 'mail')], - ], - [ - 'a field the shell behaviour does not declare names the ones it does', - `process p { service R(type: "shell") { field command = "ls" field args = "x" } }`, - [unknownBuiltinFieldMessage('args', 'shell')], - ], - [ - "a field's own shape is refused first, and the shell rules stand down", - `process p { service R(type: "shell") { field command = ["ls"] } }`, - [fieldValueMessage('command')], - ], [ 'a type beside a code binding is the one-binding conflict and nothing more', `process p { service N(class: "com.example.X", type: "mail") }`, @@ -1502,21 +1187,6 @@ checks('Validation - the mail and shell bindings', [ `process p { start S throw message("Ack", type: "mail") }`, [notValidOn('type', 'a throw statement')], ], - [ - 'an emitted message takes none either', - `process p { start S emit message("Ack", type: "shell") }`, - [notValidOn('type', 'an emit statement')], - ], - [ - 'a user task takes no type', - `process p { user U(type: "mail") }`, - [notValidOn('type', 'a user task')], - ], - [ - 'a result variable beside a type warns that nothing writes it, as beside a topic', - `process p { service R(type: "shell", resultVariable: "r") { field command = "ls" } }`, - [warn(resultVariableUnreadMessage('type'))], - ], ]); checks('Validation - script tasks and fenced scripts', [ @@ -1543,40 +1213,33 @@ checks('Validation - script tasks and fenced scripts', [ checks('Validation - goto', [ [ - 'an unresolved goto is the linker error alone', - `process p { user Foo goto Missing }`, + 'a goto resolves to any flow step kind, or is an unresolved linker error alone', + `process p { + var c: boolean + user Foo goto Missing + user Foo2 service Ship(topic: "shipping") script Compute ${FENCE}js\nx = 1\n${FENCE} call F(process: "p") if (c) { goto Foo2 } if (c) { goto Ship } if (c) { goto Compute } goto F +}`, [missingStep('Missing')], ], - [ - 'a goto resolves to a user task, a service task, a script task, and a call', - `process p { var c: boolean user Foo service Ship(topic: "shipping") script Compute ${FENCE}js\nx = 1\n${FENCE} call F(process: "p") if (c) { goto Foo } if (c) { goto Ship } if (c) { goto Compute } goto F }`, - [], - ], [ 'a goto from outside into a parallel branch is one error', `process p { parallel { { user A } { user B } } goto A }`, [gotoIntoBranch('A', 'parallel')], ], [ - 'a goto from one parallel branch into its sibling is one error', - `process p { parallel { { user A goto B } { user B } } }`, + 'a goto stays clean within its own branch but not across parallel siblings', + `process p { + parallel { { user A goto B } { user B } } + parallel { { user A2 goto A2 } { user B2 } } + await { message("M") { user A3 goto A3 } signal("S") { user B3 } } +}`, [gotoIntoBranch('B', 'parallel')], ], - [ - 'a goto within one parallel branch is clean', - `process p { parallel { { user A goto A } { user B } } }`, - [], - ], [ 'a goto from outside into a race branch names the await statement', `process p { await { message("M") { user A } signal("S") { user B } } goto A }`, [gotoIntoBranch('A', 'await')], ], - [ - 'a goto within one race branch is clean', - `process p { await { message("M") { user A goto A } signal("S") { user B } } }`, - [], - ], ]); checks('Validation - a body with no flow steps', [ @@ -1590,7 +1253,14 @@ checks('Validation - a body with no flow steps', [ `process p { error Boom on error(Boom) { end H } }`, [noFlowSteps('p')], ], - ['a start alone is a flow step', `process p { start S }`, []], + [ + 'an empty body is refused by naming the step that would need one', + `process p { + start S + attempt A { } +}`, + [blockNoFlowSteps('an attempt block', 'A')], + ], [ 'an empty subprocess body names the subprocess head', `process p { subprocess S { } }`, @@ -1601,64 +1271,41 @@ checks('Validation - a body with no flow steps', [ `process p { error Boom subprocess S { on error(Boom) { end H } } }`, [blockNoFlowSteps('a subprocess', 'S')], ], - [ - 'an empty attempt body names the attempt head instead', - `process p { attempt A { } }`, - [blockNoFlowSteps('an attempt block', 'A')], - ], ]); checks('Validation - empty branches and bodies', [ [ - "an empty 'if' branch is one warning", - `process p { var flag: boolean if (flag == true) { } }`, - [warn(emptyBranch(`'if' branch`))], - ], - [ - "an empty 'else if' branch is one warning", - `process p { var flag: boolean if (flag == true) { user A } else if (flag == false) { } }`, - [warn(emptyBranch(`'else if' branch`))], - ], - [ - "an empty 'else' branch is one warning", - `process p { var flag: boolean if (flag == true) { user A } else { } }`, - [warn(emptyBranch(`'else' branch`))], - ], - [ - "an empty 'while' body would lose the loop's condition", - `process p { var flag: boolean while (flag == true) { } }`, - [emptyLoopBodyMessage('while')], - ], - [ - "an empty 'do ... while' body would lose the loop's condition", - `process p { var flag: boolean do { } while (flag == true) }`, - [emptyLoopBodyMessage('do')], - ], - [ - 'a parameter written in a handler body configures nothing', - `process p { user T on message("Late") { input who = "x" end LateEnd } }`, - [blockParameter('input')], + 'an empty branch or body is warned about, naming what it would lose', + `process p { + var flag: boolean + var c: boolean + if (flag == true) { } + if (flag == true) { user A } else if (flag == false) { } + if (flag == true) { user A2 } else { } + while (flag == true) { } + do { } while (flag == true) + while (c) { user A3 output y = 1 } + parallel { { user A4 } { } } + await { message("M") { } signal("S") { user B } } + user T on message("Late") { input who = "x" end LateEnd } +}`, + [ + warn(emptyBranch(`'if' branch`)), + warn(emptyBranch(`'else if' branch`)), + warn(emptyBranch(`'else' branch`)), + emptyLoopBodyMessage('while'), + emptyLoopBodyMessage('do'), + blockParameter('output'), + warn(emptyNumberedBranch(2, 'parallel')), + warn(emptyNumberedBranch(1, 'await')), + blockParameter('input'), + ], ], [ 'a listener written first in a subprocess body observes nothing', `process p { subprocess S { on start(class: "x.L") user A } }`, [BLOCK_LISTENER], ], - [ - 'a parameter written in a loop body configures nothing', - `process p { var c: boolean while (c) { user A output y = 1 } }`, - [blockParameter('output')], - ], - [ - 'an empty parallel branch is one warning naming its position', - `process p { parallel { { user A } { } } }`, - [warn(emptyNumberedBranch(2, 'parallel'))], - ], - [ - 'an empty race branch is one warning naming its position', - `process p { await { message("M") { } signal("S") { user B } } }`, - [warn(emptyNumberedBranch(1, 'await'))], - ], [ 'an empty handler body is one warning', `process p { error X on error(X) { } }`, @@ -1668,13 +1315,32 @@ checks('Validation - empty branches and bodies', [ checks('Validation - reserved synthesized-id names', [ [ - 'every Gateway_ suffix the desugarer synthesizes is reserved', + 'synthesized ids the desugarer or layouter would generate are reserved names', `process p { start Gateway_foo_split user Gateway_invoice-approval_2_join service Gateway_p_0_fork(class: com.example.X) user Gateway_p_1_loop user Gateway_p_1_race + user EndEvent_Boundary_T_error + user StartEvent_EventSubProcess_p_1 + user EndEvent_EventSubProcess_p_1 + subprocess Gateway_x_split { user A } + subprocess Throw_foo { user B } + call Catch_x(process: "p") + user StartEvent_p user EndEvent_p + subprocess S { user EndEvent_S } attempt T { user StartEvent_T } + user EndEvent_S2 subprocess S2 { user A2 } + user S_di + user BPMNDiagram_p + user BPMNPlane_p + user GatewayCheck + user MyFlow_Thing + user Flow_Control + user Flow_State + user StartEventHandler + user EndEventHandler + user Gateway_split }`, [ reservedName('Gateway_foo_split'), @@ -1682,6 +1348,19 @@ checks('Validation - reserved synthesized-id names', [ reservedName('Gateway_p_0_fork'), reservedName('Gateway_p_1_loop'), reservedName('Gateway_p_1_race'), + reservedName('EndEvent_Boundary_T_error'), + reservedName('StartEvent_EventSubProcess_p_1'), + reservedName('EndEvent_EventSubProcess_p_1'), + reservedName('Gateway_x_split'), + reservedName('Throw_foo'), + reservedName('Catch_x'), + mintedTerminal('StartEvent_p', 'start', 'p'), + mintedTerminal('EndEvent_p', 'end', 'p'), + mintedTerminal('EndEvent_S', 'end', 'S'), + mintedTerminal('StartEvent_T', 'start', 'T'), + reservedName('S_di'), + reservedName('BPMNDiagram_p'), + reservedName('BPMNPlane_p'), ], ], [ @@ -1690,13 +1369,14 @@ checks('Validation - reserved synthesized-id names', [ [reservedName('Gateway__p_split')], ], [ - 'every reserved prefix, and the two-segment Flow_ shape, is rejected', + 'every reserved id prefix and shape is rejected, wherever it is written', `process p { start Flow_A_B user Boundary_X_error user Throw_p_1 user EventSubProcess_x user Catch_p_1 + subprocess StartEvent_p { user A } }`, [ reservedName('Flow_A_B'), @@ -1704,42 +1384,7 @@ checks('Validation - reserved synthesized-id names', [ reservedName('Throw_p_1'), reservedName('EventSubProcess_x'), reservedName('Catch_p_1'), - ], - ], - [ - // The desugarer mints these off a handler's own coordinate, never the - // enclosing process or subprocess name, so only a prefix catches them. - 'a boundary escape end and a handler body start/end are rejected under any container', - `process p { - user EndEvent_Boundary_T_error - user StartEvent_EventSubProcess_p_1 - user EndEvent_EventSubProcess_p_1 -}`, - [ - reservedName('EndEvent_Boundary_T_error'), - reservedName('StartEvent_EventSubProcess_p_1'), - reservedName('EndEvent_EventSubProcess_p_1'), - ], - ], - [ - 'a subprocess and a call carry the same rule', - `process p { - subprocess Gateway_x_split { user A } - subprocess Throw_foo { user B } - call Catch_x(process: "p") -}`, - [ - reservedName('Gateway_x_split'), - reservedName('Throw_foo'), - reservedName('Catch_x'), - ], - ], - [ - 'the exact implicit start and end of the process body are reserved', - `process p { user StartEvent_p user EndEvent_p }`, - [ mintedTerminal('StartEvent_p', 'start', 'p'), - mintedTerminal('EndEvent_p', 'end', 'p'), ], ], [ @@ -1747,52 +1392,6 @@ checks('Validation - reserved synthesized-id names', [ `process p { start StartEvent_1 user EndEvent_1 }`, [], ], - [ - 'a subprocess body reserves its own implicit end, under either head word', - `process p { subprocess S { user EndEvent_S } attempt T { user StartEvent_T } }`, - [ - mintedTerminal('EndEvent_S', 'end', 'S'), - mintedTerminal('StartEvent_T', 'start', 'T'), - ], - ], - [ - "another container's implicit end is free outside that container", - `process p { user EndEvent_S subprocess S { user A } }`, - [], - ], - [ - 'the subprocess statement itself sits in the enclosing body', - `process p { subprocess StartEvent_p { user A } }`, - [mintedTerminal('StartEvent_p', 'start', 'p')], - ], - [ - "the layouter's own diagram, plane, and per-shape ids are reserved too", - `process p { - user S_di - user BPMNDiagram_p - user BPMNPlane_p -}`, - [ - reservedName('S_di'), - reservedName('BPMNDiagram_p'), - reservedName('BPMNPlane_p'), - ], - ], - // A single-segment `Flow_` name cannot match `Flow__`; the rest - // miss the prefix or the suffix. - [ - 'a name merely resembling one of the shapes is free', - `process p { - user GatewayCheck - user MyFlow_Thing - user Flow_Control - user Flow_State - user StartEventHandler - user EndEventHandler - user Gateway_split -}`, - [], - ], ]); checks('Validation - one process per file', [ @@ -1830,24 +1429,22 @@ checks('Validation - where a start may sit', [ [startNotFirst('Nested')], ], [ - 'a start opening a host-less handler body is legal', - `process p { error PF service A(class: "x.A") on error(PF) { start S service R(class: "x.R") } }`, - [], - ], - [ - 'a start opening a hosted handler body has no scope of its own', - `process p { error PF service A(class: "x.A") on A: error(PF) { start S service R(class: "x.R") } }`, - [hostedHandlerStartMessage('S')], - ], - [ - 'a start first in a subprocess body is clean', - `process p { subprocess S { start In user A end Out } }`, + 'a start event is clean when it opens a handler or subprocess body', + `process p { + error PF + subprocess S2 { start In user A2 end Out } + service A(class: "x.A") on error(PF) { start S service R(class: "x.R") } +}`, [], ], [ - 'a start second in a subprocess body is one error', - `process p { subprocess S { user A start In } }`, - [startNotFirst('In')], + 'a start out of place in a hosted handler or subprocess body is one error', + `process p { + error PF + subprocess S2 { user A2 start In } + service A(class: "x.A") on A: error(PF) { start S service R(class: "x.R") } +}`, + [startNotFirst('In'), hostedHandlerStartMessage('S')], ], ]); @@ -1858,13 +1455,11 @@ checks('Validation - the default start among several', [ [warn(noDefaultStartMessage('p'))], ], [ - 'a timer start beside a message start is the default', - `process p { start A timer(every: "R/PT1H") start B message("M") user T end E }`, - [], - ], - [ - 'a single message start is its own default', - `process p { start A message("M") user T end E }`, + 'the default start among several is picked by trigger kind, or by being alone', + `process p { + start A timer(every: "R/PT1H") start B message("M") user T end E + start A2 message("M2") user T2 end E2 +}`, [], ], [ @@ -1964,50 +1559,43 @@ checks('Validation - duplicate declarations', [ [duplicateSetting('label')], ], [ - 'two steps with one name make a goto ambiguous', - `process p { user Review user Review }`, - [duplicateStepName('Review', 'p')], - ], - [ - 'a step named like the process id collides with it in the compiled document', - `process p { user p }`, - [stepNameEqualsProcess('p')], - ], - [ - 'the ambiguity crosses element kinds', - `process p { service A(topic: "shipping") script A ${FENCE}js\nx = 1\n${FENCE} }`, - [duplicateStepName('A', 'p')], - ], - [ - 'two subprocesses with one name is one error', - `process p { subprocess S { user A } subprocess S { user B } }`, - [duplicateStepName('S', 'p')], + 'a duplicate name collides across element kinds, the process id, and goto targets', + `process p { + error X + escalation Y + user Review user Review + user p + service A(topic: "shipping") script A ${FENCE}js\nx = 1\n${FENCE} + subprocess S { user A2 } subprocess S { user B } + throw error Same(X) throw escalation Same(Y) +}`, + [ + duplicateStepName('Review', 'p'), + duplicateStepName('A', 'p'), + duplicateStepName('S', 'p'), + duplicateStepName('Same', 'p'), + stepNameEqualsProcess('p'), + UNREACHABLE, + ], ], [ - 'a step inside a subprocess reusing a parent step name is one error', - `process p { user A subprocess S { user A } }`, - [duplicateStepName('A', 'p')], + 'a subprocess step name collides with its parent, and an await shares that namespace', + `process p { + user A subprocess S { user A } + user Wait await message Wait("M") +}`, + [duplicateStepName('A', 'p'), duplicateStepName('Wait', 'p')], ], [ 'a call sharing a name with a task is one error', `process p { user A call A(process: "p") }`, [duplicateStepName('A', 'p')], ], - [ - 'two named throws sharing a name is one error, and the second is dead', - `process p { error X escalation Y throw error Same(X) throw escalation Same(Y) }`, - [duplicateStepName('Same', 'p'), UNREACHABLE], - ], - [ - 'a named await shares the step namespace', - `process p { user Wait await message Wait("M") }`, - [duplicateStepName('Wait', 'p')], - ], ]); checks('Validation - parallel branch heads', [ [ - "an 'else' branch beside conditioned siblings is clean", + 'an else branch needs a conditioned sibling to fall back from', `process p { var amount: number parallel { @@ -2015,8 +1603,9 @@ checks('Validation - parallel branch heads', [ if (amount > 0) { service RecordReceipt(topic: "receipts") } else { user ManualTriage } } + parallel { { user A } else { user B } } }`, - [], + [PARALLEL_ELSE_WITHOUT_CONDITION_MESSAGE], ], [ "an 'else' branch beside an unconditioned one could never run", @@ -2042,11 +1631,6 @@ checks('Validation - parallel branch heads', [ }`, [PARALLEL_SECOND_ELSE_MESSAGE], ], - [ - "an 'else' branch with no conditioned sibling has nothing to fall back from", - `process p { parallel { { user A } else { user B } } }`, - [PARALLEL_ELSE_WITHOUT_CONDITION_MESSAGE], - ], ]); describe('Validation - parallel branch heads, reported in place', () => { @@ -2066,10 +1650,7 @@ process p { }); }); -/** - * The `if` carries an `else if`, so a row on it also pins that the chain's one - * head takes the settings for the whole chain. - */ +/** The `if` carries an `else if`, pinning that the chain's one head takes the settings. */ const GATEWAY_HOSTS: ReadonlyArray< [kind: string, description: string, head: (items: string) => string] > = [ @@ -2108,7 +1689,6 @@ const ifHead = hostOf('if'); const awaitHead = hostOf('await'); checks('Validation - gateway settings', [ - // The await head leaves `asyncAfter` out: its own row below refuses it. ...GATEWAY_HOSTS.map(([kind, , head]): Case => [ `${kind} takes every head key`, head( @@ -2369,7 +1949,7 @@ checks('Validation - job-setting pairing', [ [], ], [ - 'a signal start, an awaited signal and a signal race branch price their subscription job with jobPriority alone; exclusive and retryCycle still need the flag, and a signal handler declares no such job', + 'jobPriority alone prices a signal subscription, other settings still need their flag', `process p { start Begin signal("S1", jobPriority: 5, exclusive: false, retryCycle: "PT1M") await signal("S2", jobPriority: 5) @@ -2404,12 +1984,13 @@ checks('Validation - job-setting pairing', [ checks('Validation - form fields', [ [ - 'a form on a start event and on a user task is clean', + 'a form field belongs only on a start event or a user task', `process p { start Begin { form { amount: number "Amount" } } user Approve(assignee: "demo") { form { approved: boolean "OK?" = false } } + service S(class: "com.x.Y") { form { a: number } } }`, - [], + [noFormBlock('a service task')], ], [ 'a form field declares the variable it binds', @@ -2426,11 +2007,6 @@ checks('Validation - form fields', [ `process p { start Begin { form { blob: json "Blob" (minlength: 2) } } }`, [formFieldType('blob', 'json')], ], - [ - 'a form block on a service task belongs elsewhere', - `process p { service S(class: "com.x.Y") { form { a: number } } }`, - [noFormBlock('a service task')], - ], [ 'a bare attribute on a start event is not valid there', `process p { start Begin(assignee: "demo") }`, @@ -2478,39 +2054,43 @@ checks('Validation - form fields', [ `process p { start S { form { amount: number (minimum: 0) } } }`, [unknownFormFieldSettingMessage('amount', 'minimum')], ], - ...formFieldRows('a constraint fits the type its validator checks', [ - ['min: 0', 'string', [constraintMisfit('min', 'f', 'string', 'number')]], + formFieldRows('a constraint fits the type its validator checks', [ + [ + 'min: 0', + 'string', + (f) => [constraintMisfit('min', f, 'string', 'number')], + ], [ 'maxlength: 2', 'number', - [constraintMisfit('maxlength', 'f', 'number', 'string')], + (f) => [constraintMisfit('maxlength', f, 'number', 'string')], ], [ 'minlength: 2', 'date', - [constraintMisfit('minlength', 'f', 'date', 'string')], + (f) => [constraintMisfit('minlength', f, 'date', 'string')], ], - ['pattern: "dd/MM/yyyy"', 'string', [patternMisfit('f', 'string')]], - ['pattern: "dd/MM/yyyy"', 'enum', [patternMisfit('f', 'enum')]], - ['min: 0', 'number', []], - ['minlength: 2', 'string', []], - ['required: true', 'boolean', []], - ['pattern: "dd/MM/yyyy"', 'date', []], + ['pattern: "dd/MM/yyyy"', 'string', (f) => [patternMisfit(f, 'string')]], + ['pattern: "dd/MM/yyyy"', 'enum', (f) => [patternMisfit(f, 'enum')]], + ['min: 0', 'number', () => []], + ['minlength: 2', 'string', () => []], + ['required: true', 'boolean', () => []], + ['pattern: "dd/MM/yyyy"', 'date', () => []], ]), - ...formFieldRows('a constraint takes one value shape', [ - ['required: false', 'string', [flagFalseMessage('required')]], - ['required: "true"', 'string', [flagNotTrueMessage('required')]], - ['min: "abc"', 'number', [integerBoundMessage('min')]], - ['max: 1.5', 'number', [integerBoundMessage('max')]], - ['minlength: 2.5', 'string', [integerBoundMessage('minlength')]], - ['pattern: ""', 'date', [PATTERN_VALUE_MESSAGE]], - ['min: -5', 'number', []], - ['min: "-5"', 'number', []], - ['maxlength: "80"', 'string', []], - ['validator: com.example.Check', 'string', []], - ['validator: ""', 'string', [VALIDATOR_EMPTY_MESSAGE]], - ['pattern: "dd-xx-yyyy"', 'date', [PATTERN_LETTERS_MESSAGE]], - [`pattern: "'T'HH:mm"`, 'date', []], + formFieldRows('a constraint takes one value shape', [ + ['required: false', 'string', () => [flagFalseMessage('required')]], + ['required: "true"', 'string', () => [flagNotTrueMessage('required')]], + ['min: "abc"', 'number', () => [integerBoundMessage('min')]], + ['max: 1.5', 'number', () => [integerBoundMessage('max')]], + ['minlength: 2.5', 'string', () => [integerBoundMessage('minlength')]], + ['pattern: ""', 'date', () => [PATTERN_VALUE_MESSAGE]], + ['min: -5', 'number', () => []], + ['min: "-5"', 'number', () => []], + ['maxlength: "80"', 'string', () => []], + ['validator: com.example.Check', 'string', () => []], + ['validator: ""', 'string', () => [VALIDATOR_EMPTY_MESSAGE]], + ['pattern: "dd-xx-yyyy"', 'date', () => [PATTERN_LETTERS_MESSAGE]], + [`pattern: "'T'HH:mm"`, 'date', () => []], ]), [ "a bare word in a field's parens is not a setting", @@ -2602,19 +2182,29 @@ checks('Validation - form field defaults', [ checks('Validation - unreachable statements', [ [ - 'every dead step after an end is reported', - `process p { start S end Done user A user B }`, - [UNREACHABLE, UNREACHABLE], - ], - [ - 'a step after a goto can never run', - `process p { user A goto A user Dead }`, - [UNREACHABLE], + 'a step after an end or a goto is reported as unreachable everywhere', + `process p { + start S end Done user A user B + user A2 goto A2 user Dead + await { message("M") { end A3 user DeadInside } signal("S2") { end B2 } } user DeadAfter + subprocess S3 { start In end Out user Dead2 } +}`, + [ + UNREACHABLE, + UNREACHABLE, + UNREACHABLE, + UNREACHABLE, + UNREACHABLE, + UNREACHABLE, + ], ], [ - 'a goto target after an end stays reachable', - `process p { start S if (cond) { goto Retry } end Done user Retry }`, - [warn(undeclared('cond'))], + 'a goto target survives past an end, but a step after total termination does not', + `process p { + start S if (cond) { goto Retry } end Done user Retry + if (cond) { end A } else { end B } user Dead +}`, + [warn(undeclared('cond')), warn(undeclared('cond')), UNREACHABLE], ], [ 'an unreachable compound is reported once, not once per nested step', @@ -2622,23 +2212,21 @@ checks('Validation - unreachable statements', [ [warn(undeclared('cond')), UNREACHABLE], ], [ - 'a step after an all-terminating if/else can never run', - `process p { if (cond) { end A } else { end B } user Dead }`, - [warn(undeclared('cond')), UNREACHABLE], - ], - [ - 'a step after an all-terminating parallel can never run', - `process p { var c: boolean parallel { { end A } { end B } } user Dead }`, + 'reachability after a parallel or a loop follows whether every branch ends', + `process p { + var c: boolean + start S do { if (c) { end X } else { user A2 } } while (c) + parallel { { end A } { end B } } user Dead +}`, [UNREACHABLE], ], [ - 'a parallel with every branch conditioned keeps a fallback to its join', - `process p { var c: boolean parallel { if (c) { end A } if (!c) { end B } } user Alive }`, - [], - ], - [ - 'a parallel mixing a conditioned and a plain branch has no fallback, so every branch ending kills the step after it', - `process p { var c: boolean parallel { if (c) { end A } { end B } } user Dead }`, + "a parallel's fallback to its join depends on whether every branch is conditioned", + `process p { + var c: boolean + parallel { if (c) { end A } if (!c) { end B } } user Alive + parallel { if (c) { end A2 } { end B2 } } user Dead +}`, [UNREACHABLE], ], [ @@ -2647,40 +2235,25 @@ checks('Validation - unreachable statements', [ [UNREACHABLE], ], [ - 'a race reports the dead step inside a branch and the one after it', - `process p { await { message("M") { end A user DeadInside } signal("S") { end B } } user DeadAfter }`, - [UNREACHABLE, UNREACHABLE], - ], - [ - 'a step after an if without else stays reachable', - `process p { if (cond) { end A } user Alive }`, - [warn(undeclared('cond'))], - ], - [ - 'a step after a while whose body ends stays reachable', - `process p { while (cond) { end A } user Alive }`, - [warn(undeclared('cond'))], - ], - [ - 'a step after a do-while whose body always ends can never run, and neither can the loop', - `process p { var c: boolean do { end X } while (c) user Dead }`, - [UNREACHABLE, DEAD_LOOP_MESSAGE], + 'a step after a loop or a conditionless if stays reachable unless the loop always ends', + `process p { + var c: boolean + if (cond) { end A } user Alive + while (cond) { end A2 } user Alive2 + do { end X } while (c) user Dead +}`, + [ + warn(undeclared('cond')), + warn(undeclared('cond')), + UNREACHABLE, + DEAD_LOOP_MESSAGE, + ], ], [ 'a last do-while whose body always ends leaves its gateway with no incoming flow', `process p { var c: boolean start S do { end X } while (c) }`, [DEAD_LOOP_MESSAGE], ], - [ - 'a do-while whose body ends on one branch only keeps its loop', - `process p { var c: boolean start S do { if (c) { end X } else { user A } } while (c) }`, - [], - ], - [ - 'a step after an end inside a subprocess can never run', - `process p { subprocess S { start In end Out user Dead } }`, - [UNREACHABLE], - ], [ 'an unreachable subprocess is reported once, not once per nested step', `process p { start S end Done subprocess Sub { user A user B } }`, @@ -2692,9 +2265,13 @@ checks('Validation - unreachable statements', [ [], ], [ - 'a step after a throw can never run', - `process p { error X throw error(X) user Dead }`, - [UNREACHABLE], + 'a goto target reopens reachability that a throw or an end had closed', + `process p { + error X + start S if (c) { goto Wait } end Done await message Wait("M") + throw error(X) user Dead +}`, + [warn(undeclared('c')), UNREACHABLE], ], [ 'a goto-targeted step after a throw stays reachable', @@ -2706,17 +2283,15 @@ checks('Validation - unreachable statements', [ `process p { escalation X emit escalation(X) user Alive }`, [], ], - [ - 'a named await after an end is reachable again as a goto target', - `process p { start S if (c) { goto Wait } end Done await message Wait("M") }`, - [warn(undeclared('c'))], - ], ]); checks('Validation - call activities', [ [ - 'every attribute and mapping shape together is clean', + "a call activity's binding, mappings, and mapper together follow one exclusion rule set", `process p { + var a: number + var b: number + var calleeVar: string var amount: number var tax: number var vipFlag: boolean @@ -2734,93 +2309,33 @@ checks('Validation - call activities', [ out shipmentId out shipped = confirmed } + call X(process: "p", binding: latest) + call X2 { } + call X3(process: "") + call X4(process: "p", binding: version) + call X5(process: "p", binding: weekly) + call X6(process: "p", binding: deployment, version: 2) + call X7(process: "p", version: 2) + call X8(process: "p") { in x = a in x = b } + call X9(process: "p") { in x out x } + call X10(process: "p") { in * in * } + call X11(process: "p") { in * in x } + call X12(process: "p") { out y = calleeVar } + call X13(process: "p") { out y = calleeVar > 5 && true } + call X14(process: "p") { in y = callerVar } + call X15(process: some-id) + call X16(process: "p", mapper: "com.acme.Mapper") { in * out result } call Y(process: "p", mapperDelegate: "\${mapperBean}") + call X17(process: "p", mapper: "com.acme.Mapper2", mapperDelegate: "\${mapperBean}") }`, - [], - ], - [ - 'an explicit binding: latest is clean', - `process p { call X(process: "p", binding: latest) }`, - [], - ], - [ - 'a call with no process attribute names the requirement', - `process p { call X { } }`, - [CALL_PROCESS_REQUIRED], - ], - [ - 'a call with an empty process attribute is one error', - `process p { call X(process: "") }`, - [emptyBinding('process', 'process to start')], - ], - [ - "'binding: version' points at the version setting", - `process p { call X(process: "p", binding: version) }`, - [BINDING_IS_VERSION], - ], - [ - 'an unrecognized binding value names both legal ones', - `process p { call X(process: "p", binding: weekly) }`, - [BINDING_VALUE], - ], - [ - 'binding and version together is one mutual-exclusion error', - `process p { call X(process: "p", binding: deployment, version: 2) }`, - [bindingVersionClash('A call')], - ], - [ - 'version alone is clean', - `process p { call X(process: "p", version: 2) }`, - [], - ], - [ - 'two in mappings naming one target is one error', - `process p { var a: number var b: number call X(process: "p") { in x = a in x = b } }`, - [duplicateMapping('in', 'x')], - ], - [ - 'in and out are independent namespaces', - `process p { call X(process: "p") { in x out x } }`, - [], - ], - [ - 'two copy-everything mappings in one direction is one error', - `process p { call X(process: "p") { in * in * } }`, - [duplicateAllMapping('in')], - ], - [ - 'a copy-everything mapping beside a named one is clean', - `process p { call X(process: "p") { in * in x } }`, - [], - ], - [ - 'an out mapping source is evaluated in the callee, so it is exempt', - `process p { call X(process: "p") { out y = calleeVar } }`, - [], - ], - [ - 'the exemption reaches a source nested inside operators', - `process p { var calleeVar: string call X(process: "p") { out y = calleeVar > 5 && true } }`, - [], - ], - [ - 'an in mapping source is caller-scope, so it still warns', - `process p { call X(process: "p") { in y = callerVar } }`, - [warn(undeclared('callerVar'))], - ], - [ - 'a bareword process value names a deployed process, not a variable', - `process p { call X(process: some-id) }`, - [], - ], - [ - 'a mapper class or a mapper delegate is clean, and runs beside the declared mappings', - `process p { call X(process: "p", mapper: "com.acme.Mapper") { in * out result } call Y(process: "p", mapperDelegate: "\${mapperBean}") }`, - [], - ], - [ - 'both mapper spellings on one call is one exclusion error', - `process p { call X(process: "p", mapper: "com.acme.Mapper", mapperDelegate: "\${mapperBean}") }`, [ + warn(undeclared('callerVar')), + CALL_PROCESS_REQUIRED, + emptyBinding('process', 'process to start'), + BINDING_IS_VERSION, + BINDING_VALUE, + bindingVersionClash('A call'), + duplicateMapping('in', 'x'), + duplicateAllMapping('in'), bindingConflict( 'A call', 'mapper, mapperDelegate', @@ -2849,9 +2364,19 @@ checks('Validation - event handlers', [ [], ], [ - 'a handler inside a subprocess is clean', - `process p { error X subprocess S { user A on error(X) { user B } } }`, - [], + 'an event handler scopes to its nearest container and names unknown triggers', + `process p { + error X + start S2 on erorr("X2") { } + subprocess S { user A on error(X) { user B } } + if (true) { on error(X) { user A2 } } + user T on timer("PT1H", jobPriority: 5) { start ES(jobPriority: 7, exclusive: false) user A3 } +}`, + [ + onTriggerMessage('erorr'), + HANDLER_PLACEMENT, + timerJobKeyTwiceMessage('jobPriority'), + ], ], [ 'a handler nested inside another handler is clean', @@ -2869,14 +2394,14 @@ checks('Validation - event handlers', [ [], ], [ - 'a handler nested in an if scopes to a branch, not a container', - `process p { error X if (true) { on error(X) { user A } } }`, - [HANDLER_PLACEMENT], - ], - [ - 'a step after a handler reads out of order', - `process p { error X on error(X) { user A } service S(class: "x.Y") }`, - [HANDLER_TRAILING], + 'a misspelled trigger is a did-you-mean, and a timer job key is written once', + `process p { + error X + start S2 on conditional { user A2 } + on error(X) { user A } service S(class: "x.Y") + user T on timer("PT1H", jobPriority: 5) { start ES(exclusive: false) user A3 } on timer("PT2H") { start ES2(retryCycle: "R3/PT1M") user B } +}`, + [CONDITIONAL_TYPO_MESSAGE, HANDLER_TRAILING], ], [ 'an error handler cannot run alongside the scope it takes over', @@ -2939,25 +2464,15 @@ checks('Validation - event handlers', [ [warn(undeclared('c')), notValidOn('coed', 'an event handler')], ], [ - 'an unknown trigger word names every kind an on handler takes', - `process p { start S on erorr("X") { } }`, - [onTriggerMessage('erorr')], - ], - [ - "'on conditional' is a did-you-mean", - `process p { start S on conditional { user A } }`, - [CONDITIONAL_TYPO_MESSAGE], - ], - [ - "'on compensate' is a did-you-mean", - `process p { start S on compensate { user A } }`, + "'compensate' is a did-you-mean, and a variable can share a binding field's name", + `process p { + error X + var message: string + start S on compensate { user A } + if (message == "x") { user A2 } on error(X, message: m) { user B } +}`, [COMPENSATE_TYPO_MESSAGE], ], - [ - 'a variable named message coexists with the binding field of that word', - `process p { error X var message: string if (message == "x") { user A } on error(X, message: m) { user B } }`, - [], - ], [ 'two message handlers sharing one name are ambiguous', `process p { on message("X") { user A } on message("X") { user B } }`, @@ -2978,16 +2493,6 @@ checks('Validation - event handlers', [ `process p { escalation X on escalation(X) { user A } on escalation { user B } }`, [noFlowSteps('p'), escalationCatchAllBesideCoded('p')], ], - [ - "a timer job key on a timer handler's start repeats the head's", - `process p { user T on timer("PT1H", jobPriority: 5) { start ES(jobPriority: 7, exclusive: false) user A } }`, - [timerJobKeyTwiceMessage('jobPriority')], - ], - [ - 'a timer job key on the head alone, or on the start alone, is written once', - `process p { user T on timer("PT1H", jobPriority: 5) { start ES(exclusive: false) user A } on timer("PT2H") { start ES2(retryCycle: "R3/PT1M") user B } }`, - [], - ], [ 'one signal name in two containers is no duplicate', `process p { subprocess S { user T on signal("X") { user B } } on signal("X") { user A } }`, @@ -3060,12 +2565,9 @@ checks('Validation - event handlers', [ ], ]); -/** - * A timer start deploys the expression up front - * (`BpmnDeployer.adjustStartEventSubscriptions`), so its bad shape is an - * error; every other carrier reads it only when its scope is entered, so its - * bad shape is a warning. - */ +// A timer start's expression is read at deployment +// (`BpmnDeployer.adjustStartEventSubscriptions`), so its bad shape is an error; +// every other carrier reads it on entering its scope, so it only warns. const TIMER_CARRIERS: ReadonlyArray<{ name: string; severity: 'error' | 'warning'; @@ -3090,8 +2592,6 @@ const TIMER_CARRIERS: ReadonlyArray<{ `process p { await { message("M") { user A } timer(${timerClause(particle, value)}) { user B } } }`, }, { - // An interrupting 'every' handler also draws the fires-at-most-once - // warning, pinned under "Validation - event handlers". name: 'an on-handler timer', severity: 'warning', source: (particle, value) => @@ -3158,47 +2658,61 @@ const TIMER_PARTICLE_SHAPES: ReadonlyArray<{ }, ]; -checks( - "Validation - the timer clause's shape, per carrier and per particle", - TIMER_CARRIERS.flatMap(({ name, severity, source }) => - TIMER_PARTICLE_SHAPES.flatMap( - ({ particle, bad, good, message }): Case[] => [ - ...bad.map((value): Case => [ - `${name} rejects '${particle}: "${value}"'`, - source(particle, value), - [severity === 'error' ? message : warn(message)], - ]), - ...good.map((value): Case => [ - `${name} accepts '${particle}: "${value}"'`, - source(particle, value), - [], - ]), - ], +describe("Validation - the timer clause's shape", () => { + test.each(TIMER_PARTICLE_SHAPES)( + "every '$particle' value is checked against its shape", + async ({ particle, bad, good, message }) => { + const awaits = [...bad, ...good] + .map((value) => `await timer(${timerClause(particle, value)})`) + .join(' '); + expect(await diagnosticsOf(`process p { ${awaits} }`)).toEqual( + bad.map(() => warn(message)), + ); + }, + ); + + test.each( + TIMER_CARRIERS.flatMap(({ name, severity, source }) => + TIMER_PARTICLE_SHAPES.map( + ({ particle, bad, good, message }) => + [ + name, + particle, + severity, + source, + bad[0]!, + good[0]!, + message, + ] as const, + ), ), - ), -); + )( + "%s checks its '%s' value", + async (_name, particle, severity, source, bad, good, message) => { + expect(await diagnosticsOf(source(particle, bad))).toEqual([ + severity === 'error' ? message : warn(message), + ]); + expect(await diagnosticsOf(source(particle, good))).toEqual([]); + }, + ); +}); checks('Validation - boundary hosts', [ [ - 'compensation has no attached form, so it refuses a host', - `process p { subprocess S { user A on A: compensation { user Undo } } }`, - [COMPENSATION_HOST_MESSAGE], - ], - [ - 'a host-less undo block is unaffected by that rule', - `process p { subprocess S { user A on compensation { user Undo } } }`, - [], + 'a boundary host is required only where the trigger kind actually needs one', + `process p { + error X + subprocess S { user A on A: compensation { user Undo } } + subprocess S2 { user A2 on compensation { user Undo2 } } + user A3 emit signal Sig("S3") on Sig: error(X) { user B } +}`, + [COMPENSATION_HOST_MESSAGE, illegalHost('Sig', 'an emit statement')], ], [ 'a start event is no activity to attach to', `process p { error X start S on S: error(X) { user A } }`, [illegalHost('S', 'a start event')], ], - [ - 'an emit statement is no activity to attach to', - `process p { error X user A emit signal Sig("S") on Sig: error(X) { user B } }`, - [illegalHost('Sig', 'an emit statement')], - ], [ 'an end event is no activity to attach to', `process p { error X start S end E on E: error(X) { user A } }`, @@ -3247,8 +2761,6 @@ ${FENCE} `process p { script Pack ${FENCE}js\nx = 1\n${FENCE} on Pack: escalation { user A } }`, [escalationHost('Pack', 'a script task')], ], - // Operaton gates the escalation boundary on a subprocess scope, and it makes - // an `attempt` block one, so the block is a legal host beside `subprocess`. [ 'an escalation boundary takes a subprocess, an attempt block, a call, and a user task', `process p { @@ -3309,15 +2821,14 @@ ${FENCE} [escalationCatchAllBesideCoded('Sub')], ], [ - 'an escalation catch-all beside a coded catch, both inside one block, is refused', - `process p { escalation X subprocess Sub { user A on escalation { user B } on escalation(X) { user C } } }`, + 'a catch-all and a coded catch conflict inside one container, but not across it', + `process p { + escalation X + subprocess Sub { user A on escalation { user B } on escalation(X) { user C } } + subprocess Sub2 { user A2 on escalation { user B2 } } on Sub2: escalation(X) { user C2 } +}`, [escalationCatchAllBesideCoded('Sub')], ], - [ - 'an escalation catch-all inside a block beside a coded catch attached to it is the legal mix', - `process p { escalation X subprocess Sub { user A on escalation { user B } } on Sub: escalation(X) { user C } }`, - [], - ], [ 'two escalation catch-alls attached to one host are refused', `process p { subprocess Sub { user A } on Sub: escalation { user B } on Sub: escalation { user C } }`, @@ -3339,14 +2850,17 @@ ${FENCE} [missingHost('Missing')], ], [ - 'two host-less handlers of an unknown trigger word draw the unknown-kind error alone', - `process p { user T on foo { user A } on foo { user B } }`, - [onTriggerMessage('foo'), onTriggerMessage('foo')], - ], - [ - 'a hosted error handler still cannot run alongside', - `process p { error X user Pack on Pack: error(X, alongside) { user A } }`, - [alongsideMessage('error')], + 'two same-trigger handlers on one host conflict, whether hosted or not', + `process p { + error X + user T on foo { user A } on foo { user B } + user Pack on Pack: error(X, alongside) { user A2 } +}`, + [ + onTriggerMessage('foo'), + onTriggerMessage('foo'), + alongsideMessage('error'), + ], ], [ 'a fully host-less program fires no boundary machinery', @@ -3365,24 +2879,34 @@ ${FENCE} checks('Validation - compensation', [ [ - 'a subprocess with an undo block is clean', - `process p { subprocess S { user A on compensation { user Undo } } }`, - [], - ], - [ - 'an emitted and a named thrown compensation in a handler body are clean', - `process p { error X user A on error(X) { emit compensation throw compensation Undo } }`, - [], - ], - [ - 'a variable named compensation coexists with the trigger word', - `process p { var compensation: number if (compensation > 1) { user A } }`, + 'compensation as a trigger word coexists with a variable of the same name', + `process p { + error X + var compensation: number + if (compensation > 1) { user A3 } + subprocess S { user A on compensation { user Undo } } + user A2 on error(X) { emit compensation throw compensation Undo2 } +}`, [], ], [ - 'an undo block names nothing, so a code string is dropped', - `process p { subprocess S { on compensation("X") { user A } } }`, - [blockNoFlowSteps('a subprocess', 'S'), COMPENSATION_NO_CODE_MESSAGE], + 'an undo block inherits the placement and setting rules of its container', + `process p { + var amount: number + subprocess S { on compensation("X") { user A } } + subprocess S2 { start In on compensation(at: "PT1H") { user A2 } } + subprocess S3 { start In2 on compensation(amount > 100) { user A3 } } + subprocess S4 { if (true) { on compensation { user A4 } } } + subprocess S1 { user A5 on compensation { user U1 } } + subprocess S22 { user B on compensation { user U2 } } +}`, + [ + blockNoFlowSteps('a subprocess', 'S'), + COMPENSATION_NO_CODE_MESSAGE, + PARTICLE_ONLY, + CONDITION_ONLY, + HANDLER_PLACEMENT, + ], ], [ 'an undo block carries no values, so bindings are dropped', @@ -3394,16 +2918,6 @@ checks('Validation - compensation', [ `process p { subprocess S { on compensation(alongside) { user A } } }`, [blockNoFlowSteps('a subprocess', 'S'), COMPENSATION_ALONGSIDE_MESSAGE], ], - [ - 'a timer key on an undo block is the inherited timer-only rule', - `process p { subprocess S { start In on compensation(at: "PT1H") { user A } } }`, - [PARTICLE_ONLY], - ], - [ - 'a condition on an undo block is the inherited condition-only rule', - `process p { var amount: number subprocess S { start In on compensation(amount > 100) { user A } } }`, - [CONDITION_ONLY], - ], [ 'a process cannot undo itself', `process p { on compensation { user A } }`, @@ -3414,11 +2928,6 @@ checks('Validation - compensation', [ `process p { error X on error(X) { on compensation { user A } } }`, [noFlowSteps('p'), COMPENSATION_PLACEMENT_MESSAGE], ], - [ - 'an undo block in a branch is the generic placement error, once', - `process p { subprocess S { if (true) { on compensation { user A } } } }`, - [HANDLER_PLACEMENT], - ], [ 'two undo blocks in one subprocess merge into one', `process p { subprocess S { on compensation { user A } on compensation { user B } } }`, @@ -3429,14 +2938,6 @@ checks('Validation - compensation', [ `process p { attempt A { on compensation { user U1 } on compensation { user U2 } } }`, [COMPENSATION_DUPLICATE_MESSAGE, blockNoFlowSteps('an attempt block', 'A')], ], - [ - 'one undo block in each of two subprocesses is clean', - `process p { - subprocess S1 { user A on compensation { user U1 } } - subprocess S2 { user B on compensation { user U2 } } -}`, - [], - ], ...(['throw', 'emit', 'await'] as const).map((keyword): Case => [ `'${keyword} compensate' is a did-you-mean`, `process p { ${keyword} compensate }`, @@ -3574,34 +3075,37 @@ checks('Validation - the cancel end and its handler', [ checks('Validation - throw and emit', [ [ - "'emit error' points at 'throw error'", - `process p { error X emit error(X) }`, - [emitTriggerMessage('error')], - ], - [ - 'an unknown throw kind names the kinds with a terminal form', - `process p { throw banana("X") }`, - [throwTriggerMessage('banana')], - ], - [ - 'an unknown emit kind leaves error off the list', - `process p { emit banana("X") }`, - [emitTriggerMessage('banana')], - ], - [ - 'a thrown error with no code names the shape', - `process p { throw error }`, - [codeRequired('A thrown', 'error', 'throw')], + "'emit error' redirects to 'throw error', and an emitted signal carries no code", + `process p { + error X + start S emit signal("Ready", class: "c") + emit error(X) + throw banana("X2") +}`, + [ + noImplementation('class', 'an emitted signal'), + emitTriggerMessage('error'), + throwTriggerMessage('banana'), + ], ], [ - 'an emitted signal with no code names the shape', - `process p { emit signal }`, - [codeRequired('An emitted', 'signal', 'emit')], + 'an unknown emit kind excludes error, and a codeless thrown error names its shape', + `process p { + emit banana("X") + throw error +}`, + [emitTriggerMessage('banana'), codeRequired('A thrown', 'error', 'throw')], ], [ - 'an empty code is the same mistake as an omitted one', - `process p { throw escalation("") }`, - [codeRequired('A thrown', 'escalation', 'throw')], + 'an empty code is the same mistake as leaving it out entirely', + `process p { + emit signal + throw escalation("") +}`, + [ + codeRequired('An emitted', 'signal', 'emit'), + codeRequired('A thrown', 'escalation', 'throw'), + ], ], [ 'a thrown compensation names nothing', @@ -3629,117 +3133,80 @@ checks('Validation - throw and emit', [ ] as const ).map(([keyword, binding]): Case => [ `a message ${keyword} carries a ${binding.split(':')[0]} implementation`, - `process p { start S ${keyword} message("Ack", ${binding}) }`, - [], - ]), - [ - 'only a message really sends, so a thrown error carries no implementation', - `process p { error X start S throw error(X, class: "c") }`, - [noImplementation('class', 'a thrown error')], - ], - [ - 'a binding key beside an engine setting on a thrown error still draws only the binding error', - `process p { error X start S throw error(X, class: "c", asyncBefore: true) }`, - [noImplementation('class', 'a thrown error')], - ], - [ - 'an emitted signal carries none either', - `process p { start S emit signal("Ready", class: "c") }`, - [noImplementation('class', 'an emitted signal')], - ], - [ - 'a thrown message with two implementations names both', - `process p { start S throw message("Ack", class: "a", expression: "\${b}") }`, - [bindingConflict('A thrown message', 'class, expression', THROW_BINDINGS)], - ], - [ - 'a goto targeting a named throw resolves', - `process p { error X var cond: boolean start S if (cond) { goto Failed } throw error Failed(X) }`, - [], - ], -]); - -checks('Validation - awaited events', [ - [ - 'an awaited message, timer, signal, and condition are clean', - `process p { var x: number await message("M") await timer("PT1H") await signal("S") await condition(x > 1) }`, - [], - ], - [ - 'an error is raised outward, so it cannot be awaited', - `process p { error E await error(E) }`, - [catchTriggerMessage('error')], - ], - [ - 'an escalation cannot be awaited either', - `process p { escalation E await escalation(E) }`, - [catchTriggerMessage('escalation')], - ], - [ - 'compensation runs through an undo block, not an await', - `process p { await compensation }`, - [catchTriggerMessage('compensation')], - ], - [ - 'an unknown word names the kinds an await takes and where the rest are written', - `process p { start S await nonsense }`, - [catchTriggerMessage('nonsense')], - ], - [ - 'an awaited message with no name asks for it', - `process p { await message }`, - [awaitNameRequired('message')], - ], - [ - 'an awaited signal with no name asks for it', - `process p { await signal }`, - [awaitNameRequired('signal')], - ], - [ - 'an awaited timer with no payload asks how to read the time', - `process p { await timer }`, - [TIMER_PAYLOAD_MESSAGE], - ], + `process p { start S ${keyword} message("Ack", ${binding}) }`, + [], + ]), [ - 'an awaited condition with no parens asks for the condition', - `process p { await condition }`, - [AWAIT_CONDITION_REQUIRED], + 'only a message really sends, so a thrown error carries no implementation', + `process p { error X start S throw error(X, class: "c") }`, + [noImplementation('class', 'a thrown error')], ], [ - 'a timer key on an awaited message belongs to a timer', - `process p { await message(at: "PT1H") }`, - [awaitNameRequired('message'), AWAIT_PARTICLE_ONLY], + 'a binding key beside an engine setting on a thrown error still draws only the binding error', + `process p { error X start S throw error(X, class: "c", asyncBefore: true) }`, + [noImplementation('class', 'a thrown error')], ], [ - 'a condition on an awaited message belongs to an awaited condition', - `process p { var x: number await message(x > 1) }`, - [awaitNameRequired('message'), AWAIT_CONDITION_ONLY], + 'a thrown message with two implementations names both', + `process p { start S throw message("Ack", class: "a", expression: "\${b}") }`, + [bindingConflict('A thrown message', 'class, expression', THROW_BINDINGS)], ], [ - 'a code string on an awaited condition is not the condition', - `process p { await condition("X") }`, - [AWAIT_CONDITION_NO_CODE], + 'a goto targeting a named throw resolves', + `process p { error X var cond: boolean start S if (cond) { goto Failed } throw error Failed(X) }`, + [], ], +]); + +checks('Validation - awaited events', [ [ - 'an awaited condition carrying both mistakes reports them left to right', - `process p { await condition("X", at: "2026-01-01") }`, - [AWAIT_CONDITION_NO_CODE, AWAIT_PARTICLE_ONLY], + "an awaited event's payload and settings are validated per trigger kind", + `process p { + var x: number + error E + start S2 await nonsense + await message("M") await timer("PT1H") await signal("S") await condition(x > 1) + await error(E) + await compensation + await message + await signal + await timer + await condition + await message(at: "PT1H2") + await message(x > 1) + await condition("X") + await condition("X2", at: "2026-01-01") + await { message("M2", assignee: "u") { user A } signal("S3") { user B } } + await { message("Dup") { user A2 } signal("Dup") { user B2 } } +}`, + [ + catchTriggerMessage('nonsense'), + catchTriggerMessage('error'), + catchTriggerMessage('compensation'), + awaitNameRequired('message'), + awaitNameRequired('signal'), + TIMER_PAYLOAD_MESSAGE, + AWAIT_CONDITION_REQUIRED, + awaitNameRequired('message'), + AWAIT_PARTICLE_ONLY, + awaitNameRequired('message'), + AWAIT_CONDITION_ONLY, + AWAIT_CONDITION_NO_CODE, + AWAIT_CONDITION_NO_CODE, + AWAIT_PARTICLE_ONLY, + notValidOn('assignee', 'a branch of an await block'), + ], ], [ - 'a race branch takes the settings keys an awaited event takes', - `process p { await { message("M", assignee: "u") { user A } signal("S") { user B } } }`, - [notValidOn('assignee', 'a branch of an await block')], + 'an escalation cannot be awaited either', + `process p { escalation E await escalation(E) }`, + [catchTriggerMessage('escalation')], ], ...(['message', 'signal'] as const).map((trigger): Case => [ `two ${trigger} branches of one race on one name subscribe twice on the gateway`, `process p { await { ${trigger}("Dup") { user A } timer("PT1H") { user T } ${trigger}("Dup") { user B } } }`, [raceDuplicateMessage(trigger, 'Dup')], ]), - [ - 'a message branch and a signal branch of one name are two subscriptions', - `process p { await { message("Dup") { user A } signal("Dup") { user B } } }`, - [], - ], [ 'a race branch a parse error leaves without a trigger draws no phantom trigger error', `process p { start S await { (label: "x") message M } }`, @@ -3808,39 +3275,37 @@ checks('Validation - link events', [ [], ], [ - 'a step after an emit link can never run', - `process p { step A emit link T("L") step Dead await link C("L") }`, - [UNREACHABLE], - ], - [ - 'a link catch after a live step refuses the flow that would enter it', - `process p { step A await link C("L") step B emit link T("L") }`, - [LINK_CATCH_FLOW_MESSAGE], - ], - [ - 'a link cannot head a race branch', - `process p { await { link("L") { user A } message("M") { user B } } }`, - [LINK_IN_RACE_MESSAGE], - ], - [ - 'an awaited link with no name asks for it', - `process p { step A end E await link C }`, - [awaitNameRequired('link')], - ], - [ - 'an emitted link with no name asks for it', - `process p { step A emit link }`, - [codeRequired('An emitted', 'link', 'emit')], + 'reachability across a link follows the emit and catch on either end', + `process p { + step A emit link T("L") step Dead await link C("L") + step A2 await link C2("L2") step B emit link T2("L2") +}`, + [UNREACHABLE, LINK_CATCH_FLOW_MESSAGE], ], [ - 'a bareword link name is a missing pair of quotes', - `process p { step A emit link T(Retry) await link C(Retry) }`, - [barewordName('link', 'Retry'), barewordName('link', 'Retry')], + 'a link needs a name and cannot head a race branch', + `process p { + await { link("L") { user A } message("M") { user B } } + step A2 end E await link C + step A3 emit link +}`, + [ + LINK_IN_RACE_MESSAGE, + awaitNameRequired('link'), + codeRequired('An emitted', 'link', 'emit'), + ], ], [ - 'engine settings and listeners on an emitted link are refused, one each', - `process p { step A emit link T("L", asyncBefore: true, jobPriority: 5) { on end(class: "x.L") } await link C("L") }`, + 'an emit link refuses engine settings and listeners without duplicating the never-runs error', + `process p { + step A emit link T(Retry) await link C(Retry) + step A3 emit link T3("L2", topic: "shipping") await link C3("L2") + step A2 emit link T2("L", asyncBefore: true, jobPriority: 5) { on end(class: "x.L") } await link C2("L") +}`, [ + barewordName('link', 'Retry'), + barewordName('link', 'Retry'), + noImplementation('topic', 'an emitted link'), linkThrowNeverRunsMessage("Setting 'asyncBefore'"), linkThrowNeverRunsMessage("Setting 'jobPriority'"), linkThrowNeverRunsMessage("The 'on end' listener"), @@ -3852,14 +3317,12 @@ checks('Validation - link events', [ [linkNoCatch('L')], ], [ - 'a binding key on an emit link draws its own not-valid error alone, not the never-runs message too', - `process p { step A emit link T("L", topic: "shipping") await link C("L") }`, - [noImplementation('topic', 'an emitted link')], - ], - [ - 'a link catch in another container is out of reach', - `process p { subprocess S { step A emit link T("L") } end E await link C("L") step B }`, - [linkOtherContainer('L')], + "a link catch reaches only its own container, and 'throw link' redirects to 'emit link'", + `process p { + subprocess S { step A emit link T("L") } end E await link C("L") step B + step A2 throw link("L2") +}`, + [linkOtherContainer('L'), throwTriggerMessage('link')], ], [ 'an emit link cannot enter a parallel branch from outside it', @@ -3867,123 +3330,82 @@ checks('Validation - link events', [ [warn(undeclared('c')), linkIntoBranch('L', 'parallel')], ], [ - 'two catches of one link name are refused even across subprocesses', - `process p { step A emit link T("L") await link C1("L") subprocess S { step B emit link T2("L") await link C2("L") step D } }`, - [linkNameTaken('L')], + "a link name's catches are unique across subprocesses, and goto cannot target one", + `process p { + step A emit link T("L") await link C1("L") subprocess S { step B emit link T2("L") await link C2("L") step D } + step A2 if (c) { goto C } emit link T3("L2") await link C("L2") step B2 +}`, + [warn(undeclared('c')), linkNameTaken('L'), gotoToLink('C')], ], [ 'a link catch nothing emits is a warning, not an error', `process p { step A end E await link C("L") step B }`, [warn(linkUnused('L'))], ], - [ - 'a goto cannot target a link catch', - `process p { step A if (c) { goto C } emit link T("L") await link C("L") step B }`, - [warn(undeclared('c')), gotoToLink('C')], - ], - [ - 'throw link points at emit link', - `process p { step A throw link("L") }`, - [throwTriggerMessage('link')], - ], ]); checks('Validation - start triggers', [ [ - 'a message start, a signal start, and a labelled repeating timer start are clean', - `process p { start A message("OrderReceived") start B signal("Ready") start C timer(every: "R/PT10M", label: "Scheduled") user T end E }`, - [], - ], - [ - 'a message start with no name names what the engine matches on', - `process p { start S message }`, - [startNameRequired('message')], - ], - [ - 'a signal start with no name names what the engine matches on', - `process p { start S signal }`, - [startNameRequired('signal')], - ], - [ - 'a timer start with no payload asks how to read the time', - `process p { start S timer }`, - [TIMER_PAYLOAD_MESSAGE], - ], - [ - 'a timer key on a non-timer start belongs to a timer', - `process p { start S message(at: "PT1H") }`, - [startNameRequired('message'), START_PARTICLE_ONLY], - ], - [ - 'the engine ignores an error start', - `process p { error X start S error(X) }`, - [startTriggerMessage('error')], - ], - [ - 'the engine ignores an escalation start', - `process p { escalation X start S escalation(X) }`, - [startTriggerMessage('escalation')], - ], - [ - 'a compensation start points at the undo block', - `process p { start S compensation }`, - [startTriggerMessage('compensation')], - ], - [ - 'a condition start carrying its condition is clean', - `process p { var amount: number start S condition(amount > 100) user A }`, - [], - ], - [ - 'a condition start with no condition asks for it', - `process p { start S condition }`, - [START_CONDITION_REQUIRED], - ], - [ - 'a code string on a condition start is not the condition', - `process p { start S condition("X") }`, - [START_CONDITION_NO_CODE], - ], - [ - 'a condition start carrying both mistakes reports them left to right', - `process p { start S condition("X", at: "2026-01-01") }`, - [START_CONDITION_NO_CODE, START_PARTICLE_ONLY], - ], - [ - 'a condition on a message start belongs to a condition start', - `process p { var amount: number start S message(amount > 100) }`, - [startNameRequired('message'), START_CONDITION_ONLY], - ], - [ - 'the near-miss spelling is answered as a typo', - `process p { start S conditional }`, - [CONDITIONAL_TYPO_MESSAGE], - ], - [ - 'an unknown start kind names the legal ones', - `process p { start S nonsense("X") }`, - [startTriggerMessage('nonsense')], - ], - [ - 'a message start name cannot hold a trailing expression', - `process p { start S message("Order\${x}") }`, - [startMessageExpressionMessage('Order${x}')], + "a start event's trigger decides which payload it requires or ignores", + `process p { + error X + var amount: number + start A message("OrderReceived") start B signal("Ready") start C timer(every: "R/PT10M", label: "Scheduled") user T end E + start S message + start S2 signal + start S3 message(at: "PT1H") + start S4 error(X) + start S5 compensation + start S6 condition(amount > 100) user A2 +}`, + [ + startNameRequired('message'), + startNameRequired('signal'), + startNameRequired('message'), + START_PARTICLE_ONLY, + startTriggerMessage('error'), + startTriggerMessage('compensation'), + ], ], [ - 'a message start name cannot hold a whole expression', - `process p { start S message("#{orderType}") }`, - [startMessageExpressionMessage('#{orderType}')], + "a start event's missing payload, wrong setting, or bad spelling is named precisely", + `process p { + escalation X + var amount: number + start S timer + start S2 escalation(X) + start S3 condition + start S4 condition("X2") + start S5 condition("X3", at: "2026-01-01") + start S6 message(amount > 100) + start S7 conditional + start S8 nonsense("X4") + start S9 message("Order\${x}") + start S10 message("#{orderType}") +}`, + [ + TIMER_PAYLOAD_MESSAGE, + startTriggerMessage('escalation'), + START_CONDITION_REQUIRED, + START_CONDITION_NO_CODE, + START_CONDITION_NO_CODE, + START_PARTICLE_ONLY, + startNameRequired('message'), + START_CONDITION_ONLY, + CONDITIONAL_TYPO_MESSAGE, + startTriggerMessage('nonsense'), + startMessageExpressionMessage('Order${x}'), + startMessageExpressionMessage('#{orderType}'), + ], ], [ - 'the other expression spelling is rejected too', - `process p { start S message("Order#{x}") }`, + 'a message or signal name accepts either expression spelling, consistently', + `process p { + start S message("Order#{x}") + start P start S2 signal("Order2\${x}") start T signal("#{orderType}") await message("Order2\${x}") await message("#{orderType}") user A on message("Order2\${x}") { user B } +}`, [startMessageExpressionMessage('Order#{x}')], ], - [ - 'a signal start, an awaited message, and a handler message name may hold either expression spelling: the process is running', - `process p { start P start S signal("Order\${x}") start T signal("#{orderType}") await message("Order\${x}") await message("#{orderType}") user A on message("Order\${x}") { user B } }`, - [], - ], [ 'only the process own start carries a trigger, not a subprocess start', `process p { start S subprocess Sub { start In message("M") user A } end E }`, @@ -4099,14 +3521,12 @@ checks('Validation - a message or signal name is text, not a reference', [ [barewordName('message', 'OrderReceived')], ], [ - 'a signal start names its subscription', - `process p { start S signal(Ready) user U }`, - [barewordName('signal', 'Ready')], - ], - [ - 'an awaited message names its subscription', - `process p { await message(OrderReceived) }`, - [barewordName('message', 'OrderReceived')], + 'a signal start and an awaited message both refuse a bareword name', + `process p { + start S signal(Ready) user U + await message(OrderReceived) +}`, + [barewordName('signal', 'Ready'), barewordName('message', 'OrderReceived')], ], [ 'a message handler names its subscription', @@ -4119,14 +3539,12 @@ checks('Validation - a message or signal name is text, not a reference', [ [barewordName('message', 'OrderReceived')], ], [ - 'an emitted signal names its subscription', - `process p { start S emit signal(Ready) user U }`, - [barewordName('signal', 'Ready')], - ], - [ - 'a race branch head names its subscription', - `process p { await { message(OrderReceived) { user A } timer("PT1H") { user B } } }`, - [barewordName('message', 'OrderReceived')], + 'an emitted signal and a race branch head both refuse a bareword name', + `process p { + start S emit signal(Ready) user U + await { message(OrderReceived) { user A } timer("PT1H") { user B } } +}`, + [barewordName('signal', 'Ready'), barewordName('message', 'OrderReceived')], ], [ 'an error code is a declared name and stays one', @@ -4135,11 +3553,7 @@ checks('Validation - a message or signal name is text, not a reference', [ ], ]); -/** - * A paren item is one of several, so a diagnostic reported on the whole list - * would underline whichever came first. Every source below writes the offending - * item second. - */ +/** Each source writes the offending paren item second, so a diagnostic on the whole list would miss it. */ describe('Validation - paren items, reported in place', () => { test.each([ [ @@ -4204,14 +3618,50 @@ describe('Validation - paren items, reported in place', () => { checks('Validation - code declarations', [ [ - 'an error carrying a code and a message and a bare escalation both declare cleanly', - `process p { error OUT_OF_STOCK(code: "order.failed", message: "Out of stock") escalation MANUAL_REVIEW user A }`, - [], + 'a code declaration is unique per name and per code, across every declaring kind', + `process p { + error OUT_OF_STOCK(code: "order.failed", message: "Out of stock") + escalation MANUAL_REVIEW + error A + escalation A + error B(code: "A") + error X + escalation Y(code: "X") + escalation E(message: "m", wibble: "x") + error Pack + start S emit escalation("review.manual") + user A + user U + user U2 + user U3 + user A2 + user Pack +}`, + [ + quotedCodeMessage('escalation', 'review.manual'), + alreadyDeclared('escalation', 'A'), + duplicateDeclaredCode('error', 'A', 'A'), + notValidOn('wibble', 'an escalation declaration'), + ESCALATION_NO_MESSAGE_MESSAGE, + ], ], [ - 'an unknown declaration kind names the kinds a declaration takes', - `process p { usr Review user A }`, - [unknownDeclarationKindMessage('usr')], + 'an unknown declaration kind, a shared code, and a repeated key are each named', + `process p { + error A(code: "PA") + error A(code: "PB") + error C(code: "PB") + error E(code: "a", code: "b") + usr Review user A + user U + user U2 +}`, + [ + alreadyDeclared('error', 'A'), + duplicateDeclaredCode('error', 'PB', 'A'), + duplicateSetting('code'), + unknownDeclarationKindMessage('usr'), + ], ], [ 'a mistyped step keyword is named rather than blamed on the brace after it', @@ -4223,54 +3673,26 @@ checks('Validation - code declarations', [ unknownDeclarationKindMessage('usr'), ], ], - [ - 'a second declaration of one name is refused', - `process p { error A escalation A user U }`, - [alreadyDeclared('escalation', 'A')], - ], - [ - 'a second declaration of one code is refused', - `process p { error A error B(code: "A") user U }`, - [duplicateDeclaredCode('error', 'A', 'A')], - ], - [ - 'one code declared once per kind is no collision', - `process p { error X escalation Y(code: "X") user U }`, - [], - ], [ 'a repeated name reports the name alone, since it repeats the code it stands for', `process p { error A error A user U }`, [alreadyDeclared('error', 'A')], ], [ - 'a repeated name still reports a code it shares with a third declaration', - `process p { error A(code: "PA") error A(code: "PB") error C(code: "PB") user U }`, - [alreadyDeclared('error', 'A'), duplicateDeclaredCode('error', 'PB', 'A')], - ], - [ - 'two empty codes name nothing, so neither collides with the other', - `process p { error A(code: "") error B(code: "") user U }`, - [declarationEmpty('error', 'code'), declarationEmpty('error', 'code')], - ], - [ - 'a setting a declaration does not take, and a message on an escalation, are both refused', - `process p { escalation E(message: "m", wibble: "x") user A }`, + 'two empty codes never collide, but an unquoted code is a missing pair of quotes', + `process p { + error A(code: "") + error B(code: "") + error E(code: FOO) + user U + user A2 +}`, [ - notValidOn('wibble', 'an escalation declaration'), - ESCALATION_NO_MESSAGE_MESSAGE, + declarationEmpty('error', 'code'), + declarationEmpty('error', 'code'), + declarationNotAString('error', 'code'), ], ], - [ - 'a second setting of one key is refused', - `process p { error E(code: "a", code: "b") user U }`, - [duplicateSetting('code')], - ], - [ - 'an unquoted code is a missing pair of quotes, not an undeclared variable', - `process p { error E(code: FOO) user A }`, - [declarationNotAString('error', 'code')], - ], [ 'an empty code and an empty message name nothing', `process p { error E(code: "", message: "") user U }`, @@ -4286,23 +3708,11 @@ checks('Validation - code declarations', [ `process p { error E(SOMETHING) user A }`, [DECLARATION_SETTINGS_ONLY_MESSAGE], ], - [ - 'a declaration may carry the name of a step', - `process p { error Pack user Pack }`, - [], - ], - // The grammar admits the string here and always will, since a message name - // is written the same way; only this rule separates the two. [ 'a thrown code is a declared name, not quoted text', `process p { start S throw error("PAYMENT_DECLINED") }`, [quotedCodeMessage('error', 'PAYMENT_DECLINED')], ], - [ - 'text no name could spell is declared under a name the author picks', - `process p { start S emit escalation("review.manual") }`, - [quotedCodeMessage('escalation', 'review.manual')], - ], [ 'a caught code is a declared name too', `process p { start S user T on error("X") { user U } }`, @@ -4315,10 +3725,6 @@ checks('Validation - code declarations', [ ], ]); -/** - * Each of the four minimal task kinds, all named `X` so a `goto` and a boundary - * host read the same for every row. - */ const TASK_KINDS: Array< [kind: string, description: string, statement: string] > = [ @@ -4330,54 +3736,19 @@ const TASK_KINDS: Array< describe('Validation - the task kinds', () => { test.each(TASK_KINDS)( - 'a %s is clean in a process body, a subprocess body, and a handler body', - async (_kind, _description, statement) => { - for (const program of [ - `process p { ${statement} }`, + 'a %s is clean in every body, takes a goto and a timer boundary, but no escalation boundary and no repeated name', + async (_kind, description, statement) => { + const programs = [ + `process p { user A goto X ${statement} on X: timer("PT1H") { user B } on X: escalation { user C } }`, `process p { subprocess S { ${statement} } }`, `process p { start S on error { ${statement} } }`, - ]) { - expect(await diagnosticsOf(program), program).toEqual([]); - } - }, - ); - - test.each(TASK_KINDS)( - 'a timer boundary event attaches to a %s', - async (_kind, _description, statement) => { - expect( - await diagnosticsOf( - `process p { ${statement} on X: timer("PT1H") { user A } }`, - ), - ).toEqual([]); - }, - ); - - test.each(TASK_KINDS)( - 'an escalation boundary event does not attach to a %s', - async (_kind, description, statement) => { - expect( - await diagnosticsOf( - `process p { ${statement} on X: escalation { user A } }`, - ), - ).toEqual([escalationHost('X', description)]); - }, - ); - - test.each(TASK_KINDS)( - 'a goto resolves into a %s', - async (_kind, _description, statement) => { - expect( - await diagnosticsOf(`process p { user A goto X ${statement} }`), - ).toEqual([]); - }, - ); - - test.each(TASK_KINDS)( - 'a %s repeating an earlier step name is ambiguous', - async (_kind, _description, statement) => { - expect(await diagnosticsOf(`process p { user X ${statement} }`)).toEqual([ - duplicateStepName('X', 'p'), + `process p { user X ${statement} }`, + ]; + expect(await Promise.all(programs.map(diagnosticsOf))).toEqual([ + [escalationHost('X', description)], + [], + [], + [duplicateStepName('X', 'p')], ]); }, ); @@ -4400,152 +3771,57 @@ describe('Validation - the task kinds', () => { ); }); -const TASK_KIND_BLOCKS = BLOCK_HOSTS.filter(([kind]) => - TASK_KINDS.some(([taskKind]) => taskKind === kind), -); - -/** - * `label` and `documentation` share their whole derivation: both are owned by - * exactly the kinds lowering to a BPMN node with a name slot. Each key runs - * against every host so neither stands in for the other. - */ -const NAME_SLOT_HOSTS = ['label', 'documentation'].flatMap((key) => - BLOCK_HOSTS.map( - ([kind, description, , settings]) => - [key, kind, description, settings] as const, - ), -); - describe('Validation - every element with settings and a member block', () => { + // A flag word lexes as a flag inside any parens, so a stray one reaches the + // validator. A form or mapping in a `call` block is a parse error, and an + // external host takes property and mapping lines only under `topic` (below). test.each(BLOCK_HOSTS)( - 'the engine settings are accepted on %s', - async (_kind, _description, _members, settings) => { - expect(await diagnosticsOf(settings(ENGINE_SETTINGS))).toEqual([]); - }, - ); - - test.each(BLOCK_HOSTS)( - 'an unknown key on %s names the element kind', - async (_kind, description, _members, settings) => { - expect(await diagnosticsOf(settings('wibble: 1'))).toEqual([ + 'the settings and members of %s draw exactly the misfit diagnostics', + async (kind, description, members, settings) => { + const labelled = LABEL_HOSTS.has(kind); + expect( + await diagnosticsOf( + settings( + `${ENGINE_SETTINGS}, wibble: 1, sequentially, label: "L", documentation: "L"`, + ), + ), + ).toEqual([ notValidOn('wibble', description), - ]); - }, - ); - - // Every flag word lexes as a flag inside any parens, being a keyword - // elsewhere in the grammar, so one written out of place reaches the validator - // rather than the parser. - test.each(BLOCK_HOSTS)( - 'a stray flag on %s names the element kind', - async (_kind, description, _members, settings) => { - expect(await diagnosticsOf(settings('sequentially'))).toEqual([ + ...(labelled + ? [] + : [ + notValidOn('label', description), + notValidOn('documentation', description), + ]), flagNotValidOn('sequentially', description), ]); - }, - ); - - test.each(NAME_SLOT_HOSTS.filter(([, kind]) => LABEL_HOSTS.has(kind)))( - '%s is accepted on %s', - async (key, _kind, _description, settings) => { - expect(await diagnosticsOf(settings(`${key}: "L"`))).toEqual([]); - }, - ); - - test.each(NAME_SLOT_HOSTS.filter(([, kind]) => !LABEL_HOSTS.has(kind)))( - '%s on %s names the element kind, having no name slot to land in', - async (key, _kind, description, settings) => { - expect(await diagnosticsOf(settings(`${key}: "L"`))).toEqual([ - notValidOn(key, description), - ]); - }, - ); - - test.each(BLOCK_HOSTS)( - 'an execution listener is accepted on %s', - async (_kind, _description, program) => { - expect( - await diagnosticsOf(program('on start(class: "com.example.L")')), - ).toEqual([]); - }, - ); - - test.each(TASK_KIND_BLOCKS)( - 'a task listener is not accepted on %s', - async (_kind, description, program) => { - expect( - await diagnosticsOf(program('on create(class: "com.example.L")')), - ).toEqual([taskListenerOnly('create', description)]); - }, - ); - - // `call` is absent from both lists: its body has no `form` member at all, so - // a form written there is a parse error rather than this diagnostic. - test.each( - BLOCK_HOSTS.filter(([kind]) => !FORM_HOSTS.has(kind) && kind !== 'call'), - )( - 'a form block on %s names the element kind', - async (_kind, description, program) => { - expect(await diagnosticsOf(program('form { a: number }'))).toEqual([ - noFormBlock(description), - ]); - }, - ); - - test.each(BLOCK_HOSTS.filter(([kind]) => FORM_HOSTS.has(kind)))( - 'a form block is accepted on %s', - async (_kind, _description, program) => { - expect(await diagnosticsOf(program('form { a: number }'))).toEqual([]); - }, - ); - - test.each(BLOCK_HOSTS.filter(([kind]) => PARAMETER_HOSTS.has(kind)))( - 'input and output parameters are accepted on %s', - async (_kind, _description, program) => { - expect( - await diagnosticsOf(program('input a = 1 output b = "two"')), - ).toEqual([]); - }, - ); - - test.each(BLOCK_HOSTS.filter(([kind]) => !PARAMETER_HOSTS.has(kind)))( - 'a parameter on %s names the element kind and the hosts that take one', - async (_kind, description, program) => { - expect(await diagnosticsOf(program('input a = 1'))).toEqual([ - noParameters(description), - ]); - }, - ); - - test.each(BLOCK_HOSTS.filter(([kind]) => !EXTERNAL_HOSTS.has(kind)))( - 'a property line on %s names the element kind and the hosts that take one', - async (_kind, description, program) => { - expect(await diagnosticsOf(program('property k = "v"'))).toEqual([ - noPropertyHostMessage(description), - ]); - }, - ); - - // `call` is absent: its own block has no mapping member, so one written - // there is a parse error rather than this diagnostic. The code is declared - // in the header so the linker stays out of the list. - test.each( - BLOCK_HOSTS.filter( - ([kind]) => !EXTERNAL_HOSTS.has(kind) && kind !== 'call', - ).map( - ([kind, description, members]) => - [ - kind, - description, - (c: string) => - members(c).replace('process p {', 'process p { error E '), - ] as const, - ), - )( - 'an error mapping on %s names the element kind and the hosts that take one', - async (_kind, description, program) => { - expect(await diagnosticsOf(program('error E when "${x}"'))).toEqual([ - noMappingHostMessage(description), + + const task = TASK_KINDS.some(([taskKind]) => taskKind === kind); + const external = EXTERNAL_HOSTS.has(kind); + const block = [ + 'on start(class: "com.example.L")', + task ? 'on create(class: "com.example.L")' : '', + kind === 'call' ? '' : 'form { a: number }', + 'input a = 1 output b = "two"', + external ? '' : 'property k = "v"', + external || kind === 'call' ? '' : 'error E when "${x}"', + ].join(' '); + expect( + await diagnosticsOf( + members(block).replace('process p {', 'process p { error E '), + ), + ).toEqual([ + ...(kind === 'call' || FORM_HOSTS.has(kind) + ? [] + : [noFormBlock(description)]), + ...(PARAMETER_HOSTS.has(kind) + ? [] + : [noParameters(description), noParameters(description)]), + ...(external ? [] : [noPropertyHostMessage(description)]), + ...(external || kind === 'call' + ? [] + : [noMappingHostMessage(description)]), + ...(task ? [taskListenerOnly('create', description)] : []), ]); }, ); @@ -4568,54 +3844,39 @@ describe('Validation - every element with settings and a member block', () => { checks('Validation - input and output parameters', [ [ - 'an unrecognized direction names the two legal ones', - `process p { user U { inp a = 1 } }`, - [unknownDirectionMessage('inp', ['input', 'output'])], - ], - [ - 'a repeated name within one direction is one error', - `process p { user U { input a = 1 input a = 2 } }`, - [duplicateParameter('input', 'a')], - ], - [ - 'the two directions are independent namespaces', - `process p { user U { input a = 1 output a = 2 } }`, - [], - ], - [ - 'a list, a map, and an inline script are parameter values', - `process p { service V(topic: "t") { input items = [1, 2] input rows = { k: "v" } input computed = ${FENCE}groovy\n1 + 1\n${FENCE} } }`, - [], + 'input and output parameters validate their direction, name, and value the same way', + `process p { + var count: string + user U { inp a = 1 } + user U2 { input a = 1 input a = 2 } + user U3 { input a = 1 output a = 2 } + service V(topic: "t") { input items = [1, 2] input rows = { k: "v" } input computed = ${FENCE}groovy\n1 + 1\n${FENCE} } + user U4 { input xs = [${FENCE}cobol\n1\n${FENCE}] } + user U5 { input m = { "": "empty" } } + user U6 { input m = { rows: [{ cells: { "": 1 } }] } } + user U7 { input a = "" input b = " " } + user U8 { input a = ["x", ""] input b = { k: "" } } + user U9 { input count = 1 } step T for 3 until (count >= 2) + user U10 { input m = { "say \\"hi\\"": 1, "{ braces }": 2, "two +lines": 3, "Grüße 日本": 4 } } +}`, + [ + typeMismatch('count', 'string', 'an ordered comparison', '>='), + unknownDirectionMessage('inp', ['input', 'output']), + duplicateParameter('input', 'a'), + unsupportedScriptTag(`Input 'xs'`, 'cobol'), + EMPTY_MAP_KEY, + EMPTY_MAP_KEY, + warn(EMPTY_STRING_VALUE_MESSAGE), + warn(EMPTY_STRING_VALUE_MESSAGE), + warn(EMPTY_STRING_VALUE_MESSAGE), + warn(EMPTY_STRING_VALUE_MESSAGE), + ], ], ...scriptTagCases( (tag) => `process p { user U { input x = ${FENCE}${tag}\n1\n${FENCE} } }`, `Input 'x'`, ), - [ - 'a script nested in a list is checked the same as a top-level one', - `process p { user U { input xs = [${FENCE}cobol\n1\n${FENCE}] } }`, - [unsupportedScriptTag(`Input 'xs'`, 'cobol')], - ], - [ - 'an empty map key says what to write', - `process p { user U { input m = { "": "empty" } } }`, - [EMPTY_MAP_KEY], - ], - [ - 'an empty map key is reached through a nested list and map', - `process p { user U { input m = { rows: [{ cells: { "": 1 } }] } } }`, - [EMPTY_MAP_KEY], - ], - [ - 'an empty or a whitespace-only string value writes no value at all', - `process p { user U { input a = "" input b = " " } }`, - [warn(EMPTY_STRING_VALUE_MESSAGE), warn(EMPTY_STRING_VALUE_MESSAGE)], - ], - [ - "an empty string reached through a list item or a map entry's value warns the same way", - `process p { user U { input a = ["x", ""] input b = { k: "" } } }`, - [warn(EMPTY_STRING_VALUE_MESSAGE), warn(EMPTY_STRING_VALUE_MESSAGE)], - ], [ 'a parameter name is in scope for the collection it feeds', `process p { start S subprocess B { input lines = ["a", "b"] } { user U for each line in lines } end E }`, @@ -4626,192 +3887,100 @@ checks('Validation - input and output parameters', [ `process p { start S user U { output total = 1 } step T { input seen = total } end E }`, [], ], - [ - "an author's own declaration wins over the seeded parameter name", - `process p { var count: string user U { input count = 1 } step T for 3 until (count >= 2) }`, - [typeMismatch('count', 'string', 'an ordered comparison', '>=')], - ], - [ - 'a key holding a quote, a brace, a newline, or non-ASCII text is accepted', - `process p { user U { input m = { "say \\"hi\\"": 1, "{ braces }": 2, "two -lines": 3, "Grüße 日本": 4 } } }`, - [], - ], ]); checks('Validation - injected fields', [ [ - 'a class-bound service task, a delegate-bound send task, and a class-bound decision step carry a field, quoted or as an expression, beside io parameters', - `process p { service V(class: "com.acme.D") { field greeting = "hello" } send N(delegate: "\${sender}") { field subject = "\${topic}" } decide D(class: "com.acme.R") { input amount = 1 field greeting = "hello" output risk = 2 } }`, - [], - ], - [ - 'an expression-bound service task takes no field', - `process p { service V(expression: "\${bean.run(execution)}") { field greeting = "hello" } }`, - [fieldBinding('A service task', 'expression')], - ], - [ - 'a topic-bound service task hands work to a worker, so it takes no field', - `process p { service V(topic: "t") { field greeting = "hello" } }`, - [fieldBinding('A service task', 'topic')], - ], - [ - 'a decision-bound decide step runs no implementation, so it takes no field', - `process p { decide D(decision: "riskRating") { field greeting = "hello" } }`, - [fieldBinding('A decision step', 'decision')], - ], - [ - 'a service task binding nothing at all has no binding to name in the refusal', - `process p { service V { field greeting = "hello" } }`, - [ - fieldBinding('A service task'), - bindingRequired(`Service task 'V'`, SERVICE_BINDINGS), - ], - ], - [ - 'a field and a member of an unknown direction report in the order they are written', - `process p { service V(class: "com.acme.D") { field greeting = 3 fld x = 1 } }`, + "a field value's binding rules apply the same way across tasks and listeners", + `process p { + var salutation: string + start S { field greeting = "hello" } + service V(class: "com.acme.D") { field greeting = "hello" } send N(delegate: "\${sender}") { field subject = "\${topic}" } decide D(class: "com.acme.R") { input amount = 1 field greeting = "hello" output risk = 2 } + service V2(expression: "\${bean.run(execution)}") { field greeting = "hello" } + service V3(topic: "t") { field greeting = "hello" } + decide D2(decision: "riskRating") { field greeting = "hello" } + service V4(class: "com.acme.D3") { field greeting = 3 fld x = 1 } + user U { field greeting = "hello" } + service V5(class: "com.acme.D4") { field retries = 3 field greeting = salutation field greetings = ["a", "b"] field text = { text: "hi" } field body = ${FENCE}groovy\n"hi"\n${FENCE} } + service V6(class: "com.acme.D5") { field greeting = "hi" field greeting = "ho" } + service V7(class: "com.acme.D6") { input greeting = 1 field greeting = "hi" } + service V8(class: "com.acme.D7") { fld greeting = "hi" } + user U2 { on start(class: "com.acme.L") { field greeting = "hello" } on complete(delegate: "\${listenerBean}") { field greeting = "hello" } } + user U3 { on start(expression: "\${bean.run(task)}") { field greeting = "hello" } } + user U4 { on start ${FENCE}groovy\nlog(task)\n${FENCE} { field greeting = "hello" } } + user U5 { on start(class: "com.acme.L2") ${FENCE}groovy\nlog(task)\n${FENCE} { field greeting = "hello" } } + user U6 { on start(class: "com.acme.L3") { input x = 1 } } +}`, [ + noFieldHostMessage('a start event'), + fieldBinding('A service task', 'expression'), + fieldBinding('A service task', 'topic'), + fieldBinding('A decision step', 'decision'), fieldValueMessage('greeting'), unknownDirectionMessage('fld', ['input', 'output', 'field', 'property']), - ], - ], - [ - 'a user task names the kinds that take a field', - `process p { user U { field greeting = "hello" } }`, - [noFieldHostMessage('a user task')], - ], - [ - 'a start event names the kinds that take a field', - `process p { start S { field greeting = "hello" } }`, - [noFieldHostMessage('a start event')], - ], - [ - 'a class-bound listener and a delegate-bound task listener carry a field', - `process p { user U { on start(class: "com.acme.L") { field greeting = "hello" } on complete(delegate: "\${listenerBean}") { field greeting = "hello" } } }`, - [], - ], - [ - 'an expression-bound listener takes no field', - `process p { user U { on start(expression: "\${bean.run(task)}") { field greeting = "hello" } } }`, - [ + noFieldHostMessage('a user task'), + fieldValueMessage('retries'), + fieldValueMessage('greeting'), + fieldValueMessage('greetings'), + fieldValueMessage('text'), + fieldValueMessage('body'), + duplicateParameter('field', 'greeting'), + unknownDirectionMessage('fld', ['input', 'output', 'field', 'property']), fieldBindingMessage( `The 'on start' listener`, ['expression'], LISTENER_FIELD_BINDINGS, ), + scriptListenerFieldMessage(`The 'on start' listener`), + scriptListenerFieldMessage(`The 'on start' listener`), + unknownDirectionMessage('input', ['field']), ], ], [ - 'a fenced-script listener takes no field', - `process p { user U { on start ${FENCE}groovy\nlog(task)\n${FENCE} { field greeting = "hello" } } }`, - [scriptListenerFieldMessage(`The 'on start' listener`)], - ], - [ - 'a fenced script beside a class binding is still what refuses the field', - `process p { user U { on start(class: "com.acme.L") ${FENCE}groovy\nlog(task)\n${FENCE} { field greeting = "hello" } } }`, - [scriptListenerFieldMessage(`The 'on start' listener`)], - ], - [ - 'a number, a bareword, a list, a map, and an inline script are not field values, one refusal each', - `process p { var salutation: string service V(class: "com.acme.D") { field retries = 3 field greeting = salutation field greetings = ["a", "b"] field text = { text: "hi" } field body = ${FENCE}groovy\n"hi"\n${FENCE} } }`, + 'a service task binding nothing at all has no binding to name in the refusal', + `process p { service V { field greeting = "hello" } }`, [ - fieldValueMessage('retries'), - fieldValueMessage('greeting'), - fieldValueMessage('greetings'), - fieldValueMessage('text'), - fieldValueMessage('body'), + fieldBinding('A service task'), + bindingRequired(`Service task 'V'`, SERVICE_BINDINGS), ], ], - [ - 'a repeated field name is one error', - `process p { service V(class: "com.acme.D") { field greeting = "hi" field greeting = "ho" } }`, - [duplicateParameter('field', 'greeting')], - ], - [ - 'a field and an input of the same name are independent namespaces', - `process p { service V(class: "com.acme.D") { input greeting = 1 field greeting = "hi" } }`, - [], - ], - [ - 'an unrecognized direction on a service task names field as legal too', - `process p { service V(class: "com.acme.D") { fld greeting = "hi" } }`, - [unknownDirectionMessage('fld', ['input', 'output', 'field', 'property'])], - ], - [ - "a listener's block takes a field alone, so an input there is unrecognized", - `process p { user U { on start(class: "com.acme.L") { input x = 1 } } }`, - [unknownDirectionMessage('input', ['field'])], - ], ]); /** `BpmnParse.parseExternalServiceTask` reads these extras and nothing else does. */ checks('Validation - external task extras', [ [ - 'a priority under a class binding names the binding written', - `process p { service V(class: "c.D", taskPriority: 5) }`, - [topicBindingMessage('A service task', "'taskPriority'", ['class'])], - ], - [ - 'a priority under a decision binding names the binding written', - `process p { decide D(decision: "k", taskPriority: 5) }`, - [topicBindingMessage('A decision step', "'taskPriority'", ['decision'])], - ], - [ - 'a priority on a thrown message is an unknown key, topic or not', - `process p { start S emit message M("X", topic: "t", taskPriority: 5) }`, - [notValidOn('taskPriority', 'an emit statement')], - ], - [ - 'a property line under a class binding names the binding written', - `process p { service V(class: "c.D") { property k = "v" } }`, - [topicBindingMessage('A service task', 'a property line', ['class'])], - ], - [ - 'a property value is text', - `process p { service V(topic: "t") { property k = ["a"] } }`, - [propertyValueMessage('k')], - ], - [ - 'a repeated property key is one duplicate', - `process p { service V(topic: "t") { property k = "1" property k = "2" } }`, - [duplicateParameter('property', 'k')], - ], - [ - 'a property declares no process variable', - `process p { service V(topic: "t") { property k = "v" } if (k) { step A } }`, - [warn(undeclared('k'))], - ], - [ - 'a mapping under a class binding names the binding written', - `process p { error E service V(class: "c.D") { error E when "\${x}" } }`, - [topicBindingMessage('A service task', 'an error mapping', ['class'])], - ], - [ - 'a mapping raises an error and nothing else', - `process p { escalation S service V(topic: "t") { escalation S when "\${x}" } }`, - [MAPPING_HEAD_MESSAGE], - ], - [ - 'a mapping is written with when', - `process p { error E service V(topic: "t") { error E wenn "\${x}" } }`, - [MAPPING_WHEN_MESSAGE], - ], - [ - 'a mapping naming an undeclared code draws the linker message alone', - `process p { service V(topic: "t") { error NOPE when "\${x}" } }`, - [codeNotDeclared('error', 'NOPE')], - ], - // `externalTask` is resolved on an external task's execution alone. - [ - "a mapping's condition reads the external task", - `process p { error E service V(topic: "t") { error E when externalTask.errorMessage == "x" } }`, - [], - ], - [ - 'an if reading the external task warns like any undeclared variable', - `process p { if (externalTask.retries == 0) { step A } }`, - [warn(undeclared('externalTask'))], + 'external task priority, properties, and mappings each name the binding they belong to', + `process p { + error E + escalation S + start S emit message M("X", topic: "t", taskPriority: 5) + service V(class: "c.D", taskPriority: 5) + decide D2(decision: "k", taskPriority: 5) + service V2(class: "c.D3") { property k = "v" } + service V3(topic: "t") { property k = ["a"] } + service V4(topic: "t") { property k = "1" property k = "2" } + service V5(topic: "t") { property k = "v" } if (k) { step A } + service V6(class: "c.D4") { error E when "\${x}" } + service V7(topic: "t") { escalation S when "\${x}" } + service V8(topic: "t") { error E wenn "\${x}" } + service V9(topic: "t") { error NOPE when "\${x}" } + service V10(topic: "t") { error E when externalTask.errorMessage == "x" } + if (externalTask.retries == 0) { step A2 } +}`, + [ + codeNotDeclared('error', 'NOPE'), + warn(undeclared('k')), + warn(undeclared('externalTask')), + notValidOn('taskPriority', 'an emit statement'), + topicBindingMessage('A service task', "'taskPriority'", ['class']), + topicBindingMessage('A decision step', "'taskPriority'", ['decision']), + topicBindingMessage('A service task', 'a property line', ['class']), + propertyValueMessage('k'), + duplicateParameter('property', 'k'), + topicBindingMessage('A service task', 'an error mapping', ['class']), + MAPPING_HEAD_MESSAGE, + MAPPING_WHEN_MESSAGE, + ], ], ]); @@ -4841,50 +4010,32 @@ checks( checks('Validation - form references on a user task', [ [ - 'a form reference resolved by binding or pinned to a version, a bareword form id, and a form key alone are accepted', - `process p { user T(formRef: "review-form", binding: latest) user U(formRef: "review-form", version: 2) user V(formRef: reviewForm, binding: latest) user W(formKey: "review-form") }`, - [], - ], - [ - 'a form key beside a form reference is one error', - `process p { user T(formKey: "k", formRef: "review-form", binding: latest) }`, - [FORM_KEY_AND_REF_MESSAGE], - ], - [ - 'a form reference with no binding is one error', - `process p { user T(formRef: "review-form") }`, - [FORM_REF_BINDING_MESSAGE], - ], - [ - 'a binding with no form reference has nothing to pin', - `process p { user T(binding: latest) }`, - [FORM_REF_MISSING_MESSAGE], - ], - [ - 'a version with no form reference has nothing to pin', - `process p { user T(version: 2) }`, - [FORM_REF_MISSING_MESSAGE], - ], - [ - 'a binding and a version together is reported once', - `process p { user T(formRef: "review-form", binding: latest, version: 2) }`, - [bindingVersionClash('A user task')], - ], - [ - "'binding: version' redirects to the numeric form", - `process p { user T(formRef: "review-form", binding: version) }`, - [BINDING_IS_VERSION], - ], - [ - 'an unrecognized binding names the two it accepts', - `process p { user T(formRef: "review-form", binding: newest) }`, - [BINDING_VALUE], + 'a form reference and a form key each validate their own binding and version rules', + `process p { + user T(formRef: "review-form", binding: latest) user U(formRef: "review-form", version: 2) user V(formRef: reviewForm, binding: latest) user W(formKey: "review-form") + user T2(formKey: "k", formRef: "review-form", binding: latest) + user T3(formRef: "review-form") + user T4(binding: latest) + user T5(version: 2) + user T6(formRef: "review-form", binding: latest, version: 2) + user T7(formRef: "review-form", binding: version) + user T8(formRef: "review-form", binding: newest) +}`, + [ + FORM_KEY_AND_REF_MESSAGE, + FORM_REF_BINDING_MESSAGE, + FORM_REF_MISSING_MESSAGE, + FORM_REF_MISSING_MESSAGE, + bindingVersionClash('A user task'), + BINDING_IS_VERSION, + BINDING_VALUE, + ], ], ]); checks('Validation - listeners', [ [ - 'every task-listener event is accepted on a user task', + 'a task listener validates its event, binding, and timer the same way a task does', `process p { user U { on create(class: "com.example.A") @@ -4896,93 +4047,52 @@ checks('Validation - listeners', [ on delete(class: "com.example.D") on timeout after "PT8H"(class: "com.example.T") } + service V(topic: "t") { on create(class: "com.example.C") } + user U2 { on start(class: "com.example.L", alongside) } + user U3 { on assign(class: "com.example.C2") } + subprocess S { on wibble(class: "com.example.C3") } { user U4 } + user U5 { on start } + user U6 { on start(class: "com.example.C4", delegate: "\${bean}") } + user U7 { on start(topic: "t") } + user U8 { on timeout(class: "com.example.T2") } + user U9 { on create after "PT1H" (class: "com.example.C5") } + user U10 { on timeout whenever "PT1H2" (class: "com.example.T3") } + user U11 { on start ${FENCE}php\necho 1;\n${FENCE} } + user U12 { on start ${FENCE}groovy\n${FENCE} } }`, - [], - ], - [ - 'a task-listener event elsewhere says only a user task has one', - `process p { service V(topic: "t") { on create(class: "com.example.C") } }`, - [taskListenerOnly('create', 'a service task')], - ], - [ - 'a listener binds and nothing else, so a bare flag is refused there too', - `process p { user U { on start(class: "com.example.L", alongside) } }`, - [flagNotValidOn('alongside', 'a listener')], - ], - [ - "'on assign' is refused with the list, whose word is 'assignment' (BpmnParse.parseTaskListeners)", - `process p { user U { on assign(class: "com.example.C") } }`, - [unknownListenerEvent('assign', USER_LISTENER_EVENTS)], - ], - [ - 'an unknown event word lists only the execution events elsewhere', - `process p { subprocess S { on wibble(class: "com.example.C") } { user U } }`, - [unknownListenerEvent('wibble', `'start' or 'end'`)], - ], - [ - 'a listener with no binding names the three attributes and the script body', - `process p { user U { on start } }`, [ + taskListenerOnly('create', 'a service task'), + flagNotValidOn('alongside', 'a listener'), + unknownListenerEvent('assign', USER_LISTENER_EVENTS), + unknownListenerEvent('wibble', `'start' or 'end'`), bindingRequired( `The 'on start' listener`, LISTENER_BINDINGS, ', or a fenced script body', ), - ], - ], - [ - 'a listener with two bindings names both', - `process p { user U { on start(class: "com.example.C", delegate: "\${bean}") } }`, - [ bindingConflict( `The 'on start' listener`, 'class, delegate', LISTENER_BINDINGS, ), - ], - ], - [ - 'a key that binds nothing is rejected inside a listener block', - `process p { user U { on start(topic: "t") } }`, - [ notValidOn('topic', 'a listener'), bindingRequired( `The 'on start' listener`, LISTENER_BINDINGS, ', or a fenced script body', ), + LISTENER_TIMER_PAYLOAD_MESSAGE, + LISTENER_PARTICLE_ONLY, + unknownParticle('whenever'), + unsupportedScriptTag(`The 'on start' listener`, 'php'), + emptyScript(`The 'on start' listener`), ], ], - [ - 'a timeout listener without a timer asks for the particle clause', - `process p { user U { on timeout(class: "com.example.T") } }`, - [LISTENER_TIMER_PAYLOAD_MESSAGE], - ], - [ - 'a timer on any other listener event is one error', - `process p { user U { on create after "PT1H" (class: "com.example.C") } }`, - [LISTENER_PARTICLE_ONLY], - ], - [ - 'an unknown particle on a timeout listener names the legal ones', - `process p { user U { on timeout whenever "PT1H" (class: "com.example.T") } }`, - [unknownParticle('whenever')], - ], [ 'listeners repeat freely and fire in written order (CoreModelElement.addListenerToMap appends)', `process p { user U { on start(class: "a.B") on start(class: "c.D") on timeout after "PT1H"(class: "x.Y") on timeout after "P1D"(class: "x.Z") } }`, [], ], - [ - "a script binding follows the script task's fence rules", - `process p { user U { on start ${FENCE}php\necho 1;\n${FENCE} } }`, - [unsupportedScriptTag(`The 'on start' listener`, 'php')], - ], - [ - 'an empty script binding is one error', - `process p { user U { on start ${FENCE}groovy\n${FENCE} } }`, - [emptyScript(`The 'on start' listener`)], - ], ...scriptTagCases( (tag) => `process p { user U { on start ${FENCE}${tag}\nx = 1\n${FENCE} } }`, @@ -4991,9 +4101,7 @@ checks('Validation - listeners', [ ]); describe('Validation - the timer clause, reported in place', () => { - // A listener names its kind in `event` and everything else in `trigger`, so - // the squiggle covers the word the author wrote rather than the whole - // construct around it. + // The squiggle covers the word the author wrote, not the whole listener. test.each([ [ 'a listener is marked on its event word', @@ -5120,10 +4228,6 @@ checks('Validation - process header value shapes', [ const DECORATED_CLAUSE = 'for each line in lines sequentially until (nrOfCompletedInstances >= 2)'; -/** - * Each host writes whatever else its own validation demands, so the only - * diagnostics a case can produce are the slots'. - */ const REPEAT_HOSTS: Array< [ kind: string, @@ -5161,115 +4265,70 @@ function repeatHost(head: string, own = '', tail = '') { describe('Validation - the repeat clause', () => { // Nothing in the clause's validation branches on the statement kind or on how - // deeply it nests, so one host carries the forms; per-host reachability is the - // parameter matrices below and the parse sweep in `parsing.test.ts`. - test.each([ - 'for 3', - 'for each line in lines', - 'for each in lines', - 'for 2 each line in lines', - DECORATED_CLAUSE, - ])('`%s` validates clean', async (clause) => { - expect( - await diagnosticsOf(`process p { var lines: json user U ${clause} }`), - ).toEqual([]); - }); - - test.each(['for 1.5', 'for -3', 'for "high"'])( - '`%s` is not a repeat count', - async (clause) => { - expect(await diagnosticsOf(`process p { user U ${clause} }`)).toEqual([ - REPEAT_COUNT_MESSAGE, - ]); - }, - ); - - test.each(['for "3"', 'for count', `for "\${count}"`])( - '`%s` is a clean repeat count', - async (clause) => { - expect( - await diagnosticsOf(`process p { var count: number user U ${clause} }`), - ).toEqual([]); - }, - ); - - test.each([ - ['a subprocess', `subprocess Outer { user U ${DECORATED_CLAUSE} }`], - ['an on handler body', `start S on error { user U ${DECORATED_CLAUSE} }`], - ])('the decorated form validates clean inside %s', async (_where, body) => { - expect( - await diagnosticsOf(`process p { var lines: json ${body} }`), - ).toEqual([]); - }); - - test.each([ - 'nrOfInstances', - 'nrOfActiveInstances', - 'nrOfCompletedInstances', - 'loopCounter', - ])('`%s` is in scope in a process carrying a clause', async (variable) => { + // deeply it nests, so one host carries the forms. + test('every clause form validates, and only a count that is no integer or expression is refused', async () => { + const clauses = [ + 'for 3', + 'for each line in lines', + 'for each in lines', + 'for 2 each line in lines', + DECORATED_CLAUSE, + 'for "3"', + 'for count', + `for "\${count}"`, + ...[ + 'nrOfInstances', + 'nrOfActiveInstances', + 'nrOfCompletedInstances', + 'loopCounter', + ].map((variable) => `for 3 until (${variable} >= 2)`), + 'for 1.5', + 'for -3', + 'for "high"', + ]; expect( await diagnosticsOf( - `process p { user U for 3 until (${variable} >= 2) }`, + `process p { var lines: json var count: number ${clauses.map((c, i) => `user U${i} ${c}`).join(' ')} subprocess Outer { user V ${DECORATED_CLAUSE} } on error { user W ${DECORATED_CLAUSE} } }`, ), - ).toEqual([]); + ).toEqual([ + REPEAT_COUNT_MESSAGE, + REPEAT_COUNT_MESSAGE, + REPEAT_COUNT_MESSAGE, + ]); }); test.each(REPEAT_HOSTS)( - "an 'output' parameter on a repeated %s is one error saying to move it", - async (_kind, _description, statement) => { - expect( - await diagnosticsOf( - `process p { ${statement('for 3', '', 'output b = 1')} }`, - ), - ).toEqual([REPEATED_OUTPUT_MESSAGE]); - }, - ); - - test.each(REPEAT_HOSTS)( - "an 'input' parameter on a repeated %s is accepted", - async (_kind, _description, statement) => { - expect( - await diagnosticsOf( - `process p { ${statement('for 3', '', 'input a = 1')} }`, - ), - ).toEqual([]); - }, - ); - - test.each(REPEAT_HOSTS)( - "an 'output' parameter on an unrepeated %s stays clean", + "only an 'output' parameter on a repeated %s is refused", async (_kind, _description, statement) => { expect( - await diagnosticsOf( - `process p { ${statement('', '', 'output b = 1')} }`, + await Promise.all( + [ + statement('for 3', '', 'input a = 1 output b = 1'), + statement('', '', 'output b = 1'), + ].map((body) => diagnosticsOf(`process p { ${body} }`)), ), - ).toEqual([]); + ).toEqual([[REPEATED_OUTPUT_MESSAGE], []]); }, ); }); checks('Validation - the repeat clause in scope', [ [ - 'the element variable a clause binds is in scope inside the statement', - `process p { var lines: json user U for each line in lines { input x = line } }`, - [], + "the repeat clause's element variable is in scope, and an author's own declaration wins", + `process p { + var lines: json + var loopCounter: string + user U for each line in lines { input x = line } + user U2 for 3 until (loopCounter >= 2) + subprocess B for each line in lines { user U3 for 2 } +}`, + [typeMismatch('loopCounter', 'string', 'an ordered comparison', '>=')], ], [ 'a process with no clause still warns about a loop variable', `process p { user U { input x = loopCounter } }`, [warn(undeclared('loopCounter'))], ], - [ - "an author's own declaration wins over the seeded loop variable", - `process p { var loopCounter: string user U for 3 until (loopCounter >= 2) }`, - [typeMismatch('loopCounter', 'string', 'an ordered comparison', '>=')], - ], - [ - 'a clause nests inside a repeated subprocess', - `process p { var lines: json subprocess B for each line in lines { user U for 2 } }`, - [], - ], ]); /** The four per-run keys with a value each takes, spelled out so a drift in the derivation shows. */ @@ -5293,53 +4352,36 @@ checks('Validation - per-run job settings', [ ], ]), [ - 'a run key on a kind that never repeats is an unknown key there', - `process p { start S(runAsyncBefore: true) }`, - [notValidOn('runAsyncBefore', 'a start event')], - ], - [ - 'a run key on a gateway head is an unknown key there', - `process p { var a: boolean if (a) (runAsyncBefore: true) { user A } }`, - [notValidOn('runAsyncBefore', 'an if statement')], - ], - [ - 'runJobPriority on a repeated step names the key the engine reads', - `process p { step X for 2(runJobPriority: 5) }`, - [RUN_JOB_PRIORITY_MESSAGE], + 'a per-run job key is legal only on a step that actually repeats', + `process p { + var a: boolean + start S(runAsyncBefore: true) + if (a) (runAsyncBefore: true) { user A } + step X for 2(runJobPriority: 5) + service V for 3(topic: "t", runAsyncBefore: true, runExclusive: "false") + service V2 for 3(topic: "t", runAsyncBefore: true, runRetryCycle: 3) + service V3 for 3(topic: "t", runAsyncBefore: true, runRetryCycle: R2) + step X2 for 2(asyncBefore: true, runAsyncBefore: true) +}`, + [ + notValidOn('runAsyncBefore', 'a start event'), + notValidOn('runAsyncBefore', 'an if statement'), + RUN_JOB_PRIORITY_MESSAGE, + quotedBoolean('runExclusive'), + unquotedText('runRetryCycle'), + unquotedText('runRetryCycle'), + ], ], [ 'runJobPriority on a kind that never repeats is an unknown key there', `process p { start S(runJobPriority: 5) }`, [notValidOn('runJobPriority', 'a start event')], ], - [ - 'a quoted boolean in a run key names the unquoted form', - `process p { service V for 3(topic: "t", runAsyncBefore: true, runExclusive: "false") }`, - [quotedBoolean('runExclusive')], - ], - [ - 'a number in runRetryCycle asks for quotes', - `process p { service V for 3(topic: "t", runAsyncBefore: true, runRetryCycle: 3) }`, - [unquotedText('runRetryCycle')], - ], - [ - 'a bareword in runRetryCycle asks for quotes and names no variable', - `process p { service V for 3(topic: "t", runAsyncBefore: true, runRetryCycle: R2) }`, - [unquotedText('runRetryCycle')], - ], - [ - 'a run key beside the step key of the same setting is clean: one job around, one per run', - `process p { step X for 2(asyncBefore: true, runAsyncBefore: true) }`, - [], - ], ]); /** - * A hard keyword in a slot makes the parser recover with the slot left empty, - * so each program reaches one check that reads an empty name or trigger back. - * A row without a word empties its slot another way: a form field type is a - * keyword alternation with no `ID` alternative, and a trailing `STRING` or - * value can be left out. + * A hard keyword in a slot makes the parser recover with the slot empty; a row + * without a word empties its slot another way, by omitting it. */ const UNPARSED_SLOT: Array<[where: string, program: string, word?: string]> = [ ['a `var` name', `process p { var while: number start S end E }`, 'while'], @@ -5351,8 +4393,7 @@ const UNPARSED_SLOT: Array<[where: string, program: string, word?: string]> = [ `process p { start S subprocess while { user U } end E }`, 'while', ], - // A name is only read back when something crosses the boundary it labels, and - // the reserved-word spelling above cannot get far enough to be crossed. + // A container name is only read back when a goto crosses it. [ 'a subprocess name a goto reaches into', `process p { start S subprocess { user U } goto U end E }`, @@ -5362,8 +4403,7 @@ const UNPARSED_SLOT: Array<[where: string, program: string, word?: string]> = [ `process p { start S subprocess { goto Fin } user U end Fin }`, ], ['a process name', `process while { start S end E }`, 'while'], - // Each duplicate walk names the process it scopes, so the reader is a second - // declaration colliding with the first rather than the empty name alone. + // A duplicate walk is what reads the process name back. [ 'a process name a duplicate step name reports', `process { start S user U user U end E }`, @@ -5424,8 +4464,7 @@ const UNPARSED_SLOT: Array<[where: string, program: string, word?: string]> = [ 'while', ], [ - // The colliding `var` is the reader: without it the agreement check that - // names the type never runs. + // The colliding `var` makes the agreement check read the type. 'a form field type', `process p { var a: number start S user U { form { a: while } } end E }`, ], @@ -5439,8 +4478,7 @@ const UNPARSED_SLOT: Array<[where: string, program: string, word?: string]> = [ `process p { error C(message:) start S end E }`, ], ['an io parameter value', `process p { start S user U { input a } end E }`], - // The two keys a duplicate walk builds from more than one slot: a template - // literal would stringify the missing half and collide with itself. + // Duplicate keys built from two slots, where a missing half would stringify. [ 'a call mapping target', `process p { start S call C(process: "q") { in in } end E }`, @@ -5458,58 +4496,42 @@ const UNPARSED_SLOT: Array<[where: string, program: string, word?: string]> = [ ]; describe('Validation - an unparsed slot', () => { - const containing = (messages: string[], needle: string) => - messages.filter((message) => message.includes(needle)); - - test.each(UNPARSED_SLOT)( - '%s produces no crash and no diagnostic naming an element it cannot name', - async (_where, program, word) => { + test('no unparsed slot crashes a check or draws a diagnostic naming what it cannot name', async () => { + const problems: string[][] = []; + for (const [where, program, word] of UNPARSED_SLOT) { const messages = await diagnosticsOf(program); - if (word !== undefined) { - expect(messages).toContain(reservedWord(word)); + const wrong = messages.filter( + (m) => + m.includes('An error occurred during validation') || + m.includes('undefined'), + ); + if (word !== undefined && !messages.includes(reservedWord(word))) { + wrong.push(`missing: ${reservedWord(word)}`); } - expect( - containing(messages, 'An error occurred during validation'), - ).toEqual([]); - expect(containing(messages, 'undefined')).toEqual([]); - }, - ); + if (wrong.length > 0) problems.push([where, ...wrong]); + } + expect(problems).toEqual([]); + }); - // The boundary explanation names the container that was crossed, so an - // unparsed name or trigger leaves it nothing to name and it stands down. - test.each([ - [ - 'a handler a goto reaches into', - `process p { start S on { step T } goto T end E }`, - 'T', - ], - [ - 'a handler a goto reaches out of', - `process p { start S on { goto T } step T end E }`, - 'T', - ], - [ - 'a subprocess a goto reaches into', - `process p { start S subprocess { user U } goto U end E }`, - 'U', - ], - [ - 'a subprocess a goto reaches out of', - `process p { start S subprocess { goto Fin } user U end Fin }`, - 'Fin', - ], - ])( - '%s keeps the stock unresolved-reference message', - async (_where, program, name) => { - expect(await diagnosticsOf(program)).toContain(unresolvedStatement(name)); - }, - ); + // With no container name to cite, the boundary explanation stands down. + test('a goto across an unnamed handler or subprocess keeps the stock unresolved-reference message', async () => { + const crossings: Array<[program: string, name: string]> = [ + [`process p { start S on { step T } goto T end E }`, 'T'], + [`process p { start S on { goto T } step T end E }`, 'T'], + [`process p { start S subprocess { user U } goto U end E }`, 'U'], + [`process p { start S subprocess { goto Fin } user U end Fin }`, 'Fin'], + ]; + for (const [program, name] of crossings) { + expect(await diagnosticsOf(program), program).toContain( + unresolvedStatement(name), + ); + } + }); test('an unterminated fence resolves to the malformed-body error', async () => { const messages = await diagnosticsOf( `process p { script total ${FENCE}js\nx = 1\n }`, ); - // Reported exactly once, while tolerating surrounding parser noise. expect(messages.filter((m) => m === unterminatedScript('total'))).toEqual([ unterminatedScript('total'), ]); diff --git a/packages/language/test/variables.test.ts b/packages/language/test/variables.test.ts index 1bde365..527ef6a 100644 --- a/packages/language/test/variables.test.ts +++ b/packages/language/test/variables.test.ts @@ -1,8 +1,3 @@ -/** - * `VariableSymbolProvider.collect`, the flat table of every name a process - * declares, driven through the injected service on a parsed process. - */ - import { beforeAll, describe, expect, test } from 'vitest'; import { EmptyFileSystem } from 'langium'; import { parseHelper } from 'langium/test'; diff --git a/packages/language/test/vocabulary-sync.test.ts b/packages/language/test/vocabulary-sync.test.ts index dd53abc..979758c 100644 --- a/packages/language/test/vocabulary-sync.test.ts +++ b/packages/language/test/vocabulary-sync.test.ts @@ -1,11 +1,5 @@ -/** - * Lists that have to agree with a list they cannot import: the TextMate - * grammar cannot read TypeScript, so its keyword alternation is a second - * derivation of what the parser reserves, generated into the gitignored - * `syntaxes/` where a stale copy never shows in a diff. A trigger word and a - * listener event both follow `on`, so one word in both would give `on ` - * two meanings. - */ +// The TextMate grammar cannot import TypeScript, and its generated copy under +// the gitignored `syntaxes/` never shows in a diff, so these pin the two lists. import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -98,7 +92,7 @@ function grammarPatterns(): { name?: string; match?: string }[] { .patterns; } -/** Every match of the named top-level pattern as a JS `RegExp`; Oniguruma agrees on the lookaround and classes used here, as `injection-grammar.test.ts` assumes too. */ +/** Oniguruma agrees with JS `RegExp` on the lookaround and classes used here. */ function tokensOf(line: string, namePrefix: string): string[] { const pattern = grammarPatterns().find((p) => p.name?.startsWith(namePrefix)); if (!pattern?.match) { diff --git a/packages/transform/src/ast-to-ir.ts b/packages/transform/src/ast-to-ir.ts index 470b2a8..9f318ef 100644 --- a/packages/transform/src/ast-to-ir.ts +++ b/packages/transform/src/ast-to-ir.ts @@ -1,20 +1,11 @@ /** - * Desugaring AST -> IR. Lowers the Langium AST into the flat, BPMN-shaped - * {@link BpmnProcess}: control-flow keywords become gateways and sequence - * flows, implicit flow and implicit start/end events are materialized, and - * conditions render to `${...}` bodies. - * - * Every synthesized id comes from `./synthesize-ids.js`, seeded by a structural - * coordinate ``: the statement's static position in the block tree, never a - * traversal counter, so re-running this on `irToDsl` output yields identical - * ids. See ADR 0010, Use Deterministic Structural Ids for Synthesized BPMN - * Elements. Two things the ADR leaves open: an `on` handler owns a single - * block, so like a loop body its enclosing coordinate is its own ``; and a - * sub-process body is rooted at that coordinate rather than at the - * sub-process's name, because gateway ids skip `resolveCollision` and a + * Synthesized ids are seeded by a structural coordinate `` (the statement's + * static position in the block tree, never a traversal counter), so lowering + * `irToDsl` output again yields identical ids. A sub-process body is rooted at + * its coordinate, not its name: gateway ids skip `resolveCollision`, so a * sub-process named like a coordinate could otherwise duplicate one. * - * The desugarer is total: it never throws on a program the validator rejects. + * Throws only on a program the validator rejects. */ import { AstUtils } from 'langium'; @@ -187,36 +178,32 @@ import { } from './synthesize-ids.js'; /** - * The fall-through boundary of a lowered statement or block. A `null` `exit` - * suppresses both the implicit flow to the next sibling and the join/end - * continuation. + * A `null` `exit` suppresses both the implicit flow to the next sibling and the + * join/end continuation. */ interface Frontier { - /** Node an incoming flow targets; `null` for an empty block, whose caller routes to the join. */ + /** `null` for an empty block, whose caller routes to the join. */ entry: string | null; exit: string | null; /** - * The exact id the fall-through flow out of `exit` takes, a gateway's - * reserved default flow. Only set beside a non-null `exit`, so a `start` - * taking the empty `exit` slot leaves it alone. + * A gateway's reserved default flow id for the fall-through out of `exit`; + * only set beside a non-null `exit`. */ exitFlowId?: string; - /** Starts beyond `exit` still waiting for a step; a container body routes them to its end. */ + /** + * Starts beyond `exit` still waiting for a step; a container body routes them + * to its end. + */ waitingStarts?: string[]; } -/** - * One container's elements. `taken` is shared document-wide and seeded with - * every authored name, so a synthesized id never clashes with one (BPMN `id` - * is an XML ID). - */ +/** `taken` is shared document-wide and seeded with every authored name. */ interface Builder { readonly flowElements: FlowElement[]; readonly sequenceFlows: IrSequenceFlow[]; readonly taken: Set; } -/** A nested container's own element lists over the parent's `taken` set. */ function nestedBuilder(parent: Builder): Builder { return { flowElements: [], sequenceFlows: [], taken: parent.taken }; } @@ -234,11 +221,8 @@ export function astToIr(model: Model): BpmnProcess { taken: collectNamedIds(process), }; - // Both the top-level coordinate and the implicit-event seed are the process id. lowerContainerBody(builder, process.body, process.name, process.name); - const label = processSetting(process, 'label'); - const documentation = processSetting(process, 'documentation'); const versionTag = processSetting(process, 'versionTag'); const historyTimeToLive = processSetting(process, 'historyTimeToLive'); const candidateStarterUsers = processSetting( @@ -262,8 +246,7 @@ export function astToIr(model: Model): BpmnProcess { return { id: process.name, - ...(label !== undefined ? { name: label } : {}), - ...(documentation !== undefined ? { documentation } : {}), + ...namedAttrs(process), isExecutable: true, ...(versionTag !== undefined ? { versionTag } : {}), ...(historyTimeToLive !== undefined ? { historyTimeToLive } : {}), @@ -283,11 +266,10 @@ interface DeclaredCode { } /** - * The header's error and escalation declarations plus one for every code only - * a throw, an emit, or a catch names, in the order `irToXml` writes roots and - * `xmlToIr` reads them back: every used code in first-use order, then every - * unused declaration in source order. A code no declaration names still gets - * one, since a use site refers to a declaration by name. + * Every used code in first-use order, then unused declarations in source + * order: the order `irToXml` writes roots and `xmlToIr` reads them back. An + * undeclared code still gets a declaration, since a use site refers to one by + * name. */ function collectCodeDecls( process: Process, @@ -324,7 +306,7 @@ function collectCodeDecls( ...[...from.values()].filter((d) => !used.has(d.code)), ]; - // One namespace: a use site resolves error and escalation names in one scope. + // A use site resolves error and escalation names in one scope. const taken = new Set(); const errorDecls = canonical(usedErrorCodes, errors).map((d) => ({ name: claimDeclarationName(d.code, taken, d.name), @@ -340,10 +322,6 @@ function collectCodeDecls( return { errorDecls, escalationDecls }; } -/** - * Lower one flow container's body plus its implicit start and end. `coord` is - * the body's structural coordinate; `containerId` seeds the implicit event ids. - */ function lowerContainerBody( builder: Builder, statements: Statement[], @@ -371,7 +349,7 @@ function lowerContainerBody( addFlow(builder, start, endId); } } else if (body.entry === null) { - // No flow step at all (empty, or only `on` handlers): neither branch above ran. + // Empty, or only `on` handlers. const startId = makeStartEventId(containerId, builder.taken); const endId = makeEndEventId(containerId, builder.taken); builder.flowElements.unshift({ kind: 'startEvent', id: startId }); @@ -381,11 +359,10 @@ function lowerContainerBody( } /** - * Lower a flat statement list with implicit top-to-bottom flow. A `null` exit - * breaks the chain: later statements are still lowered, since they may be jump - * targets, but no implicit flow bridges the gap. A `start` takes no incoming - * flow: it joins the exits waiting for the next step without consuming them, - * so starts written back to back all enter the step after them. + * After a `null` exit later statements are still lowered (they may be jump + * targets), but no implicit flow bridges the gap. A `start` takes no incoming + * flow and does not consume the waiting exit, so back-to-back starts all enter + * the next step. */ function lowerBlockStatements( builder: Builder, @@ -398,7 +375,6 @@ function lowerBlockStatements( let waitingStarts: string[] = []; statements.forEach((stmt, index) => { - // An `on` handler is not a flow step: it lowers out of the chain. if (isOnHandler(stmt)) { if (stmt.host !== undefined) { // As in `lowerGoto`, `$refText` survives an unresolved host. @@ -410,7 +386,6 @@ function lowerBlockStatements( } const frontier = lowerStatement(builder, stmt, coord, index); - // Only an empty block, never a statement, yields a null entry. const stmtEntry = frontier.entry!; if (entry === null) { @@ -444,14 +419,13 @@ function lowerBlockStatements( } /** - * The caller's `coord` carries any branch segment (`_t`, `_e`, - * `_b`), so sibling blocks never share a coordinate. + * The caller's `coord` carries the branch segment (`_t`, `_e`, + * `_b`). */ function lowerBlock(builder: Builder, block: Block, coord: string): Frontier { return lowerBlockStatements(builder, block.statements, coord); } -/** `index` is the statement's position in its block, forming `_`. */ function lowerStatement( builder: Builder, stmt: Statement, @@ -518,7 +492,6 @@ function lowerStatement( if (isIntermediateCatchEvent(stmt)) { return lowerIntermediateCatch(builder, stmt, coord, index); } - // `OnHandler` is intercepted by `lowerBlockStatements` and never reaches here. throw new Error( `astToIr: unexpected statement type '${(stmt as { $type: string }).$type}'.`, ); @@ -541,9 +514,8 @@ function lowerStartEvent(builder: Builder, stmt: AstStartEvent): Frontier { } /** - * The word a position admits, or `undefined`. The literal return type makes - * every dispatch below carry an arm per word in its vocabulary, so a word - * cannot be admitted here and lowered as something else. + * The literal return type forces every dispatch to carry an arm per admitted + * word. */ function admittedTrigger( vocabulary: readonly W[], @@ -554,7 +526,6 @@ function admittedTrigger( : vocabulary.find((word) => word === written); } -/** A word outside `START_TRIGGERS` lowers to nothing; the validator reports it. */ function startEventDefinition( stmt: AstStartEvent, ): EventDefinition | undefined { @@ -564,7 +535,6 @@ function startEventDefinition( : namedTriggerDefinition(trigger, stmt); } -/** Both end words are payload-free, so the word is the kind; any other lowers to nothing. */ function endEventDefinition( trigger: string | undefined, ): EndEventDefinition | undefined { @@ -584,11 +554,6 @@ function lowerEndEvent(builder: Builder, stmt: AstEndEvent): Frontier { return { entry: stmt.name, exit: null }; } -/** - * A user task is the one element with a human lifecycle, so its listener list - * splits two ways: task events into `taskListeners`, `start`/`end` into - * `executionListeners` with every other element's. - */ function lowerUserTask(builder: Builder, stmt: AstUserTask): Frontier { const settings = settingsOf(stmt.items); const assignee = attrValue(settings, 'assignee'); @@ -623,8 +588,8 @@ function lowerUserTask(builder: Builder, stmt: AstUserTask): Frontier { } /** - * A form reference naming no binding is left out: Operaton refuses to deploy - * one, so there is nothing to carry. The validator reports it. + * Dropped without a binding, which Operaton refuses to deploy; the validator + * reports it. */ function readFormRef(attrs: KeyValueAttr[]): IrUserTask['formRef'] { const key = attrValue(attrs, 'formRef'); @@ -678,8 +643,8 @@ function toFormFieldType(type: string): FormFieldType { } /** - * Source order, which the engine validates in. A key outside - * `FORM_CONSTRAINT_NAMES` (`pattern` included) is left to the validator. + * Source order, which the engine validates in. Other keys (`pattern` included) + * are left to the validator. */ function readFormFieldConstraints( settings: KeyValueAttr[], @@ -691,11 +656,7 @@ function readFormFieldConstraints( .map((setting) => formFieldConstraint(setting.key, setting.value)); } -/** - * `validator` reads the shape a `class:` binding takes. Exhaustive on - * purpose: a name added to `FORM_CONSTRAINT_NAMES` stops compiling here until - * it picks a reading. - */ +/** `validator` reads the shape a `class:` binding takes. */ function formFieldConstraint( name: FormConstraintName, value: Expr, @@ -727,7 +688,6 @@ function renderFormDefault(expr: Expr): string { return renderExpression(expr); } -/** The one node the three tags share; `element` picks the tag, absent is a service task. */ function lowerServiceTaskLike( builder: Builder, stmt: AstServiceTask | AstSendTask | AstBusinessRuleTask, @@ -778,14 +738,13 @@ function codeBinding(attrs: KeyValueAttr[]): CodeBinding | undefined { return undefined; } -/** What a binding block with no key resolves to; the validator owns the diagnostic. */ +/** A binding block with no key; the validator reports it. */ const NO_BINDING: CodeBinding = { kind: 'class', className: '' }; function serviceTaskBinding(attrs: KeyValueAttr[]): ServiceTaskBinding { return writtenBinding(attrs) ?? NO_BINDING; } -/** `undefined` where a binding is optional and the block names none. */ function writtenBinding(attrs: KeyValueAttr[]): ServiceTaskBinding | undefined { const code = codeBinding(attrs); if (code !== undefined) { @@ -800,9 +759,8 @@ function writtenBinding(attrs: KeyValueAttr[]): ServiceTaskBinding | undefined { } /** - * Lower-cased first, since `BpmnParse.parseServiceTaskLike` compares the - * attribute case-insensitively; any other value binds nothing here and the - * validator refuses it. + * Lower-cased: `BpmnParse.parseServiceTaskLike` compares the attribute + * case-insensitively. */ function builtinBinding(attrs: KeyValueAttr[]): ServiceTaskBinding | undefined { const written = attrValue(attrs, TYPE_BINDING_KEY)?.toLowerCase(); @@ -857,10 +815,7 @@ function lowerBusinessRuleTask( ); } -/** - * `decision` names a decision table and takes {@link versionBinding} and - * `mapDecisionResult` beside it; without it the block reads as a service task's. - */ +/** Without `decision` the block reads as a service task's. */ function businessRuleBinding(attrs: KeyValueAttr[]): ServiceTaskBinding { const decisionRef = attrValue(attrs, 'decision'); if (decisionRef === undefined) { @@ -888,7 +843,6 @@ function toDecisionResultMapping(mapping: string) { return mapped; } -/** An unrecognized language tag is carried through as-is; the validator rejects it first. */ function lowerScriptTask(builder: Builder, stmt: AstScriptTask): Frontier { const { tag, code } = splitFencedScript(stmt.body); const resultVariable = attrValue(settingsOf(stmt.items), 'resultVariable'); @@ -907,11 +861,9 @@ function lowerScriptTask(builder: Builder, stmt: AstScriptTask): Frontier { } /** - * Lower `if`/`else if`/`else` to an exclusive split and join: one split with a - * conditioned flow per branch, so the head parens govern it and an `else if` - * head takes none. The `else`, or the fall-through standing in for an absent - * one, is the default flow and never carries a condition: Operaton rejects a - * conditioned default. + * One split for the whole chain, so an `else if` head takes no settings. The + * `else` (or the fall-through standing in for it) is the default flow and + * never conditioned: Operaton rejects a conditioned default. */ function lowerIf(builder: Builder, stmt: IfStatement, x: string): Frontier { const splitId = makeGatewaySplitId(x); @@ -959,17 +911,14 @@ function lowerIf(builder: Builder, stmt: IfStatement, x: string): Frontier { interface ForkBranch { block: Block; coord: string; - /** Rendered condition on the flow into the branch; absent means unconditioned. */ condition?: string; /** Forces the flow's id, marking this branch as the gateway's default. */ flowId?: string; } /** - * Lower every branch and rejoin. An empty branch flows straight to the join; a - * terminating one gets no continuation. When no branch claimed the reserved - * `defaultFlowId`, it runs fork -> join, so a fork whose every branch is - * conditioned has somewhere to go when none holds. + * An unclaimed `defaultFlowId` runs fork -> join, so an all-conditioned fork + * has somewhere to go when none holds. */ function lowerForkBranches( builder: Builder, @@ -998,11 +947,6 @@ function lowerForkBranches( } } -/** - * Pre-test XOR loop: the loop gateway takes a conditioned flow into the body, - * an unconditioned default flow out, and the back-edge from the body's exit. - * Never `standardLoopCharacteristics`. - */ function lowerWhile( builder: Builder, stmt: WhileStatement, @@ -1031,11 +975,6 @@ function lowerWhile( return { entry: loopId, exit: loopId, exitFlowId: defaultFlowId }; } -/** - * Post-test XOR loop: the body runs first, and the loop gateway after it holds - * the conditioned back-edge into the body plus an unconditioned default flow - * out. - */ function lowerDoWhile( builder: Builder, stmt: DoWhileStatement, @@ -1069,18 +1008,12 @@ function lowerDoWhile( } /** - * Lower `parallel { { A } { B } ... }` to a fork/join pair: inclusive when any - * branch carries a condition, else an AND pair, on which Operaton ignores a - * condition. - * - * `InclusiveGatewayActivityBehavior.execute` takes the default flow only when - * no other flow was, and an unconditioned flow always is, so the fork gets one - * only where it can run: onto the `else` branch, or straight to the join when - * every branch is conditioned (without it the fork deploys, then throws a - * stuck execution the first time no condition holds). That is also why an - * `else` alone does not make the fork inclusive: beside an unconditioned - * branch it would be dead, and the printer would spell it as an `else` the - * validator refuses. + * Inclusive when any branch is conditioned; Operaton ignores conditions on a + * parallel gateway. The engine takes an inclusive default only when no other + * flow was, and an unconditioned flow always is, so the fork gets a default + * only onto an `else` or, when every branch is conditioned, to the join + * (without it the first time no condition holds is a stuck execution). For the + * same reason an `else` alone does not make the fork inclusive. */ function lowerParallel( builder: Builder, @@ -1099,8 +1032,7 @@ function lowerParallel( (stmt.branches.some((b) => b.otherwise) || stmt.branches.every((b) => b.condition !== undefined)); - // Claimed only where a flow takes it: an idle claim would rename an authored - // collider for nothing. + // An idle claim would rename an authored collider for nothing. let defaultFlowId: string | undefined; if (fallback) { defaultFlowId = reserveDefaultFlowId(builder, forkId); @@ -1122,8 +1054,7 @@ function lowerParallel( builder.flowElements.push({ kind: 'parallelGateway', id: joinId, ...join }); } - // Only the first `else` carries the default flow, so a second one (invalid) - // still lowers deterministically. + // Only the first `else`, so an invalid second one lowers deterministically. const defaultIndex = inclusive ? stmt.branches.findIndex((b) => b.otherwise) : -1; @@ -1143,12 +1074,10 @@ function lowerParallel( } /** - * Lower `await { { A } ... }` to an event-based gateway, one catch - * event per branch, and an exclusive join: the first branch to fire cancels - * the rest, so the merge is a plain XOR join. No flow out of the gateway - * carries a condition, since Operaton builds no transition for one and routes - * through the event scope instead; a branch's settings land on its catch - * event, where the engine's wait state is. + * The first branch to fire cancels the rest, so the merge is a plain XOR join. + * Flows out of an event-based gateway carry no condition (Operaton routes + * through the event scope); a branch's settings land on its catch event, where + * the wait state is. */ function lowerRace(builder: Builder, stmt: RaceStatement, x: string): Frontier { const raceId = makeGatewayRaceId(x); @@ -1185,11 +1114,6 @@ function lowerRace(builder: Builder, stmt: RaceStatement, x: string): Frontier { return { entry: raceId, exit: pruneUnreachableJoin(builder, joinId) }; } -/** - * A nested flow container, one opaque node in the parent's flow. An `attempt` - * differs only by tag, the one the engine needs before it accepts a cancel end - * inside the block. - */ function lowerSubProcess( builder: Builder, stmt: AstSubProcess, @@ -1213,12 +1137,8 @@ function lowerSubProcess( } /** - * Lower a host-less `on` handler into a `triggeredByEvent` sub-process, the - * caught trigger on its start event. Nothing wires it into the parent's flow, - * so this returns nothing. The handler's settings land on the sub-process, - * except the three a timer job takes off the start event that declares it - * ({@link splitTimerJobSettings}); none of their readers looks at the - * enclosing sub-process. + * Settings land on the sub-process, except the timer-job ones, which the + * engine reads only off the start event that declares the timer. */ function lowerOnHandler( builder: Builder, @@ -1256,16 +1176,11 @@ function lowerOnHandler( } /** - * Lower a hosted `on : ` handler into a boundary event inline - * in the host's container, with no wrapping container: the body's statements - * are siblings of the main flow, so a `goto` crosses between the two in either - * direction, the only way an escape chain can rejoin. The chain runs boundary - * -> body -> its own end, seeded from the boundary id so the main flow's end - * keeps its number whatever handlers the container has. - * - * `bpmn-auto-layout` positions an attached event from `attachedTo.di.bounds`, - * so the host shape has to exist before the attacher is laid out; a handler - * follows its host in the statement list, so the host precedes it here too. + * The body lowers inline as siblings of the main flow, so a `goto` can cross + * between the two, the only way an escape chain can rejoin. Its end id is + * seeded from the boundary id so the main flow's end keeps its number. + * The host must precede the boundary event: `bpmn-auto-layout` positions it + * from the host's bounds. */ function lowerBoundaryHandler( builder: Builder, @@ -1303,10 +1218,8 @@ function lowerBoundaryHandler( } /** - * The handler body's start event. `lowerContainerBody` leaves one behind - * unless `pruneUnreachableJoin` spliced an authored start that spells a later - * join's id, a name the validator refuses; minting one keeps the desugarer - * total over that program. + * Missing only when `pruneUnreachableJoin` spliced out an authored start named + * like a later join (a name the validator refuses). */ function ensureHandlerStart(nested: Builder, id: string): IrStartEvent { const existing = nested.flowElements.find( @@ -1324,10 +1237,8 @@ function ensureHandlerStart(nested: Builder, id: string): IrStartEvent { } /** - * The caught {@link EventDefinition} of an `on` handler. Fields with nowhere - * to go (a code on `compensation` or `cancel`, bindings on `message`/`signal`) - * are dropped, a missing code is catch-all, and a word the position does not - * admit falls back to error, the kind its validator message speaks of. + * An unadmitted word falls back to error, the kind its validator message speaks + * of. */ function handlerEventDefinition(stmt: OnHandler): EventDefinition { const trigger = admittedTrigger(ON_TRIGGERS, stmt.trigger); @@ -1371,7 +1282,6 @@ function handlerEventDefinition(stmt: OnHandler): EventDefinition { } } -/** `TIMER_PARTICLE_BY_KIND` read backwards; a timer with no readable time lands on `duration`. */ function timerParticleKind(particle: string | undefined): TimerKind { for (const [kind, word] of Object.entries(TIMER_PARTICLE_BY_KIND)) { if (word === particle) { @@ -1403,10 +1313,6 @@ function lowerThrow( return { entry: id, exit: null }; } -/** - * The implementation that makes the engine send the message; the validator - * holds the binding keys to the `message` trigger. - */ function thrownMessageBinding( def: EventDefinition, attrs: KeyValueAttr[], @@ -1419,8 +1325,8 @@ function thrownMessageBinding( } /** - * A link ends the chain like a `goto`: `BpmnParse.parseSequenceFlow` refuses - * a flow out of a link throw. + * A link ends the chain: `BpmnParse.parseSequenceFlow` refuses a flow out of a + * link throw. */ function lowerEmit( builder: Builder, @@ -1443,9 +1349,8 @@ function lowerEmit( } /** - * BPMN has no intermediate error throw, so a word the emit position does not - * admit lowers as an escalation and the validator points the author at - * `throw error`. + * An unadmitted word lowers as an escalation, since BPMN has no intermediate + * error throw. */ function emitEventDefinition(stmt: EmitStatement): EmitEventDefinition { const trigger = admittedTrigger(EMIT_TRIGGERS, stmt.trigger); @@ -1487,7 +1392,6 @@ function lowerIntermediateCatch( return { entry: id, exit: id }; } -/** A word the throw position does not admit maps to `error`. */ function throwEventDefinition(stmt: ThrowStatement): EndEventDefinition { const trigger = admittedTrigger(THROW_TRIGGERS, stmt.trigger); const code = raisedCodeOf(stmt.items); @@ -1512,14 +1416,8 @@ function throwEventDefinition(stmt: ThrowStatement): EndEventDefinition { } } -/** The condition a catch with no readable one waits on. */ const ALWAYS_TRUE = '${true}'; -/** - * Error, escalation and compensation are raised with `throw`/`emit` and never - * awaited inline; a word the await position does not admit falls back to the - * always-true conditional. - */ function catchEventDefinition( stmt: IntermediateCatchEvent | RaceBranch, ): CatchEventDefinition { @@ -1530,10 +1428,8 @@ function catchEventDefinition( } /** - * The code a payload raises. A bare word names a declaration, which keys by - * its own `code` setting, so `error OrderFailed(code: "order.failed")` reaches - * the engine as `order.failed` however its use sites spell it; anywhere but a - * code position a bare word is the text written. + * A bare word naming a declaration resolves to that declaration's `code` + * setting. */ function raisedCodeOf(items: ParenItem[]): string | undefined { const payload = payloadItemOf(items)?.value; @@ -1544,7 +1440,6 @@ function raisedCodeOf(items: ParenItem[]): string | undefined { : payloadTextOf(items); } -/** The trigger words that mean the same thing in every position that takes them. */ type NamedTrigger = (typeof CATCH_TRIGGERS)[number]; function namedTriggerDefinition( @@ -1607,7 +1502,7 @@ function lowerCallActivity(builder: Builder, stmt: AstCallActivity): Frontier { return { entry: stmt.name, exit: stmt.name }; } -/** Class first, the order Operaton resolves the two attributes in. */ +/** Class first, the order Operaton resolves them in. */ function callVariableMapper( attrs: KeyValueAttr[], ): CallVariableMapper | undefined { @@ -1625,11 +1520,7 @@ function callVariableMapper( return undefined; } -/** - * The `binding`/`version` pin a call activity and a decision step share. - * `version` wins even beside a stray `binding` (the pair is a validator - * error), and a `binding` resolves only for a bare `latest` or `deployment`. - */ +/** `version` wins over a stray `binding` (the pair is a validator error). */ function versionBinding(attrs: KeyValueAttr[]): VersionBinding | undefined { const versionAttr = attrs.find((a) => a.key === 'version'); if (versionAttr !== undefined) { @@ -1648,7 +1539,6 @@ function versionBinding(attrs: KeyValueAttr[]): VersionBinding | undefined { return undefined; } -/** An int or decimal literal stays bare; anything else reads as {@link elText} does. */ function numericOrElValue(expr: Expr): string { const integer = integerLiteralText(expr); if (integer !== undefined) { @@ -1681,11 +1571,6 @@ function lowerCallMappings(mappings: VariableMapping[]): { return { inMappings, outMappings }; } -/** - * `all` (`*`) copies everything; a bare `target` is the same-name shorthand; a - * single-segment `VarRef` source copies that variable by name; anything else - * becomes its {@link elText}. - */ function lowerCallMapping(mapping: VariableMapping): CallVariableMapping { const local = mapping.local ? ({ local: true } as const) : {}; if (mapping.all) { @@ -1711,17 +1596,11 @@ function lowerCallMapping(mapping: VariableMapping): CallVariableMapping { }; } -/** - * A `goto` produces no node: its `entry` is the target's id, so the chain's - * implicit flow lands there, and its `exit` is `null`. `$refText` is the id - * verbatim and is there even when the linker could not resolve it, which keeps - * the desugarer total over unresolved gotos. - */ +/** No node: `entry` is the target. `$refText` survives an unresolved link. */ function lowerGoto(stmt: GotoStatement): Frontier { return { entry: stmt.target.$refText, exit: null }; } -/** Structural, as {@link EngineAttributeOwner} is. */ interface RepeatOwner { cardinality?: Expr; collection?: Expr; @@ -1732,12 +1611,9 @@ interface RepeatOwner { } /** - * A statement carrying no repeat clause spreads nothing, not even a `run*` - * setting written beside it (the validator reports that). A clause always sets - * a count, a collection or both, which is what tells it from an absent one: - * `sequential` is a plain boolean the parser leaves `false` either way. - * Nothing in the engine reads a priority off the loop, so `runJobPriority` is - * dropped. + * A clause is present iff it sets a count or collection (`sequential` is + * `false` either way); without one even stray `run*` settings are dropped. + * The engine reads no priority off the loop, so `runJobPriority` is dropped. */ function readLoop(stmt: RepeatOwner): Repeatable { if (stmt.cardinality === undefined && stmt.collection === undefined) { @@ -1759,7 +1635,6 @@ function readLoop(stmt: RepeatOwner): Repeatable { ...(stmt.completion !== undefined ? { completionCondition: renderExpression(stmt.completion) } : {}), - // Parallel is the engine default, so only the marked form is stored. ...(stmt.sequential ? { sequential: true as const } : {}), ...runSettings, }, @@ -1767,10 +1642,8 @@ function readLoop(stmt: RepeatOwner): Repeatable { } /** - * Only a plain integer goes in bare: - * `MultiInstanceActivityBehavior.resolveLoopCardinality` `Integer.parseInt`s a - * body with no `${`/`#{` on every run. Anything else is wrapped, so the engine - * evaluates it as EL and truncates with `intValue()`. + * The engine `Integer.parseInt`s a body without `${`, so only a plain integer + * goes in bare. */ function loopCardinality(expr: Expr): string { if (isLiteralInt(expr)) { @@ -1780,10 +1653,8 @@ function loopCardinality(expr: Expr): string { } /** - * Operaton reads `operaton:collection` as a variable name unless the text - * carries `${`, so only a bare identifier and a quoted string emit a name; an - * accessor such as `order.lines` names no variable and has to become an - * expression or the process cannot run. + * Operaton reads `operaton:collection` as a variable name unless it carries + * `${`, so an accessor such as `order.lines` must become an expression. */ function loopCollection(expr: Expr): string { if (isVarRef(expr) && expr.accessors.length === 0) { @@ -1795,7 +1666,6 @@ function loopCollection(expr: Expr): string { return renderExpression(expr); } -/** Structural rather than a union of statement types, so every carrier reads the same. */ interface EngineAttributeOwner { items: ParenItem[]; listeners: AstListener[]; @@ -1809,7 +1679,10 @@ function readEngineAttributes(owner: EngineAttributeOwner): EngineAttributes { }; } -/** `keyOf` respells the keys for a second carrier sharing the parens; {@link jobSettings} decides what is kept. */ +/** + * `keyOf` respells the keys for a second carrier sharing the parens (join, loop + * run). + */ function readJobSettings( attrs: Setting[], keyOf: (key: string) => string = (key) => key, @@ -1824,9 +1697,8 @@ function readJobSettings( } /** - * The event word, not the element, splits execution from task listeners, so a - * task event on an element with no such lifecycle is dropped here for the - * validator. + * A task event on an element without a task lifecycle is dropped; the validator + * reports it. */ function readExecutionListeners( listeners: AstListener[], @@ -1840,7 +1712,6 @@ function readExecutionListeners( return lowered.length > 0 ? lowered : undefined; } -/** `timeout` has no lifecycle transition of its own, so it carries the timer that says when it runs. */ function readTaskListeners( listeners: AstListener[], ): TaskListener[] | undefined { @@ -1862,7 +1733,6 @@ function readTaskListeners( return lowered.length > 0 ? lowered : undefined; } -/** The event word is a soft identifier, so membership picks the list; a word in neither is dropped. */ function listenersFor( listeners: AstListener[], events: readonly E[], @@ -1872,7 +1742,6 @@ function listenersFor( ); } -/** A fenced body replaces the brace block entirely, so it is checked first. */ function listenerBinding(listener: AstListener): ListenerBinding { if (listener.script !== undefined) { const { tag, code } = splitFencedScript(listener.script); @@ -1884,10 +1753,6 @@ function listenerBinding(listener: AstListener): ListenerBinding { ); } -/** - * Carry the block's fields onto a binding the engine hands a field list; the - * validator reports a write onto one it does not. - */ function withDeclaredFields< B extends CodeBinding | Extract, >(binding: B, params: AstIoParameter[]): B { @@ -1897,8 +1762,8 @@ function withDeclaredFields< } /** - * A list, a map, and an inline script have no `operaton:field` slot, so a - * value in one of those forms is left out for the validator to report. + * A list, map, or script has no `operaton:field` form; the validator reports + * it. */ function readFieldInjections(params: AstIoParameter[]): FieldInjection[] { return params @@ -1925,7 +1790,6 @@ function readExtensionProperties( return properties.length === 0 ? undefined : properties; } -/** What `parseExternalServiceTask` reads beside `topic`; no other binding reaches that reader. */ function withExternalExtras( binding: Extract, stmt: AstServiceTask | AstSendTask | AstBusinessRuleTask, @@ -1944,11 +1808,6 @@ function withExternalExtras( }; } -/** - * The code is read through the declaration's `code` setting, as - * {@link raisedCodeOf} does; an unresolved reference falls back to the text - * written. - */ function lowerErrorMappings( mappings: AstErrorMapping[], ): IrErrorMapping[] | undefined { @@ -2016,9 +1875,8 @@ function boolAttrValue( } /** - * Claim a gateway's default-flow id before any branch is lowered: a statement - * named `default` would otherwise take the same `Flow__default` for - * its own incoming flow, and BPMN forbids two flows under one id. + * Claimed before branches lower, or a statement named `default` could take the + * same flow id. */ function reserveDefaultFlowId(builder: Builder, gatewayId: string): string { const id = resolveCollision(makeDefaultFlowId(gatewayId), builder.taken); @@ -2026,10 +1884,6 @@ function reserveDefaultFlowId(builder: Builder, gatewayId: string): string { return id; } -/** - * `forcedId` is a reserved default flow's id, claimed by - * {@link reserveDefaultFlowId} before anything else could take it. - */ function addFlow( builder: Builder, sourceRef: string, @@ -2058,11 +1912,7 @@ function joinContinuation( } } -/** - * Drop the join when nothing flows into it, i.e. every branch terminated; a - * join with no incoming flow is invalid BPMN. The join itself is still in - * place, pushed before the branches. - */ +/** A join with no incoming flow (every branch terminated) is invalid BPMN. */ function pruneUnreachableJoin(builder: Builder, joinId: string): string | null { if (builder.sequenceFlows.some((flow) => flow.targetRef === joinId)) { return joinId; @@ -2074,11 +1924,6 @@ function pruneUnreachableJoin(builder: Builder, joinId: string): string | null { return null; } -/** - * An on-handler's id is positional and never registered, which - * {@link isNamedStatement} encodes by leaving `OnHandler` out; `streamAst` - * still walks its body for the names inside it. - */ function collectNamedIds(process: Process): Set { return new Set( AstUtils.streamAst(process) @@ -2087,10 +1932,8 @@ function collectNamedIds(process: Process): Set { ); } -/** The `key`/`value` shape every setting carries. */ type KeyValueAttr = { key: string; value: Expr }; -/** The IR calls the `label` setting `name`, since BPMN's `name` is the human-facing text. */ function namedAttrs(stmt: { items: ParenItem[] }): Named { const attrs = settingsOf(stmt.items); const name = attrValue(attrs, 'label'); @@ -2101,18 +1944,15 @@ function namedAttrs(stmt: { items: ParenItem[] }): Named { }; } -/** The first `key` setting's value as {@link exprText} reads it. */ function attrValue(attrs: KeyValueAttr[], key: string): string | undefined { const attr = attrs.find((a) => a.key === key); return attr === undefined ? undefined : exprText(attr.value); } /** - * Text the engine takes as written (`label`, `documentation`, `class`): a - * string literal bare, a bareword or dotted path verbatim, anything else its - * `${...}` body. A key the engine evaluates as EL (`expression`, `delegate`, - * `businessKey`, an io value) reads through {@link elText} instead, which keeps - * a bareword wrapped so it is a variable lookup rather than a fixed string. + * For text the engine takes as written (`label`, `class`): a bareword or dotted + * path stays verbatim. Keys the engine evaluates as EL use {@link elText}, + * which keeps a bareword wrapped so it stays a variable lookup. */ function exprText(value: Expr): string { if (isLiteralString(value)) { @@ -2129,7 +1969,6 @@ function exprText(value: Expr): string { return rendered; } -/** Text the engine evaluates as EL; {@link exprText} explains the split. */ function elText(value: Expr): string { if (isLiteralString(value)) { return value.value; @@ -2137,7 +1976,6 @@ function elText(value: Expr): string { return renderExpression(value); } -/** The first `key` setting's value as {@link elText} reads it. */ function elAttrValue(attrs: KeyValueAttr[], key: string): string | undefined { const attr = attrs.find((a) => a.key === key); return attr === undefined ? undefined : elText(attr.value); diff --git a/packages/transform/src/bpmn-moddle.d.ts b/packages/transform/src/bpmn-moddle.d.ts index a2184a1..03c1e2e 100644 --- a/packages/transform/src/bpmn-moddle.d.ts +++ b/packages/transform/src/bpmn-moddle.d.ts @@ -1,12 +1,5 @@ -/** - * `bpmn-moddle`, `bpmn-auto-layout` and `saxen` ship no `.d.ts` files of their - * own. - */ - declare module 'bpmn-moddle' { - // Moddle attaches properties dynamically, keyed by the `name` in each - // `*-moddle.json` schema entry, so `any` is the honest type here. The IR in - // `./ir/types.ts` is the only typed contract in this package. + // Moddle attaches properties dynamically from its JSON schemas. export type ModdleElement = any; export interface BpmnModdleInstance { @@ -38,12 +31,11 @@ declare module 'bpmn-moddle' { } declare module 'bpmn-auto-layout' { - /** Returns the XML with `bpmndi:` diagram-interchange elements injected. */ export function layoutProcess(xml: string): Promise; } declare module 'saxen' { - /** The source position the parser has reached, both counted from zero. */ + /** Both counted from zero. */ export interface ParseContext { line: number; column: number; @@ -64,7 +56,7 @@ declare module 'saxen' { getContext: () => ParseContext, ) => void; - /** Raw text between tags, entities still encoded; `decodeEntities` resolves them. */ + /** Entities still encoded; `decodeEntities` resolves them. */ export type TextHandler = ( value: string, decodeEntities: (text: string) => string, diff --git a/packages/transform/src/cfg-analysis.ts b/packages/transform/src/cfg-analysis.ts index ad6f0e7..cd6d8ae 100644 --- a/packages/transform/src/cfg-analysis.ts +++ b/packages/transform/src/cfg-analysis.ts @@ -1,36 +1,21 @@ /** - * Dominators, post-dominators, and back-edges over a {@link FlowContainer}'s - * `flowElements` and `sequenceFlows`. Pure graph machinery with no DSL - * knowledge, so it runs the same on a whole process or one sub-process body; - * `irToDsl`'s pattern catalog consumes it to recognize structured regions. - * - * ADR 0009, Use Dominator/Post-Dominator Analysis for IR-to-DSL Restructuring, - * names the query set. + * Dominators, post-dominators and back-edges over one {@link FlowContainer}, + * with no DSL knowledge (ADR 0009). */ - import { isGateway } from './ir/types.js'; import type { FlowContainer, SequenceFlow } from './ir/types.js'; -/** Synthetic single source: dominator analysis needs one root. */ export const VIRTUAL_ENTRY = '__cfg_entry__'; -/** Synthetic single sink: post-dominator analysis needs one. */ export const VIRTUAL_EXIT = '__cfg_exit__'; -/** - * Every method is total: a defined answer for any string, including unknown - * ids, unreachable nodes, and the two sentinels. `dominates` and - * `postDominates` are reflexive. - */ +/** Total over any string, including unknown ids and the sentinels. */ export interface CfgAnalysis { - /** `undefined` at the virtual entry, and for an unreachable or unknown node. */ immediateDominator(node: string): string | undefined; /** - * `undefined` at the virtual exit, and for a node in a fragment the - * virtual entry never reaches: `loopEntries` walks only what - * `VIRTUAL_ENTRY` reaches, so no latch is recorded for such a fragment and - * `withLoopExits` has no head there to wire to the exit. + * `undefined` also for a fragment `VIRTUAL_ENTRY` never reaches: no latch is + * recorded there, so no head is wired to the exit. */ immediatePostDominator(node: string): string | undefined; @@ -39,42 +24,30 @@ export interface CfgAnalysis { postDominates(a: string, b: string): boolean; /** - * Dominance read from the process starts alone, the edges a handler or link - * catch re-enters pruned first (see `withoutReentries`). A re-entry into a - * loop body must not unmake the loop's own nesting, which plain `dominates` - * would: it counts the virtual re-entry root as a second path in, so a - * nested loop's test gateway stops dominating the outer one's. + * Dominance from the starts alone (see `withoutReentries`): a handler or link + * catch re-entering a loop body must not unnest the loop. */ loopDominates(a: string, b: string): boolean; - /** The immediate dominator under {@link loopDominates}. */ loopImmediateDominator(node: string): string | undefined; - /** Original {@link SequenceFlow} objects, in input order. */ backEdges(): SequenceFlow[]; /** - * The heads of the loops holding `to` past their head, for a `from` outside - * them: getting from `from` to `to` takes a jump into the loop body, as a - * `goto` does. Outermost first, and empty for any other pair. + * Heads of the loops `to` sits past, when `from` is outside them (reaching + * `to` needs a `goto`). Outermost first. */ headsEnteredPast(from: string, to: string): string[]; /** - * Whether a back edge into `node` (see {@link backEdges}) only closes a - * cycle of jumps: an enclosing loop's own exit route, not a nested loop's - * test. A caller reading such a back edge as a `while`'s or `do`'s own test - * would print a loop where the source has a `goto`. + * Whether the back edges into `node` only close a cycle of jumps, so they + * must print as `goto`, not a loop test. */ isJumpCycleHead(node: string): boolean; /** - * The gateway whose own test closes the cycle at `node` (see - * {@link backEdges}): `node` itself for a `while`, or the distinct gateway - * a `do`'s test returns from. `undefined` where `node` heads no tested - * cycle. A second back edge into the same head, a `goto` closing there - * too, is never this gateway, however conditioned it prints, and however - * many of `node`'s back edges a caller has already consumed. + * The gateway whose test closes the loop at `node`: `node` for a `while`, the + * returning gateway for a `do`. Never a second back edge (a `goto`) into it. */ ownLoopTest(node: string): string | undefined; @@ -88,10 +61,8 @@ export function analyzeCfg(container: FlowContainer): CfgAnalysis { const idom = computeIdom(graph.succ, graph.pred, VIRTUAL_ENTRY); - // A back-edge closes a cycle at its head (see `loopEntries`), read from the - // starts alone (see `withoutReentries`). Where the loop is reducible that is - // exactly u -> v with v dominating u. Filtering the raw flow list keeps - // every parallel edge and excludes the sentinel edges. + // Back edges come from the starts-only graph (see `withoutReentries`). + // Filtering the raw flow list keeps parallel edges and drops sentinel edges. const forward = withoutReentries(graph); const loopIdom = computeIdom(forward.succ, forward.pred, VIRTUAL_ENTRY); const loopDominates = makeDominanceQuery(loopIdom, VIRTUAL_ENTRY); @@ -103,11 +74,8 @@ export function analyzeCfg(container: FlowContainer): CfgAnalysis { latches.get(f.targetRef)?.has(f.sourceRef), ); - // Post-dominators are the dominators of the reversed graph, rooted at a - // virtual exit (see {@link withLoopExits}). Every latch head is wired - // there, whether or not its own test survives `loopTestRank`'s - // nested-test filtering: a jump routed back through another test on the - // cycle can leave a real loop's test unranked. + // Every latch head is wired to the exit, even one whose test `loopTestRank` + // left unranked (a jump routed through another test on the cycle does that). const exitGraph = withLoopExits(graph, [...latches.keys()]); const ipdom = computeIdom(exitGraph.pred, exitGraph.succ, VIRTUAL_EXIT); @@ -151,10 +119,8 @@ export function analyzeCfg(container: FlowContainer): CfgAnalysis { } interface Graph { - /** Insertion-ordered and de-duplicated; dominance is set-based. */ succ: Map; pred: Map; - /** The boundary events and link catches wired to the virtual entry. */ reentries: Set; } @@ -187,13 +153,9 @@ function buildGraph(container: FlowContainer): Graph { addEdge(f.sourceRef, f.targetRef); } - // A boundary event and a link catch are each an entry beside the start: a - // boundary's token appears when its trigger fires, a link catch's when the - // engine reroutes a throw of its name, never along a drawn flow. A caller - // that draws the throw -> catch hop as a flow has placed the catch, so it - // is no entry then. Any other node without a predecessor stays unwired: it - // really is unreachable. A link throw has no successor and drains to - // VIRTUAL_EXIT below. + // Boundary events and unfed link catches are entries beside the start: their + // token comes from a trigger or a link throw, never a drawn flow. Other nodes + // without a predecessor stay unwired, being unreachable. const hasAnyStart = container.flowElements.some( (e) => e.kind === 'startEvent', ); @@ -216,8 +178,7 @@ function buildGraph(container: FlowContainer): Graph { } } - // Every node with no real successor drains to the exit too, otherwise it - // is a second sink the post-dominator analysis cannot see. + // Every sink drains to the one exit post-dominance needs. for (const el of container.flowElements) { const hasRealSucc = succ.get(el.id)!.length > 0; if (el.kind === 'endEvent' || !hasRealSucc) addEdge(el.id, VIRTUAL_EXIT); @@ -227,24 +188,16 @@ function buildGraph(container: FlowContainer): Graph { } /** - * `graph` without the edges by which a re-entry's chain joins the flow the - * starts reach, for finding loops: a handler or link catch jumping into a loop - * body prints as a `goto` and must not unmake the loop. A node only a re-entry - * reaches keeps its dominators from that re-entry. The other queries keep the - * re-entries' edges, since a split whose merge a handler also enters has no - * block to print. + * `graph` without the edges joining a re-entry's chain to what the starts + * reach, for loop finding only: other queries need them, since a split whose + * merge a handler also enters has no printable block. */ function withoutReentries(graph: Graph): Graph { - const reached = new Set([VIRTUAL_ENTRY]); - const pending = graph.succ - .get(VIRTUAL_ENTRY)! - .filter((n) => !graph.reentries.has(n)); - while (pending.length > 0) { - const n = pending.pop()!; - if (reached.has(n)) continue; - reached.add(n); - pending.push(...graph.succ.get(n)!); - } + const reached = closure([VIRTUAL_ENTRY], (n) => + n === VIRTUAL_ENTRY + ? graph.succ.get(n)!.filter((m) => !graph.reentries.has(m)) + : graph.succ.get(n)!, + ); const joinsStartFlow = (from: string, to: string) => !reached.has(from) && reached.has(to) && to !== VIRTUAL_EXIT; const prune = (edges: Map, forward: boolean) => @@ -264,16 +217,9 @@ function withoutReentries(graph: Graph): Graph { } /** - * `graph` with an edge to `VIRTUAL_EXIT` from every cycle's head that has no - * route there already: the head is where the loop leaves, a `while`'s - * gateway or a `do`'s first step (or, a cycle of jumps alone, the statement - * a flow into it lands on), so wiring it stands in for the route the loop - * would take out were one printable. That only equals the head's actual - * exit route when it leaves straight from there; the actual exit route can - * instead loop back through another node into the cycle first. `heads` - * lists outer cycles before the ones nested in them (see - * `loopEntries`), so a head already reached through an enclosing one's new - * edge, the two sharing one cycle a jump joins, needs none of its own. + * Wires each cycle head with no route to the exit to it, standing in for the + * loop's exit route. `heads` is outermost first, so a head reached through an + * enclosing head's new edge needs none of its own. */ function withLoopExits(graph: Graph, heads: readonly string[]): Graph { const succ = new Map([...graph.succ].map(([n, ns]) => [n, [...ns]])); @@ -286,27 +232,34 @@ function withLoopExits(graph: Graph, heads: readonly string[]): Graph { return { succ, pred, reentries: graph.reentries }; } -/** Every node with a route to `VIRTUAL_EXIT`, walked backward from it. */ function reachesExit(pred: Map): Set { - const seen = new Set([VIRTUAL_EXIT]); - const stack = [VIRTUAL_EXIT]; - while (stack.length > 0) { - const n = stack.pop()!; - for (const p of pred.get(n) ?? []) { - if (!seen.has(p)) { - seen.add(p); - stack.push(p); - } - } + return closure([VIRTUAL_EXIT], (n) => pred.get(n) ?? []); +} + +/** + * Breadth-first reach from `seeds`. Reaching `stop` ends the walk early, so + * the result is complete only without it. + */ +export function closure( + seeds: readonly string[], + next: (n: string) => readonly string[], + stop?: string, +): Set { + const seen = new Set(); + const queue = [...seeds]; + for (let i = 0; i < queue.length; i++) { + const n = queue[i]!; + if (seen.has(n)) continue; + seen.add(n); + if (n === stop) break; + queue.push(...next(n)); } return seen; } /** * Cooper/Harvey/Kennedy iterative dominators, which tolerate the irreducible - * graphs `goto` produces. Swap `succ`/`pred` and root at the exit for - * post-dominators. Unreachable nodes are absent from the result, which - * callers read as "no immediate dominator". + * graphs `goto` produces. Unreachable nodes are absent from the result. */ function computeIdom( succ: Map, @@ -317,8 +270,7 @@ function computeIdom( const order = new Map(); rpo.forEach((id, i) => order.set(id, i)); - // `undefined` means "not yet computed". Only the root is seeded, as its own - // dominator for the duration; the final value is set below. + // The root is its own dominator only while iterating. const idom = new Map(); idom.set(root, root); @@ -372,7 +324,6 @@ function intersect( return finger1; } -/** Iterative DFS, so a deep graph cannot overflow the stack. */ function reversePostorder(root: string, succ: Map): string[] { const postorder: string[] = []; const visited = new Set(); @@ -403,30 +354,16 @@ function reversePostorder(root: string, succ: Map): string[] { interface EnteredLoop { head: string; cycle: Set; - /** - * The nodes of `cycle` other than its head and the gateways it is entered - * at; reaching one from outside without passing the head takes a jump. - */ + /** Cycle nodes other than the head and the gateways it is entered at. */ past: Set; } /** - * The cycles the starts reach: `latches`, keyed by head, are the flows closing - * a cycle there, the back-edges, and `entered` holds each tested cycle with - * the nodes past its head. A cycle is a strongly connected set of nodes; its - * loops nested inside are the cycles left once its head is taken out, so an - * outer cycle comes first. - * - * A reducible loop is entered only at its head. A `goto` into a loop body - * adds a second entry, and neither entry dominates the other, so the head is - * the entry the cycle's own test touches (see {@link loopTestRank}): its - * gateway, a `while`, ahead of the node it returns to, a `do`. Model order, - * arbitrary in a model another tool wrote, only breaks a tie. A cycle whose - * own test touches none of its entries is made of jumps alone, so nothing in - * it is past a head; nor is a gateway the cycle is entered at, since a jump - * lands on a statement and a flow into a merge is the block around the cycle. - * Once the compiler has read a print back, the printed loop keeps its head - * ahead of its body, so every later print closes the same cycle. + * Finds cycles outermost first: each strongly connected set's head is removed + * and the rest searched again for nested cycles. + * A `goto` into a loop body makes a second entry, so the head is the entry the + * cycle's own test touches (see {@link loopTestRank}), model order only breaking + * ties. A cycle whose own test touches no entry is made of jumps alone. */ function loopEntries( graph: Graph, @@ -447,7 +384,6 @@ function loopEntries( const latches = new Map>(); const entered: EnteredLoop[] = []; const jumpCycleHeads = new Set(); - // Backs `ownLoopTest` (see the interface doc), filled as each head is found. const testGateways = new Map(); const pending = [new Set(reversePostorder(VIRTUAL_ENTRY, graph.succ))]; while (pending.length > 0) { @@ -456,10 +392,8 @@ function loopEntries( const inside = new Set(scc); const outside = (n: string) => graph.pred.get(n)!.filter((p) => !inside.has(p)); - // An unconditioned flow in from outside `inside`: the fall-in a loop - // after a statement has. Only a hint of the cycle's real way in, not - // proof: a loop opening an `if` branch is entered by that branch's - // conditioned route, and `user Z; goto B` enters B unconditioned. + // An unconditioned flow in from outside: a hint of the cycle's way in, not + // proof (`user Z; goto B` enters B unconditioned too). const hasNaturalEntry = (n: string) => outside(n).some((p) => container.sequenceFlows.some( @@ -469,13 +403,9 @@ function loopEntries( f.conditionExpression === undefined, ), ); - // A node already latched to an enclosing head stays in `inside` here - // (only the head is removed below), so its own exit route can pull an - // otherwise acyclic tail back into a pseudo-cycle, the way a `goto` - // after the enclosing loop does. Only a reused node with no test of - // its own inside this cycle counts as such a waypoint: a while nested - // in a while reuses the same node as both the inner test's gateway and - // the outer's back-edge source, which does have its own test. + // A node latched to an enclosing head stays in `inside`, so its exit route + // can form a pseudo-cycle of jumps. Such a waypoint has no test of its own + // here; a while nested in a while reuses its node but does have one. const testSources = new Set( conditioned .filter((f) => inside.has(f.sourceRef) && inside.has(f.targetRef)) @@ -511,37 +441,17 @@ function loopEntries( if (jumpsOnly) { jumpCycleHeads.add(head); } else { - // Whether `head` is genuinely tested does not read `rank`: a guard - // several passes out, its own body swept broad or thin by the same - // subtraction `loopTestRank` already works around, can still outrank - // the real test there the way it outranks one closer by. A `while` - // head is tested by its own outgoing conditioned flow; a `do` head's - // conditioned back edges are its own test candidates, minus a - // multi-way branch (an `else if` chain, several conditioned routes - // off the one gateway): a loop's own test always has exactly one, so - // more marks a `goto` sharing the head, however conditioned it - // prints. Several genuine candidates left (loops nested at one body - // entry, as a `while` immediately inside another `while`'s own back - // edge) stay untold apart here: `tryDoWhileEntry`'s own dominance - // reads the outermost first and the body walk re-enters `node` for - // the next one in, so leaving more than one candidate defers to it. - // Whether a candidate is multi-way is asked of the whole container, - // not `inside`: an earlier pass has already taken its other target - // out of `inside` once that target's own head is found, which would - // otherwise make a later pass mistake the same branch gateway for a - // plain binary test. + // A `do` test is a conditioned back edge from a gateway with exactly one + // conditioned flow in the whole container (more is a multi-way branch + // sharing the head). `rank` is not read: an outer guard can outrank it. const ownTests = [...backSources].filter( (s) => conditioned.some( (f) => f.sourceRef === s && f.targetRef === head, ) && conditioned.filter((f) => f.sourceRef === s).length === 1, ); - // An unconditioned back edge from the body's tail, paired with the - // head's own conditioned exit, makes it a `while` head; only - // conditioned back edges make it a `do` head instead, whatever else - // the gateway also tests on its way out. Neither makes it no loop - // head at all here, only a cycle of jumps a later pass may still - // recognize by its statement. + // A `while` head has an unconditioned back edge and one conditioned exit; + // otherwise it may still be a jump cycle recognized later. const isWhileHead = gateways.has(head) && conditioned.filter((f) => f.sourceRef === head).length === 1 && @@ -558,11 +468,8 @@ function loopEntries( : ownTests.length === 1 ? ownTests[0] : undefined; - // Whether this cycle is tested at all still reads `rank`, not - // `owner`: several genuine candidates (see `ownTests` above) still - // close a real loop at `head`, just not one `ownLoopTest` can name - // yet, and gating `entered` on `owner` too would drop the cycle from - // `headsEnteredPast` the moment a second candidate appears. + // Gated on `rank`, not `owner`: several `do` candidates still close a real + // loop, which `headsEnteredPast` must keep. if (rank(head) < UNTESTED) { const past = new Set(scc); past.delete(head); @@ -583,35 +490,23 @@ function loopEntries( const UNTESTED = 2; /** - * Ranks a node by the cycle's own tests: 0 for a test's gateway (a `while` - * head), 1 for the node a test returns to (a `do` head), `UNTESTED` otherwise. - * A test is a conditioned flow `g -> t` inside the cycle, and its body is what - * `t` reaches without passing `g`, less what `g`'s other routes reach too: an - * `if` in a loop body closes at its merge, so its body is its branch alone. - * A test is natural when its gateway or target has an unconditioned entry - * from beyond the cycle (see `hasNaturalEntry`). - * A test is the cycle's own unless its gateway sits in another test's body - * that it does not hold in turn, which is where a nested loop's test sits, or - * its loop closes without passing the other test's gateway while the other's - * does not close without passing its: a jump from after the outer loop back - * into the inner body puts that body on the outer test's exit route, so the - * subtraction takes it out of the outer test's body, which the first rule - * alone misses. The second never disqualifies a natural test, so a - * conditional nested inside it must not outrank the loop's own test. - * The first rule turns round, and the outer test loses, where only the - * inner one is natural and the outer returns into the inner's loop: a - * conditioned `goto` after the loop back into its body spans the whole - * cycle, so read as a test it would enclose the loop's own. A real outer - * loop's test returns ahead of the inner loop, which keeps an - * unconditioned `goto` into a loop opening an `if` branch from turning it - * round. - * A test into a node entered from outside only by a jump is no test of this - * cycle when its gateway's other routes come back to it past that node, - * through an entry the flow before the cycle falls into: the cycle closes - * there without the test, which is an `if` jumping into a loop of plain - * flow, recognized again in the nested pass once the join is removed. Its - * target ranks below every other entry, so model order cannot make it the - * head. + * Ranks a node by the cycle's own tests: 0 for a test's gateway (`while` + * head), 1 for its target (`do` head), `UNTESTED` otherwise. + * A test is a conditioned flow `g -> t` inside the cycle; its body is what `t` + * reaches without passing `g`, less what `g`'s other routes reach. + * A test is natural when `g` or `t` has an unconditioned entry from outside. + * A test is not the cycle's own when: + * - its gateway sits in another test's body that it does not hold in turn + * (a nested loop's test), unless only it is natural and the other returns + * into its loop, where the other loses instead: that one is a conditioned + * `goto` spanning the cycle, since a real outer test returns ahead of the + * inner loop; + * - it is not natural, and its loop closes avoiding the other test's gateway + * while the other's does not (a jump from after an outer loop into the + * inner body); + * - it jumps into a node entered from outside only by a jump, and its + * gateway's other routes return to it through a natural entry (an `if` + * jumping into a plain-flow loop). Its target ranks below every entry. */ function loopTestRank( inside: Set, @@ -620,17 +515,11 @@ function loopTestRank( hasNaturalEntry: (n: string) => boolean, entered: (n: string) => boolean, ): (n: string) => number { - const reach = (from: string, avoid: string) => { - const seen = new Set(); - const stack = [from]; - while (stack.length > 0) { - const n = stack.pop()!; - if (n === avoid || !inside.has(n) || seen.has(n)) continue; - seen.add(n); - stack.push(...succ.get(n)!); - } - return seen; - }; + const within = (n: string, avoid: string) => n !== avoid && inside.has(n); + const reach = (from: string, avoid: string) => + closure(within(from, avoid) ? [from] : [], (n) => + succ.get(n)!.filter((m) => within(m, avoid)), + ); const tests = conditioned.filter( (f) => inside.has(f.sourceRef) && inside.has(f.targetRef), ); @@ -645,12 +534,8 @@ function loopTestRank( return [f, own]; }), ); - // `closesAvoiding` and `turnsRound` below each ask "is x reachable from a - // test's own target, avoiding some node", the same root over and over - // across the O(T^2) own/turnsRound checks that follow. Removing a node d - // from a graph disconnects exactly the nodes d dominates, so one - // dominator tree per test (`computeIdom`, restricted to `inside`, rooted - // at the test's target) answers every such query in O(1). + // Removing d disconnects exactly the nodes d dominates, so one dominator tree + // per test, rooted at its target, answers each "reachable avoiding d" in O(1). const insideSucc = new Map( [...inside].map((n) => [n, succ.get(n)!.filter((m) => inside.has(m))]), ); @@ -712,11 +597,7 @@ function loopTestRank( : UNTESTED; } -/** - * The strongly connected components of `succ` restricted to `nodes` that hold - * a cycle: more than one node, or one flowing into itself. Tarjan's - * algorithm, iterative like {@link reversePostorder}. - */ +/** Strongly connected components holding a cycle (iterative Tarjan). */ function cycles(nodes: Set, succ: Map): string[][] { const index = new Map(); const low = new Map(); @@ -780,12 +661,8 @@ function cycles(nodes: Set, succ: Map): string[][] { } /** - * `a` dominates `b` when `a` sits on `b`'s idom chain. Reflexive and total. - * `idom` is a tree rooted at `root`, so one DFS numbers each node with a - * pre-order entry time and, as its exit time, the last entry time handed out - * in its subtree; `a` is an ancestor of `b` (or `b` itself) exactly when - * `b`'s entry falls inside `a`'s [entry, exit]. A node absent from `idom` - * (unknown or unreachable) gets no interval and answers false on either side. + * Dominance as interval containment of pre-order entry/exit numbers on the + * idom tree. Nodes absent from `idom` answer false. */ function makeDominanceQuery( idom: Map, @@ -801,7 +678,6 @@ function makeDominanceQuery( }; } -/** Iterative pre-order DFS over the parent map `idom`, rooted at `root`. */ function numberDominatorTree( idom: Map, root: string, diff --git a/packages/transform/src/errors.ts b/packages/transform/src/errors.ts index 7c509d6..63b1f1e 100644 --- a/packages/transform/src/errors.ts +++ b/packages/transform/src/errors.ts @@ -1,23 +1,20 @@ /** - * Refusals raised by `xmlToIr`, plus {@link LayoutError}, raised by `irToXml`. - * - * Content the IR cannot express is refused before any IR is produced. Content - * the IR does not carry but that costs no semantics is dropped with a warning - * instead, on the `warnings` channel `xmlToIr` returns. - * `packages/transform/README.md` describes which construct lands where. + * Refusals raised by `xmlToIr` (content the IR cannot express; lossless drops + * warn instead), plus {@link LayoutError} from `irToXml`. */ import { + BOUNDARY_TRIGGERS, EMIT_TRIGGERS, + END_TRIGGERS, FORM_CONSTRAINT_NAMES, formatPlainWordList, - ON_TRIGGERS, + HANDLER_START_TRIGGERS, START_TRIGGERS, - TRIGGER_PAYLOAD, + THROW_TRIGGERS, } from '@bpmn-script/language'; /** - * Base for every refusal, so one `instanceof` classifies the family. * Subclasses `declare` their fields so class field initializers cannot * overwrite what `Object.assign` wrote after `super()`. */ @@ -29,10 +26,26 @@ export abstract class UnsupportedConstructError extends Error { } } +const SERVICE_TASK_FORMS = [ + ['a Java class', 'operaton:class (or the deprecated camunda:class alias)'], + ['an expression', 'operaton:expression'], + ['a delegate expression', 'operaton:delegateExpression'], + ['an external task topic', 'operaton:type="external" with operaton:topic'], + [ + 'a built-in mail or shell task with its fields', + 'operaton:type="mail" or "shell" with their operaton:field children', + ], + ['a decision reference on a business rule task', 'operaton:decisionRef'], +] as const; + +export const SERVICE_TASK_FORM_ATTRIBUTES = formatPlainWordList( + SERVICE_TASK_FORMS.map(([, attributes]) => attributes), + 'and', +); + /** - * A task with no execution form, or an external type missing its topic. - * Operaton applies the same mandatory-discriminator rule to every tag it runs - * through its service-task factory, so `subject` names which one refused. + * Operaton applies the same mandatory-discriminator rule to every tag its + * service-task factory runs, so `subject` names which one refused. */ export class UnsupportedServiceTaskFormError extends UnsupportedConstructError { declare readonly serviceTaskId: string; @@ -46,19 +59,12 @@ export class UnsupportedServiceTaskFormError extends UnsupportedConstructError { ) { super( `${subject} '${serviceTaskId}' uses unsupported execution form: ${construct}. ` + - 'Supported forms are a Java class, an expression, a delegate expression, ' + - 'an external task topic, a built-in mail or shell task with its fields, ' + - 'or, on a business rule task, a decision reference.', + `Supported forms are ${formatPlainWordList(SERVICE_TASK_FORMS.map(([prose]) => prose))}.`, { serviceTaskId, construct, subject }, ); } } -/** - * An `operaton:formField` type outside the five the DSL maps: `string`, - * `long`, `boolean`, `date`, and `enum`, `long` being the Operaton spelling of - * the DSL's `number`. - */ export class UnsupportedFormFieldTypeError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly fieldId: string; @@ -74,13 +80,11 @@ export class UnsupportedFormFieldTypeError extends UnsupportedConstructError { } } -/** {@link FORM_CONSTRAINT_NAMES} minus `validator`, joined as a plain enumeration. */ -const REGISTERED_VALIDATOR_NAMES = (() => { - const names = FORM_CONSTRAINT_NAMES.filter((name) => name !== 'validator'); - return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`; -})(); +const REGISTERED_VALIDATOR_NAMES = formatPlainWordList( + FORM_CONSTRAINT_NAMES.filter((name) => name !== 'validator'), + 'and', +); -/** A form field constraint the engine fails the deployment on or the script cannot hold; `detail` states which. */ export class UnsupportedFormFieldConstraintError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly fieldId: string; @@ -104,7 +108,6 @@ export class UnsupportedFormFieldConstraintError extends UnsupportedConstructErr } } -/** The closing sentence of every {@link UnsupportedElementError}. */ export const SUPPORTED_KINDS_MESSAGE = 'Only start/end events, throws, emits, boundary events, event ' + 'handlers, plain tasks, user tasks, service tasks, send tasks, ' + @@ -113,14 +116,7 @@ export const SUPPORTED_KINDS_MESSAGE = 'gateways, embedded subprocesses, attempt blocks, call activities, ' + 'and sequence flows are supported.'; -/** - * A flow element kind outside the supported subset, such as - * `bpmn:adHocSubProcess` or `bpmn:complexGateway`. A supported kind carrying an - * unrepresentable shape refuses via {@link UnsupportedEventFeatureError} or - * {@link UnsupportedEventDefinitionError} instead. - */ export class UnsupportedElementError extends UnsupportedConstructError { - /** Fully-qualified BPMN type name, e.g. `bpmn:ParallelGateway`. */ declare readonly qname: string; declare readonly elementId?: string; @@ -134,7 +130,6 @@ export class UnsupportedElementError extends UnsupportedConstructError { } } -/** A call activity the engine could not resolve: `detail` names the shape. */ export class UnsupportedCallActivityError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly detail: string; @@ -152,17 +147,11 @@ export class UnsupportedCallActivityError extends UnsupportedConstructError { } } -export type EventPosition = 'start' | 'end' | 'intermediate throw' | 'boundary'; +type EventPosition = 'start' | 'end' | 'intermediate throw' | 'boundary'; -/** - * An event definition kind this tool does not import at that position. The - * right kind in the wrong shape (an error throw with no code, a timer with no - * time child) refuses via {@link UnsupportedEventFeatureError} instead. - */ export class UnsupportedEventDefinitionError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly eventKind: EventPosition; - /** Moddle `$type`, e.g. `bpmn:TerminateEventDefinition`. */ declare readonly definitionType: string; constructor( @@ -179,33 +168,24 @@ export class UnsupportedEventDefinitionError extends UnsupportedConstructError { } } -/** The triggers that open a handler of their own, which is what a handler start is. */ -const HANDLER_START_TRIGGERS = ON_TRIGGERS.filter( - (word) => TRIGGER_PAYLOAD[word]?.hostless === true, -); - /** - * Compensation falls out because a boundary compensation trigger refuses - * earlier, via {@link UnsupportedEventFeatureError}. Cancel is named apart from - * the list because nothing in the table records that it needs a host that can - * be given up. + * Compensation refuses earlier. Cancel is named apart since the trigger table + * does not record that it needs a cancellable host. */ -const BOUNDARY_TRIGGERS = ON_TRIGGERS.filter( - (word) => TRIGGER_PAYLOAD[word]?.boundary === true && word !== 'cancel', +const BOUNDARY_TRIGGERS_BUT_CANCEL = BOUNDARY_TRIGGERS.filter( + (word) => word !== 'cancel', ); -/** The default closing sentence: what a handler, a throw, and an emit accept. */ +const END_EVENT_TRIGGERS = [ + ...END_TRIGGERS.filter((word) => word !== 'cancel'), + ...THROW_TRIGGERS, +]; + const EVENT_SURFACE_NOTE = `Event handlers catch one ${formatPlainWordList(HANDLER_START_TRIGGERS)} ` + 'trigger on their single start event; throws and emits carry the code or ' + 'name their kind requires, and compensation carries neither.'; -/** - * A supported event definition kind shaped in a way the DSL surface cannot - * express. `detail` names the shape; a refusal outside the handler/throw/emit - * surface passes its own `remedy`, so the reader is told the move that fixes - * the document. - */ export class UnsupportedEventFeatureError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly detail: string; @@ -222,13 +202,8 @@ export class UnsupportedEventFeatureError extends UnsupportedConstructError { } } -/** - * A repetition the surface cannot express, or one the engine itself refuses to - * deploy. `detail` names which of the two, and why. - */ export class UnsupportedLoopCharacteristicsError extends UnsupportedConstructError { declare readonly elementId: string; - /** Moddle `$type`, e.g. `bpmn:MultiInstanceLoopCharacteristics`. */ declare readonly loopType: string; declare readonly detail: string; @@ -242,9 +217,8 @@ export class UnsupportedLoopCharacteristicsError extends UnsupportedConstructErr } /** - * Two or more executable processes: `BpmnParse.parseProcessDefinitions` - * deploys each of them, and the IR holds one. A pool or a message flow is - * diagram data to the engine and is warned about instead. + * Operaton deploys every executable process and the IR holds one. Pools and + * message flows are diagram data to the engine and only warn. */ export class UnsupportedCollaborationError extends UnsupportedConstructError { declare readonly detail: string; @@ -258,11 +232,6 @@ export class UnsupportedCollaborationError extends UnsupportedConstructError { } } -/** - * A document Operaton refuses to deploy whole (a ``, an undefined - * entity, a duplicate id, a `bpmn:import`), or one this tool cannot read as - * one process. `detail` says what was found and what reads it that way. - */ export class UnsupportedDocumentError extends UnsupportedConstructError { declare readonly detail: string; @@ -271,10 +240,6 @@ export class UnsupportedDocumentError extends UnsupportedConstructError { } } -/** - * Operaton extension content the IR's discriminated unions cannot represent. - * `detail` names the shape; the message states the rule it broke. - */ export class UnsupportedExtensionFormError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly detail: string; @@ -295,11 +260,6 @@ export class UnsupportedExtensionFormError extends UnsupportedConstructError { } } -/** - * A user task naming a deployed form in a shape Operaton's - * `parseFormDefinition` rejects. `detail` names the shape; the message states - * the rule it broke. - */ export class UnsupportedFormReferenceError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly detail: string; @@ -316,10 +276,8 @@ export class UnsupportedFormReferenceError extends UnsupportedConstructError { } /** - * A sequence flow's `bpmn:conditionExpression` or a conditional event - * definition's `bpmn:condition` that Operaton's `parseConditionExpression` - * runs outside UEL (a `language` attribute builds a `ScriptCondition`) or - * refuses (an `xsi:type` other than `tFormalExpression`); `detail` names which. + * `parseConditionExpression` runs a condition with a `language` attribute as a + * script, not UEL, and refuses an `xsi:type` other than `tFormalExpression`. */ export class UnsupportedConditionExpressionError extends UnsupportedConstructError { declare readonly elementId: string; @@ -336,11 +294,7 @@ export class UnsupportedConditionExpressionError extends UnsupportedConstructErr } } -/** - * An exclusive gateway whose outgoing flows `BpmnParse.validateExclusiveGateway` - * fails the deployment on. `detail` is the engine's own sentence for the - * shape, so the refusal reads as the deployment error would. - */ +/** `detail` is the engine's own sentence, so the refusal reads as the deployment error would. */ export class UnsupportedGatewayShapeError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly detail: string; @@ -357,7 +311,6 @@ export class UnsupportedGatewayShapeError extends UnsupportedConstructError { } } -/** An external task's error mapping the engine fails the deployment on or that names no coded error root; `detail` states which. */ export class UnsupportedErrorMappingError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly detail: string; @@ -373,7 +326,6 @@ export class UnsupportedErrorMappingError extends UnsupportedConstructError { } } -/** A user task's BPMN resource assignment in a shape Operaton refuses to deploy; `detail` states which. */ export class UnsupportedAssignmentError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly detail: string; @@ -390,7 +342,6 @@ export class UnsupportedAssignmentError extends UnsupportedConstructError { } } -/** `bpmn:TimerEventDefinition` -> `timer`. */ function friendlyEventDefinition(definitionType: string): string { const local = definitionType.replace(/^.*:/, ''); return local.replace(/EventDefinition$/, '').toLowerCase() || 'special'; @@ -406,26 +357,22 @@ function supportedKindsMessage(eventKind: EventPosition): string { ); case 'end': return ( - 'A typed end event supports terminate, error, escalation, message, ' + - 'signal, or compensation, plus cancel inside a block that can be ' + - 'given up.' + `A typed end event supports ${formatPlainWordList(END_EVENT_TRIGGERS)}, ` + + 'plus cancel inside a block that can be given up.' ); case 'intermediate throw': return `An emit supports ${formatPlainWordList(EMIT_TRIGGERS)}.`; case 'boundary': return ( - `A boundary event supports ${formatPlainWordList(BOUNDARY_TRIGGERS)}, ` + + `A boundary event supports ${formatPlainWordList(BOUNDARY_TRIGGERS_BUT_CANCEL)}, ` + 'plus cancel on a block that can be given up.' ); } } /** - * `bpmn-auto-layout`'s grid solver throws on some validator-clean flow shapes - * (a `goto` restructuring among them). `xml` is the document with no - * `bpmndi:` element, for any process; Operaton deploys it the same - * (`BpmnParse.parseDiagramInterchangeElements` reads a diagram only when one - * is present), so a caller can fall back to it. + * `bpmn-auto-layout` throws on some valid flow shapes. `xml` is the document + * without `bpmndi:`, which Operaton deploys the same, as a fallback. */ export class LayoutError extends Error { readonly xml: string; diff --git a/packages/transform/src/host-messages.ts b/packages/transform/src/host-messages.ts index 2b2f99d..6cd09c1 100644 --- a/packages/transform/src/host-messages.ts +++ b/packages/transform/src/host-messages.ts @@ -1,26 +1,21 @@ /** - * What a host shows a user for a file it cannot convert, plus the checks that - * catch input no BPMN parser can do anything with. The CLI and the VS Code - * extension both put these strings in front of a person, so they carry no host - * wording: a caller adds what it wants around them. + * User-facing messages for unconvertible files, shared by CLI and VS Code, so + * they carry no host wording. */ -// `moddle-xml`'s one message for a root that resolves to no registered type; -// it never says what it found. +// `moddle-xml`'s message for a root of no registered type; it never says what it found. const WRONG_ROOT_MESSAGE = 'failed to parse document as '; const BPMN_NAMESPACE = 'http://www.omg.org/spec/BPMN/20100524/MODEL'; -/** The message for input that is empty, as opposed to merely not XML. */ export const EMPTY_INPUT_MESSAGE = 'the file is empty'; -/** The message for a source that parses but declares no process. */ export const NO_PROCESS_MESSAGE = 'the file has no process'; function describeRoot(xml: string): string { const tag = /<([\w:]+)/.exec(xml)?.[1]; if (tag === undefined) return ''; - // A greedy `[^>]*` backtracks from the tag's end, so it lands on the last - // xmlns declared on the root, not the one for the root's own prefix. + // Not one regex: a greedy `[^>]*` lands on the root's last xmlns, not the + // one for its own prefix. const colon = tag.indexOf(':'); const decl = colon === -1 ? 'xmlns' : `xmlns:${tag.slice(0, colon)}`; const uri = new RegExp(`<${tag}[^>]*?\\s${decl}="([^"]+)"`).exec(xml)?.[1]; @@ -32,10 +27,7 @@ function printablePreview(text: string): string { return text.slice(0, 200).replace(/\p{C}/gu, '').slice(0, 40); } -/** - * `saxen`'s parse errors quote the unparsed remainder of the document after a - * newline: megabytes for a large or binary file, control characters included. - */ +/** `saxen` quotes the whole unparsed remainder, megabytes and control characters included. */ export function readableParseError(message: string, xml: string): string { const firstLine = message.split('\n')[0]; const capped = ( @@ -44,13 +36,8 @@ export function readableParseError(message: string, xml: string): string { return capped === WRONG_ROOT_MESSAGE ? capped + describeRoot(xml) : capped; } -/** - * Why `xml` cannot be a BPMN document, before a parser quotes it back in full, - * or undefined when it is worth parsing. - */ export function xmlInputProblem(xml: string): string | undefined { - // `trim` treats a leading BOM as whitespace (ECMA-262 WhiteSpace includes - // U+FEFF), so neither check needs a BOM strip. + // `trim` strips a leading BOM (U+FEFF is ECMA-262 WhiteSpace). if (xml.trim() === '') return EMPTY_INPUT_MESSAGE; if (!xml.trimStart().startsWith('<')) { return `not an XML document (starts with "${printablePreview(xml.trimStart())}")`; diff --git a/packages/transform/src/humanize.ts b/packages/transform/src/humanize.ts index 457e5d3..bf05a12 100644 --- a/packages/transform/src/humanize.ts +++ b/packages/transform/src/humanize.ts @@ -1,7 +1,6 @@ /** - * Derive a BPMN `name` from a DSL identifier, at serialization time only. - * `xml-to-ir.ts` reverses this by dropping a `name` that equals `humanize(id)`, - * so the two must stay in step or the round trip grows redundant labels. + * Derives a BPMN `name` from a DSL id. `xmlToIr` drops a `name` equal to + * `humanize(id)`, so changing this grows redundant labels on round trips. */ export function humanize(id: string): string { return id diff --git a/packages/transform/src/index.ts b/packages/transform/src/index.ts index 0e6e581..69729c6 100644 --- a/packages/transform/src/index.ts +++ b/packages/transform/src/index.ts @@ -48,12 +48,7 @@ export type { } from './ir/types.js'; export type { BuiltinTaskType } from '@bpmn-script/language'; -export { - isGateway, - gatewayDefaultFlowId, - jobSettings, - carriesFields, -} from './ir/types.js'; +export { isGateway, gatewayDefaultFlowId } from './ir/types.js'; export { makeGatewaySplitId, @@ -70,7 +65,7 @@ export { } from './synthesize-ids.js'; export { parseJuel, renderRawFallback } from './juel.js'; -export type { JuelNode, Accessor, BinaryOp, ExprResult } from './juel.js'; +export type { JuelNode } from './juel.js'; export { irToXml } from './ir-to-xml.js'; export { @@ -102,6 +97,7 @@ export { UnsupportedErrorMappingError, UnsupportedAssignmentError, LayoutError, + SERVICE_TASK_FORM_ATTRIBUTES, } from './errors.js'; export { astToIr } from './ast-to-ir.js'; diff --git a/packages/transform/src/ir-to-dsl.ts b/packages/transform/src/ir-to-dsl.ts index acab243..9f9df7e 100644 --- a/packages/transform/src/ir-to-dsl.ts +++ b/packages/transform/src/ir-to-dsl.ts @@ -1,13 +1,8 @@ /** - * Restructuring IR -> DSL, the inverse of `astToIr`: a flat {@link BpmnProcess} - * back into source that re-parses and re-desugars to an equivalent IR. - * - * Structure comes from the dominator analysis in `cfg-analysis.ts` matched - * against the pattern catalog of ADR 0009; whatever it cannot fold degrades to - * `goto`, and an edge with no `goto` form leaves an {@link UNSTRUCTURED_MARKER}. - * Every gateway a pattern matches is elided: the desugarer derives gateway ids - * from structural coordinates, so one that never prints is re-synthesized under - * the same id, which is what makes DSL -> IR -> DSL idempotent. + * IR -> DSL, the inverse of `astToIr`. Dominator patterns (ADR 0009) fold what + * they can; the rest degrades to `goto`, else an {@link UNSTRUCTURED_MARKER}. + * Matched gateways are elided: the desugarer derives gateway ids from + * structural coordinates, so re-parsing re-synthesizes the same ids. */ import { @@ -22,11 +17,13 @@ import { EXTERNAL_BINDING_KEY, EXTERNAL_TASK_EL_NAME, FIELD_DIRECTION, + ID_TEXT, INPUT_DIRECTION, isReservedName, joinSettingKey, LOOP_VARIABLES, OUTPUT_DIRECTION, + PROCESS_ENGINE_HEADER_KEYS, PROPERTY_DIRECTION, runSettingKey, TASK_PRIORITY_KEY, @@ -73,7 +70,6 @@ import { boundaryEventIdBase, CATCH_EVENT_PREFIX, claimDeclarationName, - ID_SHAPED, isMintedEndId, isMintedStartId, isWritableName, @@ -81,7 +77,7 @@ import { resolveCollision, THROW_EVENT_PREFIX, } from './synthesize-ids.js'; -import { analyzeCfg, type CfgAnalysis } from './cfg-analysis.js'; +import { analyzeCfg, type CfgAnalysis, closure } from './cfg-analysis.js'; import { escapeQuoted, type JuelNode, @@ -89,30 +85,24 @@ import { renderRawFallback, } from './juel.js'; -/** Reused by `xml-to-ir.ts` to dedent a preview built by wrapping a fragment in a throwaway process. */ export const INDENT = ' '; /** - * One entry per line, except a fenced script body, which keeps its newlines - * inside one entry: an enclosing block indents whole entries, and indenting - * inside the fence would rewrite the code. + * One entry per line, except a fenced script body: indenting inside the fence + * would rewrite the code. */ type Lines = string[]; interface PrintNames { - /** The name each raised code is written under, from {@link codeDeclarations}. */ error: Map; escalation: Map; - /** Only the ids that print under another name ({@link printedNames}); every other id prints as itself. */ printed: ReadonlyMap; } -/** Every site that writes an element or process id goes through here. */ function nameOf(names: PrintNames, id: string): string { return names.printed.get(id) ?? id; } -/** The id seeds the minted terminal ids; the elements find the boundaries and the first plain start. */ export type PrintContainer = Pick; export type PrintWarningCategory = @@ -127,11 +117,8 @@ export type PrintWarningCategory = | 'droppedSetting'; /** - * A non-fatal notice that `irToDsl` could not carry something into the script. - * - * Every warning keeps the id out of its message and in `elementId`: a - * synthesized id routinely spells BPMN vocabulary the surface keeps away from - * its readers. + * The id stays out of `message`: a synthesized id spells BPMN vocabulary the + * surface hides from readers. */ export interface PrintWarning { elementId: string; @@ -166,9 +153,8 @@ export function irToDsl(process: BpmnProcess): { } /** - * Gateways alone: every other elided label is reported by `xmlToIr`, and a - * wider rule would report the same drop twice to a caller printing both - * channels. + * Gateways only: `xmlToIr` reports every other elided label, so a wider rule + * would report it twice. */ function warnGatewayText( container: FlowContainer, @@ -206,9 +192,8 @@ function warnGatewayText( } /** - * A reserved name is the model's to repair, so it is reported ahead of the - * print. A plain synthesized end that prints for its position is reported by - * the emitter, which alone knows the position. + * A synthesized end printed for its position is reported by the emitter, which + * alone knows the position. */ function warnRefusedStatements( container: FlowContainer, @@ -241,14 +226,10 @@ function reservedNameWarning(elementId: string): PrintWarning { } /** - * Every id the script writes, as the process header, a statement name or a - * `goto` target, and cannot spell; a gateway, a boundary and an event - * sub-process never write theirs. A minted name is resolved against every id - * in the document and every name minted before it, since a name written twice - * leaves every jump to it ambiguous. The rebuilt document carries the new id, - * which the engine keys history (`HistoricActivityInstance.getActivityId`), - * migration plans (`MigrationPlanBuilder.mapActivities`) and a modification - * (`InstantiationBuilder.startBeforeActivity`) on, hence "not the same". + * Mints a name for every written id the script cannot spell, unique across the + * document since a duplicate makes jumps ambiguous. The engine keys history, + * migration plans and start-before-activity on the activity id, so the rebuilt + * process is not equivalent. */ function printedNames( process: BpmnProcess, @@ -295,34 +276,26 @@ interface Branch { } /** - * One per container: a sub-process's body lives in the child container's - * arrays, so the parent's CFG treats the sub-process as one opaque node and no - * region spans two containers. + * One per container: the parent's CFG treats a sub-process as one opaque node. */ class Emitter { private readonly cfg: CfgAnalysis; private readonly byId = new Map(); - /** In IR order, which is what keeps emission deterministic. */ + /** IR order keeps emission deterministic. */ private readonly outgoingBySource = new Map(); private readonly emittedNodes = new Set(); private readonly consumedFlows = new Set(); private readonly settingsTaken = new Set(); /** - * The `await`/`parallel` branch each statement was printed in, as the path - * of branch ids from the outermost block down, and `''` outside any. A jump - * remembers the path it was printed under, and the two are compared once - * the walk is done, since a jump can name a statement printed after it. + * Each statement's `await`/`parallel` branch path, `''` outside any. Jumps + * are checked after the walk, since a jump can precede its target. */ private readonly branchOf = new Map(); private branchPath = ''; /** - * The innermost `await`/`parallel` branch, or if-chain branch, being walked: - * its route and join. `ownEntry` is set only for an if-chain branch (see - * {@link emitIfBranch}): a sibling branch's `goto` legally targets another - * branch's first statement, unlike an `await`/`parallel` branch's entry, - * which only its own split may reach. `outer` is the branch this one is - * nested in; see {@link leavesInnermostBranch} for how `ownEntry` and - * `outer` bound a `goto`. + * Innermost branch being walked. `ownEntry` is set only for an if-chain + * branch, whose first statement a sibling's `goto` may target; an + * `await`/`parallel` branch entry is reachable only from its own split. */ private branch: Branch | undefined; private readonly jumps: { target: string; branchPath: string }[] = []; @@ -333,26 +306,18 @@ class Emitter { id: string; }[] = []; /** - * The plain end minted for this container when the printer leaves it out, - * which is the block's tail unless a chain hoisted behind it forces it to - * print. A boundary body's minted end is not it: that body prints in an - * array of its own, where nothing follows its end. + * The minted plain end the print leaves out, unless a chain hoisted behind it + * forces it to print. */ private readonly elidedTail: string | undefined; - /** Each link throw whose catch prints right behind it, from {@link linkedCatches}. */ private readonly linkedCatch: ReadonlyMap; - /** Each split's own merge, from {@link pairMerges}. */ private readonly ownMerges: ReadonlyMap; constructor( private readonly container: FlowContainer, - /** Shared with every nested container, so one process yields one report. */ private readonly warnings: PrintWarning[], private readonly names: PrintNames, - /** - * An event sub-process's start prints its trigger in the `on` header, so - * the start statement inside the body prints without one. - */ + /** An event sub-process prints its start trigger in the `on` header. */ private readonly startTriggerSuppressed = false, ) { this.elidedTail = container.flowElements.find( @@ -363,8 +328,7 @@ class Emitter { isElidedOnPrint(el, container), )?.id; for (const el of container.flowElements) { - // A duplicate would corrupt the walk. The desugarer resolves collisions, - // so one here means malformed IR. + // The desugarer resolves collisions, so a duplicate means malformed IR. if (this.byId.has(el.id)) { throw new Error( `irToDsl: duplicate flow element id '${el.id}' in container '${container.id}'.`, @@ -373,7 +337,7 @@ class Emitter { this.byId.set(el.id, el); } for (const f of container.sequenceFlows) { - const list = this.outgoingBySource.get(f.sourceRef) ?? []; + const list = this.routesOut(f.sourceRef); list.push(f); this.outgoingBySource.set(f.sourceRef, list); } @@ -385,18 +349,11 @@ class Emitter { } /** - * Each split's own merge. The compiler lowers every if, parallel and await - * with a merge whenever a route reaches it, and that merge closes the split - * in the dominator tree: walking up from the merge, every split passed is - * closed by a merge passed before it, and the first split left open is its - * own. A while's head and a do-while's test open and close nothing, being - * one back edge's two ends. A conditioned `goto` into a `do` body can read - * as that `do`'s test, and then its merge climbs to an enclosing split - * too; of the merges a split collects, the one nearest it in the dominator - * tree is its own, whatever order the model lists them in. Dominance is - * read from the starts alone, so a handler jumping into a branch leaves the - * branch in its split, but a merge that a flow from outside the split - * enters is no block's own. + * Walking up the dominator tree from a merge, each split passed is closed by + * a merge passed earlier; the first split left open owns it. Loop heads and + * do-while tests open and close nothing. Of several merges a split collects, + * the one nearest it wins. A merge entered from outside its split has no + * owner. */ private pairMerges(): Map { const loopEnds = new Set( @@ -434,12 +391,9 @@ class Emitter { const mergeOf = (split: string): string | undefined => [...owner].find(([, s]) => s === split)?.[0]; - // A goto from an outer branch into a nested one bypasses the nested split, - // so the nested merge's dominator is the outer split and the count above - // hands it the nested merge. The nested split's routes still fall into - // that merge: it keeps it, and the outer split takes the next one. Splits - // pair innermost first, so a route falling into a nested block's merge - // runs on past it. + // A goto into a nested branch makes the outer split dominate the nested + // merge, so the count above misassigns it. The nested split keeps the merge + // its routes fall into and the outer split takes the next; innermost first. const fallsInto = (from: string, split: string): string | undefined => { const seen = new Set(); for (let n: string | undefined = from; n !== undefined;) { @@ -492,13 +446,9 @@ class Emitter { } /** - * The split's own merge while the walk can still continue at it: unprinted, - * of the kind a fork or race synchronizes at, and reached by a route before - * `stop`. A route that needs a link throw's hop to reach it does not count - * once something prints past the merge ({@link reachableOverFlow}): the - * routes past it sink into the branch that carries them instead. A merge - * with nothing past it costs nothing to place past the hop either way, so - * it is kept the plain way. + * The split's own merge if unprinted, of the given kind, and reached before + * `stop`. A route reaching it only over a link hop counts only when nothing + * prints past the merge ({@link reachableOverFlow}). */ private ownJoin( splitId: string, @@ -512,8 +462,7 @@ class Emitter { this.emittedNodes.has(merge) || (kind !== undefined && this.byId.get(merge)?.kind !== kind) || this.entersUnprintedLoop(splitId, merge) || - // One route reaching this split's own merge is enough to place it: the - // rest, whatever they do, print inside their own branches instead. + // One route reaching it suffices; the rest print inside their branches. !outs.some( (f) => this.reachable(f.targetRef, splitId, stop).has(merge) && @@ -534,19 +483,15 @@ class Emitter { } /** - * The pass order is the ownership rule: the pass that walks a node first - * owns it in the print. Every chain a container entry reaches (a start, or a - * node no flow enters, a link catch above all) is walked before the boundary - * handlers, so a handler whose body jumps to one degrades to a `goto` there; - * a boundary escape chain is reached from no entry, so it is walked before - * the orphan sweep would print it as detached top-level statements. + * The pass that walks a node first owns it in the print. Entry chains print + * before boundary handlers, so a handler jumping into one takes a `goto`; + * handlers print before the orphan sweep, which would detach their chains. */ emit(): string[] { const lines: string[] = []; - // 1. The elided start first and alone: the compiler re-derives it only at - // the head of a body that opens with no `start`, and anywhere else its - // chain lands after another chain's `end` as a dangling `goto`. + // 1. The elided start first: the compiler re-derives it only at the head + // of a body that opens with no `start`. for (const el of this.container.flowElements) { if ( el.kind === 'startEvent' && @@ -557,11 +502,8 @@ class Emitter { } } // A chain printed after the one reaching the elided end pushes that end - // off the block's tail, where it prints under its reserved id. Only the - // elided start is exempt from moving, being the process's implicit - // entry; every other start whose own chain reaches the tail moves - // behind the rest, so the tail stays implicit instead of printing under - // that id. + // off the tail, where it prints under its reserved id, so starts reaching + // the tail go last. const tail = this.elidedTail; const reachesTail = ( el: Extract, @@ -576,11 +518,9 @@ class Emitter { if (!this.emittedNodes.has(el.id)) this.emitStartGroup(el, lines); } - // 2. Every chain an entry reaches that pass 1 left for a jump. Each walk - // starts at a chain's head, one whose every owned predecessor already - // printed, or in a cycle at a node a printed one flows into: started - // mid-chain, the walk prints the steps before it as a jump target - // behind it. Model order only breaks ties. + // 2. Every entry-reached chain pass 1 left for a jump. Start each walk at + // a chain head (all owned predecessors printed), else in a cycle at a + // node a printed one flows into; model order breaks ties. const owned = this.reachableFromEntries(); const leftover = () => this.container.flowElements.filter( @@ -596,9 +536,7 @@ class Emitter { rest[0]!; const before = this.emittedNodes.size; this.emitFrom(head.id, undefined, lines, 0); - // Every walked node is marked, so leftover() shrinks each pass; - // otherwise this loop never terminates, which is malformed IR, as a - // duplicate id is. + // No progress means malformed IR; without this the loop never ends. if (this.emittedNodes.size === before) { throw new Error( `irToDsl: walking '${head.id}' in container '${this.container.id}' printed nothing.`, @@ -606,10 +544,9 @@ class Emitter { } } - // 3. Boundary handlers, held back because the surface requires a handler - // block to follow the body it guards. Each block is kept under its - // element's index so pass 6 can place it among the event sub-processes - // the way the model orders them. + // 3. Boundary handlers must follow the body they guard. Keyed by element + // index so pass 6 interleaves them with event sub-processes in model + // order. const boundaryBlocks = new Map(); const rankedBoundaries: { index: number; @@ -630,13 +567,9 @@ class Emitter { rankedBoundaries.push({ index, base, rank, block }); } }); - // Two boundaries sharing a host and trigger kind mint their id from - // statement order (the base id first, then `_2`, `_3`, ...), so laying - // their blocks down in model order can print the `_2` handler first and - // swap the ids on the next compile. Reassigning each such group's blocks - // across its own index slots by that rank keeps every boundary under its - // own id whatever order the model lists them in; the slots themselves, - // and every other block's index, are untouched. + // Boundaries sharing a host and trigger mint ids by statement order (base, + // `_2`, `_3`, ...), so each group's blocks are reordered across its own + // slots by rank; model order would swap the ids on the next compile. const byBase = new Map(); for (const m of rankedBoundaries) { const group = byBase.get(m.base); @@ -653,8 +586,7 @@ class Emitter { slots.forEach((slot, i) => boundaryBlocks.set(slot, byRank[i]!.block)); } - // 4. Orphaned fragments: a cycle no entry reaches, and what a handler's - // walk left for a jump. + // 4. Orphans: a cycle no entry reaches, and what a handler left for a jump. for (const el of this.container.flowElements) { if (isHandler(el) || isBoundary(el)) continue; if (!this.emittedNodes.has(el.id)) { @@ -662,8 +594,8 @@ class Emitter { } } - // 5. Every route left: a jump carries no condition, so `consume` reports - // one the way it does for a fall-through. + // 5. Every route left becomes a jump; `consume` reports a dropped + // condition. for (const f of this.container.sequenceFlows) { if (!this.consumedFlows.has(f.id)) { this.consume(f); @@ -671,11 +603,9 @@ class Emitter { } } - // A plain synthesized end stays out only at its block's tail, the one - // position the compiler re-derives it at, and whether a flow statement - // followed it is known only here. The boundary blocks and handlers appended - // below are lowered out of chain, so they do not count as one. Reverse - // order keeps every lower index valid while splicing. + // A synthesized end stays elided only at its block's tail, the one place + // the compiler re-derives it, which is known only now. Handlers appended + // below do not count as following it. Reverse order keeps indices valid. const printedEnds: string[] = []; for (const d of this.deferredEnds.toReversed()) { if (d.index >= d.lines.length) continue; @@ -684,9 +614,8 @@ class Emitter { } for (const id of printedEnds) this.warnings.push(reservedNameWarning(id)); - // 6. Trailing handler group, in model order: the compiler lays handlers - // down in statement order and numbers an event sub-process by its - // statement index, so any other order renumbers it on the next compile. + // 6. Handlers in model order: the compiler numbers an event sub-process by + // statement index, so any other order renumbers it. this.container.flowElements.forEach((el, index) => { const block = boundaryBlocks.get(index); if (block !== undefined) lines.push(...block); @@ -695,10 +624,9 @@ class Emitter { } }); - // Swept once the walk is done rather than reported where a construct gives - // up: an elided pass-through and a jump forwarded through a gateway leave - // no degradation site, and a merge left behind by one statement can still - // open the next. + // Swept after the walk: an elided pass-through or a jump forwarded through + // a gateway has no site to report at, and a leftover merge can still open + // the next statement. for (const el of this.container.flowElements) { if ( isGateway(el) && @@ -709,9 +637,8 @@ class Emitter { } } - // The validator refuses a `goto` into a branch of an `await` or `parallel` - // block unless the jump sits inside that branch, however deep, which is - // what the path prefix asks. + // The validator refuses a `goto` into an `await`/`parallel` branch from + // outside that branch. for (const { target, branchPath } of this.jumps) { const inside = this.branchOf.get(target); if ( @@ -726,10 +653,6 @@ class Emitter { return lines; } - /** - * A start leaving on several routes is a split of its own and prints as one; - * otherwise its step's chain is walked with the start printed ahead of it. - */ private emitStartGroup( start: Extract, lines: string[], @@ -744,10 +667,8 @@ class Emitter { } /** - * Every unprinted start leaving for `step` alone prints right before it, - * back to back, since starts written back to back all enter the statement - * after them. Walked one at a time, the second start would find the step - * printed and jump onto it, which a jump cannot do for an elided end. + * Starts written back to back all enter the next statement. Walked one at a + * time, the second would `goto` the step, which fails for an elided end. */ private emitStartsInto(step: string, lines: string[]): void { for (const el of this.startsInto(step)) { @@ -772,53 +693,31 @@ class Emitter { } /** - * Whether a sequence flow runs from `from` to `to`, printed statement - * order included: a link throw's hop into its catch counts only where - * `hops` names the throw, meaning this same walk already printed that - * catch right behind the throw with no `goto` between them. Unlike - * {@link reachable}, a hop `hops` leaves out does not cross: this walk has - * not committed to printing that catch adjoining yet, so a start placed - * past it would print ahead of a `goto` this walk still owes (see - * {@link reachableOverFlow} for what the validator itself makes of an - * `emit link`). + * A link hop counts only for throws in `hops`, whose catch this walk already + * printed adjoining; unlike {@link reachable}, other hops are not crossed. */ private flowsOnTo( from: string, to: string, hops: ReadonlySet, ): boolean { - const seen = new Set(); - const queue = [from]; - for (let i = 0; i < queue.length; i++) { - const n = queue[i]!; - if (n === to) return true; - if (seen.has(n)) continue; - seen.add(n); - for (const f of this.outgoingBySource.get(n) ?? []) - queue.push(f.targetRef); - if (hops.has(n)) { - const linked = this.linkedCatch.get(n); - if (linked !== undefined) queue.push(linked); - } - } - return false; + return closure( + [from], + (n) => (hops.has(n) ? this.successors(n) : this.modelSuccessors(n)), + to, + ).has(to); } /** - * Every node a flow can carry a token to from one of the container's own - * entries: a start, or a node no flow enters, which is a link catch or a - * stranded fragment. A boundary event is an entry too, but its chain is the - * handler's own until an entry's chain reaches into it. A catch printed - * behind its throw is no entry: the walk that prints the throw owns it, - * which for a throw in a boundary body is the handler's walk. + * Nodes reachable from a start or an unentered node. Boundaries are excluded + * (the handler owns its chain), and so is a catch printed behind its throw. */ private reachableFromEntries(): Set { const entered = new Set([ ...this.container.sequenceFlows.map((f) => f.targetRef), ...this.linkedCatch.values(), ]); - const reached = new Set(); - const pending = this.container.flowElements + const entries = this.container.flowElements .filter( (el) => !isBoundary(el) && @@ -826,22 +725,24 @@ class Emitter { (el.kind === 'startEvent' || !entered.has(el.id)), ) .map((el) => el.id); - while (pending.length > 0) { - const id = pending.pop()!; - if (reached.has(id)) continue; - reached.add(id); - pending.push(...this.successors(id)); - } - return reached; + return closure(entries, (n) => this.successors(n)); } - /** The model's routes out of `id`, and the hop from a link throw into the catch printed behind it. */ + /** Includes the hop from a link throw into the catch printed behind it. */ private successors(id: string): string[] { - const next = (this.outgoingBySource.get(id) ?? []).map((f) => f.targetRef); + const next = this.modelSuccessors(id); const linked = this.linkedCatch.get(id); return linked === undefined ? next : [...next, linked]; } + private modelSuccessors(id: string): string[] { + return this.routesOut(id).map((f) => f.targetRef); + } + + private routesOut(id: string): SequenceFlow[] { + return this.outgoingBySource.get(id) ?? []; + } + private isGatewayId(id: string): boolean { const el = this.byId.get(id); return el !== undefined && isGateway(el); @@ -852,15 +753,14 @@ class Emitter { return this.isGatewayId(id) && this.successors(id).length === 1; } - /** The one route the model gives a node, or `undefined` where it splits or ends. */ private soleRoute(id: string): SequenceFlow | undefined { - const outs = this.outgoingBySource.get(id) ?? []; + const outs = this.routesOut(id); return outs.length === 1 ? outs[0] : undefined; } /** - * Nothing for a step, or for a gateway a head has already taken: a loop - * hands its leftover routes to the choice chain under its own id. + * A loop hands its leftover routes to a choice chain under its own id, so + * take once. */ private takeHeadSettings(id: string): string[] { const el = this.byId.get(id); @@ -872,13 +772,9 @@ class Emitter { } /** - * Only an unprinted merge with one route out, the shape the compiler - * synthesizes and lands the printed keys on again; a merge with a second - * route is a statement of its own, and one already printed carries them - * nowhere, which the sweep reports. Counted over the model's routes, not the - * unprinted ones: an enclosing loop spends the back-edge a merge at its - * body's tail leaves by before the body is walked, and that merge is still - * the pass-through it elides. + * Only an unprinted one-route merge, the shape the compiler re-synthesizes. + * Counts model routes, not unconsumed ones: an enclosing loop consumes a + * tail merge's back edge before the body is walked. */ private takeJoinSettings(join: string | undefined): string[] { if (join === undefined) return []; @@ -888,7 +784,7 @@ class Emitter { !isGateway(el) || this.emittedNodes.has(join) || this.settingsTaken.has(join) || - (this.outgoingBySource.get(join) ?? []).length !== 1 + this.routesOut(join).length !== 1 ) { return []; } @@ -901,10 +797,9 @@ class Emitter { } private unconsumedOut(id: string): SequenceFlow[] { - return this.unconsumed(this.outgoingBySource.get(id) ?? []); + return this.unconsumed(this.routesOut(id)); } - /** A handler carries no flow edges, so there is no fall-through continuation. */ private emitHandler( handler: Extract, lines: string[], @@ -922,11 +817,8 @@ class Emitter { } /** - * A boundary body is not a separate container: its nodes live in this - * container's arrays and share `emittedNodes`/`consumedFlows` with the main - * flow, so a rejoining chain degrades to a `goto` through the ordinary - * machinery. `depth` is the caller's, so a chain reached through a flow edge - * keeps spending the same {@link MAX_NESTING_DEPTH} budget. + * A boundary body shares this container's state, so a rejoining chain + * degrades to a `goto` like any other. */ private emitBoundaryHandler( boundary: Extract, @@ -942,10 +834,6 @@ class Emitter { lines.push('}'); } - /** - * Follows fall-through flow until `stop` (exclusive), a terminal, or a node - * unreachable structurally, which degrades to a `goto`. - */ private emitFrom( node: string | undefined, stop: string | undefined, @@ -957,18 +845,13 @@ class Emitter { return; } let current = node; - // Grows below as this walk prints a throw right into its catch; see - // flowsOnTo for what that lets it follow. const hops = new Set(); - // Bounded by the node count, against a malformed IR cycle. let guard = this.byId.size + 1; while (current !== undefined && current !== stop && guard-- > 0) { - // A start whose step runs into the elided end prints right before - // that step, keeping the end implicit at the tail. A start opens an - // entry of its own, so nothing else may flow into it: where the chain - // walked so far still runs on to the step, a `goto` closes that flow - // first. The elided end takes no `goto`, so a start entering it behind - // a live chain keeps its place after it. + // A start whose step reaches the elided end prints right before that + // step. Nothing may flow into a start, so a live chain reaching the step + // closes with a `goto` first, except into the elided end, which takes + // none. if ( depth === 0 && this.elidedTail !== undefined && @@ -1000,15 +883,10 @@ class Emitter { } /** - * Whether `current`, walked under `this.branch`, must print as a `goto` - * here rather than inline. An if-chain branch (`ownEntry` set) only - * forbids carrying an enclosing block's tail into it (see - * {@link reachesEnclosingJoin}); a `goto` into one is otherwise legal. It - * says nothing about an `await`/`parallel` ancestor the if-chain is nested - * in, whose branch a `goto` may never enter from outside: that ancestor is - * shadowed in `this.branch` while the if-chain is being walked, so it is - * looked up through `outer`, skipping any if-chain branches in between - * (they carry no such restriction of their own). + * An if-chain branch only forbids carrying an enclosing block's tail into it. + * The nearest `await`/`parallel` ancestor, shadowed by if-chain branches in + * `this.branch`, is found through `outer`: no `goto` may enter it from + * outside. */ private leavesInnermostBranch(current: string): boolean { const branch = this.branch!; @@ -1031,12 +909,10 @@ class Emitter { } /** - * Whether `node`, reached inside the `await` or `parallel` branch `route` - * opens, is also entered from outside it without leading back in. The - * validator refuses a `goto` into such a branch from outside, so the branch - * jumps to the node and the walk from outside prints it. A node leading - * into the block's `join` still ends the branch, which no placement outside - * it prints valid, so it stays. A node no token reaches enters nothing. + * Whether `node` is also entered from outside the branch without leading back + * in. The validator refuses a `goto` into a branch from outside, so the + * branch jumps out and the outside walk prints the node. A node leading into + * `join` stays in the branch: no outside placement is valid. */ private liesOutsideBranch( node: string, @@ -1060,10 +936,8 @@ class Emitter { } /** - * Whether `p` lies in an `await` or `parallel` branch that jumps to `entry` - * by {@link liesOutsideBranch}: the route out of the nearest fork or race - * above `p`, unless that route's branch holds `entry` too. The fork is not - * printed yet, so its own merge stands in for the join it will take. + * Whether `p` lies in a fork/race branch that must jump to `entry`. The fork + * is not printed yet, so its own merge stands in for its join. */ private jumpsOutOfBranch(p: string, entry: string): boolean { let route = this.forkRoute(p, entry); @@ -1080,24 +954,12 @@ class Emitter { } /** - * Whether `node`, reached in the if-chain branch `branch`, runs on into the - * join of a block enclosing it, or lies past that join: a `goto` into an - * if-chain branch is legal, so a step a sibling also enters prints wherever - * the walk meets it first, unless printing it here would carry code an - * enclosing block prints after this one into this branch. The walk stops - * at any step on a cycle through the branch's entry, a loop the branch - * prints itself rather than code to carry in, but runs through one only a - * later `goto` jumps back to, since that closes no loop. The same holds - * for an enclosing join: the walk from it stops at this branch's entry - * unless every route out of the join runs through the entry, or one loops - * back into it (see {@link wallStaysOff}), and a step whose routes all end - * on their own prints here rather than after an end the outermost block - * would then have to spell out. - * - * Every walk leaves out the enclosing block's split, since a loop around - * the block would otherwise pull every one of its nodes past the join. - * Only a merge gateway counts as a join reached, since a step there prints - * as this walk's own `goto`. + * Whether `node`, reached in an if-chain branch, runs into or lies past an + * enclosing block's join, so printing it here would carry that block's tail + * into the branch. Steps on a cycle through the branch entry are the + * branch's own loop and do not count. Walks exclude the enclosing split, or a + * loop around the block would pull every node past the join. Only a merge + * gateway counts as reaching a join. */ private reachesEnclosingJoin(node: string, branch: Branch): boolean { if (branch.join !== undefined) { @@ -1141,10 +1003,7 @@ class Emitter { return false; } - /** - * A predicate for whether `n` lies on a cycle through `entry`, inside - * `split`'s region: reachable from `entry`, and reaching back to it. - */ + /** Whether `n` lies on a cycle through `entry` inside `split`'s region. */ private cyclesThroughEntry( entry: string, split: string, @@ -1155,12 +1014,8 @@ class Emitter { } /** - * Whether a wall placed at `entry` against reaching `join` should stay - * off: `entry` post-dominates `join` (every route from `split` to `join` - * already runs through it, so walling it off would hide nothing), or a - * route from `join` runs back into `entry` over a predecessor on a cycle - * through it, so the flow past `join` reaches `entry` again regardless of - * the wall. `postDominates` is checked first, the cheaper test. + * A wall at `entry` hides nothing when `entry` post-dominates `join`, or + * when flow past `join` loops back into `entry` anyway. */ private wallStaysOff(entry: string, join: string, split: string): boolean { if (this.cfg.postDominates(entry, join)) return true; @@ -1176,7 +1031,6 @@ class Emitter { .some((p) => p !== entry && beforeEntry.has(p) && loops(p)); } - /** The route opening a branch of the fork or race `split` at `target`. */ private forkRoute(split: string, target: string): SequenceFlow | undefined { const kind = this.byId.get(split)?.kind; if ( @@ -1188,9 +1042,7 @@ class Emitter { ) { return undefined; } - return this.outgoingBySource - .get(split)! - .find((f) => f.targetRef === target); + return this.routesOut(split).find((f) => f.targetRef === target); } private emitNode( @@ -1203,21 +1055,20 @@ class Emitter { if (el === undefined) return STOP; this.branchOf.set(id, this.branchPath); - // A do-while body entry is reached before its loop gateway, so it has to be - // recognized here or the body prints ahead of the loop and degrades. + // A do-while body entry is reached before its loop gateway, so recognize it + // here or the body prints ahead of the loop and degrades. const doWhile = this.tryDoWhileEntry(id, stop, lines, depth); if (doWhile !== undefined) return doWhile; - // Read off the model's routes: a construct hands its leftover routes down - // as an empty list without the gateway being empty. - if (isGateway(el) && (this.outgoingBySource.get(id) ?? []).length === 0) { + // Model routes: a construct may hand down an empty leftover list. + if (isGateway(el) && this.routesOut(id).length === 0) { this.emittedNodes.add(id); this.warnings.push(emptySplitWarning(el.kind, id)); return STOP; } - // A gateway has no statement form to jump from, so it cannot rely on the - // final sweep: every out-edge is captured here, by a construct or a goto. + // A gateway has no statement to jump from, so the final sweep cannot cover + // it: every out-edge is captured here. if (el.kind === 'exclusiveGateway') { // Recognized before the if-chain so it is not mistaken for an XOR split. const loop = this.tryWhile(el, stop, lines, depth); @@ -1232,9 +1083,7 @@ class Emitter { } if (el.kind === 'subProcess') { - // An event sub-process prints in the trailing handler pass and is - // entered by its trigger alone; the import refuses a flow into one, so - // reaching it here is malformed IR, as a duplicate id is. + // The import refuses a flow into an event sub-process: malformed IR. if (el.triggeredByEvent === true) { throw new Error( `irToDsl: event sub-process '${id}' in container '${this.container.id}' is reached through a flow edge; a handler is entered by its trigger alone.`, @@ -1255,8 +1104,8 @@ class Emitter { return this.followLinear(id, stop, lines, depth); } - // A boundary has no inbound flow either; a malformed one prints the block - // here rather than losing the element. + // A malformed boundary with inbound flow prints here rather than being + // lost. if (isBoundary(el)) { this.emitBoundaryHandler(el, lines, depth); return STOP; @@ -1270,9 +1119,8 @@ class Emitter { el.eventDefinition === undefined && isElidedOnPrint(el, this.container) ) { - // The compiler never mints a synthesized end inside a branch or loop - // body, whose exit it wires to the join or loop head, so one there - // always prints; a top-level one is settled in `emit`. + // The compiler never mints an end inside a branch or loop body, so one + // there prints; a top-level one is settled in `emit`. if (depth > 0) { lines.push(...stmt); this.warnings.push(reservedNameWarning(id)); @@ -1288,14 +1136,11 @@ class Emitter { } /** - * One route is the fall-through; more is a fork with the statement as its - * split. `BpmnActivityBehavior.performOutgoingBehavior` leaves by every route - * whose condition holds or that carries none, and by the `default` alone when - * none was taken: the inclusive fork's rule, or with nothing weighed and no - * fallback the parallel fork's; one weighed route beside the fallback routes - * like a choice and keeps its `if`/`else`. Read off the model rather than off - * the routes left to print: a route an enclosing loop has already printed as - * its closing brace is one the step takes beside the others all the same. + * Several routes out of a step fork: Operaton's `performOutgoingBehavior` + * takes every route whose condition holds or that has none, and the default + * only when none was taken, i.e. inclusive semantics (parallel with no + * conditions or default). One condition beside the default acts as a choice. + * Reads model routes: one an enclosing loop already consumed still fires. */ private followLinear( id: string, @@ -1303,7 +1148,7 @@ class Emitter { lines: string[], depth: number, ): string | typeof STOP { - const outs = this.outgoingBySource.get(id) ?? []; + const outs = this.routesOut(id); if (outs.length <= 1) return this.emitChoice(id, stop, lines, depth); const fallbackId = this.splitFallbackFlowId(id); const weighed = outs.filter( @@ -1319,10 +1164,7 @@ class Emitter { return this.emitFork(id, kind, stop, lines, depth); } - /** - * Keyed on the id rather than on a gateway: a step whose own routes split - * reaches the same chain with no node of its own there. - */ + /** Keyed on the id: a step whose own routes split has no gateway node. */ private emitChoice( splitId: string, stop: string | undefined, @@ -1340,9 +1182,8 @@ class Emitter { } /** - * The routes are given as a list rather than read off the split, so the loop - * emitters can hand the routes their pattern did not spend to the same chain - * at the position after the closing line. + * Routes are passed in so loop emitters can hand over the ones they did not + * spend. */ private emitRoutes( splitId: string, @@ -1351,8 +1192,6 @@ class Emitter { lines: string[], depth: number, ): string | typeof STOP { - // Ahead of the two early returns: the report reads the model's routes, - // whatever prints. this.warnFallbackCondition(splitId); if (outs.length === 0) return STOP; @@ -1404,13 +1243,9 @@ class Emitter { } /** - * Whether `splitId` has, beside its last unprinted `route`, its default - * route running straight into `stop`, the way a gateway closing a `while` - * body routes back to the head: the block's end already stands for that - * route, so `route` keeps its `if` and a failed test falls through. An - * unconditioned route that is not the default can run alongside a - * conditioned one (an inclusive split, or a fork), so only the default is - * safe to leave unprinted this way. + * Whether the split's default runs straight into `stop` beside `route`, so + * the block end stands for it and `route` keeps its `if`. Only the default + * qualifies: another unconditioned route could fire alongside `route`. */ private leavesBesideStop( splitId: string, @@ -1419,7 +1254,7 @@ class Emitter { ): boolean { return ( route.conditionExpression !== undefined && - (this.outgoingBySource.get(splitId) ?? []).some( + this.routesOut(splitId).some( (o) => o !== route && o.targetRef === stop && @@ -1430,13 +1265,9 @@ class Emitter { } /** - * Every route gets a form: a conditioned one heads a branch with its - * condition, and an unconditioned one heads a branch as `true`, takes the one - * `else`, or, running straight into the join, prints as nothing. The `else` - * goes to the model's fallback first, which keeps the chain total where the - * model was; then to the route into the join, since a `true` head over that - * empty branch would leave the rest unreachable; then to the last - * unconditioned route, a second one being a shape the desugarer never writes. + * The `else` goes to the fallback, else the route into the join (a `true` + * head over that empty branch would make the rest unreachable), else the + * last unconditioned route; other unconditioned routes head as `true`. */ private emitIfChain( weighed: SequenceFlow[], @@ -1468,8 +1299,7 @@ class Emitter { lines.push(...this.emitIfBranch(f, join, splitId, depth)); }); - // The compiler lowers an empty `else` exactly as no `else`, so printing - // one would change on the next pass. + // The compiler lowers an empty `else` as none, so it is never printed. const elseBody = elseFlow === undefined ? [] @@ -1479,9 +1309,8 @@ class Emitter { } /** - * The fallback counts as unconditioned whatever it carries: heading its - * branch with the condition would put it on a run of its own and leave the - * split with nowhere to go when nothing holds. + * The fallback counts as unconditioned even with a condition, or nothing + * would catch the no-match case. */ private weighRoutes( splitId: string, @@ -1505,10 +1334,8 @@ class Emitter { } /** - * Every route leaves as a jump under its condition, each in a branch of its - * own since a jump ends its block. The split's kind is lost, which the marker - * names and {@link degradedSplitWarning} reports, and its settings with it, - * which the sweep reports. + * Each route becomes a jump in its own branch; the marker records the lost + * split kind. */ private emitJumps( splitId: string, @@ -1535,11 +1362,8 @@ class Emitter { } /** - * Whether getting from `from` to `to` jumps into a loop body past a head - * not printed yet. - * A loop statement prints ahead of its body, so until the head is out the - * jump stays a `goto`; after that the body is a chain like any other, - * ending on a jump back. + * A loop prints ahead of its body, so entering the body before its head + * prints needs a `goto`. */ private entersUnprintedLoop(from: string, to: string): boolean { return this.cfg @@ -1548,15 +1372,10 @@ class Emitter { } /** - * `undefined` when the split is unstructured. A join past `stop` is refused: - * inside a loop body the body dominates everything after the loop, so a - * split whose routes leave the loop and reconverge behind it would pass the - * dominance checks, walk the staying route past the printed loop head and - * drop the back edge. Every route must also run into the join: one that - * never reaches an end, such as a step looping on itself, has no - * post-dominators, and the split's own then speaks only for the routes that - * do end. A route into the body of a loop still to print is a jump (see - * {@link entersUnprintedLoop}), so no node of that body is a join. + * A join past `stop` is refused: in a loop body, routes reconverging behind + * the loop pass the dominance checks but would drop the back edge. Every + * route must reach the join, since a route that never ends has no + * post-dominators. */ private cleanJoin( splitId: string, @@ -1583,21 +1402,11 @@ class Emitter { } /** - * Where the routes come back together when the post-dominator queries - * cannot say: a branch that can end puts the split's post-dominator at the - * exit, and a step that loops on itself leaves the container with no - * post-dominators at all, so {@link cleanJoin} misses a merge the routes - * plainly share. - * - * Read off the model's routes, bounded by `stop`. A route straight into a - * sink never counts. The live routes are those reaching the block's exit - * (`stop`, or the elided end at the top of the container); where none does, - * those reaching a node another route reaches; where none does that either, - * all of them. The answer is the first node in the first live route's - * breadth order that every live route reaches: a merge ({@link isMerge}) - * ahead of a step on the way to it, since the compiler lowers every block - * with a merge; then the exit; then the nearest step no live route can end - * or leave before. + * Reconvergence where post-dominators cannot say (a branch that ends, or a + * self-loop leaving no post-dominators). Live routes: those reaching the + * exit, else those sharing a node with another route, else all. Picks the + * first node every live route reaches, preferring a merge, then the exit, + * then the nearest step no live route can end or leave before. */ private convergence( splitId: string, @@ -1622,22 +1431,15 @@ class Emitter { ? routes.filter(shares) : routes; if (live.length === 0) return undefined; - // A node every path into the split passes lies upstream of it, so - // continuing there is a jump back over the block, not its merge. `stop` - // is kept: a loop head dominates its body and is where the body ends. + // A node dominating the split lies upstream, so it is no merge; `stop` is + // kept, since a loop head dominates its body and ends it. const shared = [...live[0]!.reach].filter( (n) => live.every((r) => r.reach.has(n)) && (n === stop || !this.cfg.dominates(n, splitId)) && !this.entersUnprintedLoop(splitId, n), ); - // A merge only a hop-crossing route reaches, where something prints past - // it, is {@link ownJoin}'s to refuse for the same reason ({@link - // crossesHopInto}): the routes past it sink into the branch that carries - // them, found below by the farthest-step rule. Unlike ownJoin, which - // only needs one route to place its own merge, a shared merge candidate - // here has to hold for every live route, since one that cannot cross - // disqualifies `n` as their common join. + // Unlike ownJoin, every live route must pass the link-hop test. const merge = shared.find( (n) => this.isMerge(n) && @@ -1645,23 +1447,18 @@ class Emitter { ); if (merge !== undefined) return merge; if (exit !== undefined && shared.includes(exit)) { - // A branch's chain leaves the block through its join, so a lone route - // that runs into the elided end past no merge was written behind an - // authored end and jumped to; the guard clause keeps it that way. + // A lone route into the elided end past no merge was written as a jump + // behind an authored end; the guard clause keeps it that way. return live.length === 1 && stop === undefined ? undefined : exit; } - // A lone weighed route's whole chain is its branch, so it continues at the - // farthest step that holds rather than the nearest. + // A lone weighed route's whole chain is its branch: take the farthest step. const [only] = live; const weighed = live.length === 1 && only!.f.conditionExpression !== undefined && only!.f.id !== this.splitFallbackFlowId(splitId); - // A step every live route reaches is the merge only when none can end - // or leave for `stop` before it: two jumps into one step from a branch - // and from the chain after the block reach it as well, and the chain - // walked with the step as its stop prints nested inside the branch, - // which the next pass prints hoisted. + // A step is the merge only if no live route can end or reach `stop` + // before it; otherwise the next pass would print it hoisted, not nested. const leavesBefore = (entry: string, n: string): boolean => [...this.reachable(entry, splitId, n)].some( (m) => m !== n && (m === stop || this.successors(m).length === 0), @@ -1672,9 +1469,7 @@ class Emitter { } /** - * The nodes a walk from `from` can reach, in breadth order. `stop` is kept - * and not crossed, and the split is left out: a path back through it runs - * the routing again, which the block already stands for. + * Breadth order. `stop` is included but not crossed; the split is excluded. */ private reachable( from: string, @@ -1682,70 +1477,35 @@ class Emitter { stop: string | undefined, inRegion: (n: string) => boolean = () => true, ): Set { - const seen = new Set(); - const queue = [from]; - for (let i = 0; i < queue.length; i++) { - const n = queue[i]!; - if (n === splitId || seen.has(n)) continue; - seen.add(n); - if (n === stop || !inRegion(n)) continue; - queue.push(...this.successors(n)); - } - return seen; + return closure(from === splitId ? [] : [from], (n) => + n === stop || !inRegion(n) + ? [] + : this.successors(n).filter((m) => m !== splitId), + ); } /** - * The nodes reachable from `from` with the synthetic link-hop edge never - * taken: the validator reads an `emit link` as ending its path even where - * the matching `await link` prints right behind it, so a test or join - * reached only that way stays unreachable to it once printed, whatever - * {@link reachable} says. A nested split's own branches take no part in - * this walk; it jumps straight to that split's own merge instead, since a - * hop inside a nested split is that split's own to sink around, not a gap - * in this one's routing. `splitId` stops the walk from branching further - * the way {@link reachable} walls off its own split, but stays in the - * returned set here, where {@link reachable} leaves it out. + * Reachability without link hops: the validator treats `emit link` as ending + * its path even when the `await link` prints right behind it. A nested split + * is skipped to its own merge. Unlike {@link reachable}, includes `splitId`. */ private reachableOverFlow( from: string, splitId: string | undefined, ): Set { - const seen = new Set(); - const queue = [from]; - for (let i = 0; i < queue.length; i++) { - const n = queue[i]!; - if (seen.has(n)) continue; - seen.add(n); - if (n === splitId) continue; + return closure([from], (n) => { + if (n === splitId) return []; const nested = this.ownMerges.get(n); - if (nested !== undefined) { - queue.push(nested); - continue; - } - for (const f of this.outgoingBySource.get(n) ?? []) { - queue.push(f.targetRef); - } - } - return seen; + return nested !== undefined ? [nested] : this.modelSuccessors(n); + }); } - /** - * Whether a real statement prints somewhere past `n`: the elided tail and - * the gateways that carry a route to it print nothing of their own, so a - * merge whose only continuation is the process ending there is safe to - * place past a hop the plain way, unlike one a printed step follows. - */ private hasPrintableContinuation(n: string): boolean { return [...this.reachable(n, undefined, undefined)].some( (m) => !this.isGatewayId(m) && m !== this.elidedTail, ); } - /** - * Whether `m` counts as reached from `from`: always when nothing prints - * past `m`, otherwise only along a route that crosses no link hop (see - * {@link reachableOverFlow}). - */ private crossesHopInto( from: string, splitId: string | undefined, @@ -1758,36 +1518,28 @@ class Emitter { } /** - * Whether a walk from `from`, along routes {@link tryDoWhileEntry} has not - * yet consumed, reaches `target` with `wall` walled off. A consumed edge - * has already been given a place in the print and will not run again from - * here, so it does not carry the walk on; the wall keeps a candidate's own - * back edge from answering for itself the way {@link reachable}'s `splitId` - * keeps a block's own route from doing. + * Over unconsumed routes only: a consumed edge already has its place in the + * print. */ private reachesUnconsumed( from: string, wall: string, target: string, ): boolean { - const seen = new Set(); - const queue = [from]; - for (let i = 0; i < queue.length; i++) { - const n = queue[i]!; - if (n === wall || seen.has(n)) continue; - if (n === target) return true; - seen.add(n); - queue.push(...this.unconsumedOut(n).map((f) => f.targetRef)); - } - return false; + return closure( + from === wall ? [] : [from], + (n) => + this.unconsumedOut(n) + .map((f) => f.targetRef) + .filter((m) => m !== wall), + target, + ).has(target); } /** - * {@link reachable} up to where a route jumps out of the split's region, the - * nodes it dominates from the starts: the jump target is reached and - * nothing past it, since the block never runs on from there. A route into a - * gateway outside the region falls off the end of the block into an - * enclosing merge, which is no jump: a `goto` lands on a statement. + * Stops past the first node outside the split's region (a jump target). A + * gateway outside it is an enclosing merge, not a jump, so the walk + * continues. */ private reachableInRegion( from: string, @@ -1804,10 +1556,8 @@ class Emitter { } /** - * A guard clause has no clean join: one branch terminates while the - * unconditioned route carries the main flow, which prints after the `if` - * rather than as a bare `goto`. Reads the routes the chain leaves unweighed, - * so the fallback is the continuation whether it carries a condition or not. + * A guard clause: one branch terminates and the sole unconditioned route + * continues after the `if`. */ private guardClauseContinuation( unconditioned: SequenceFlow[], @@ -1816,14 +1566,9 @@ class Emitter { } /** - * A split inside a loop body or a branch whose every route is conditioned: - * a leaving route puts the split's post-dominator outside the region, so - * there is no clean join and no guard clause, yet the routes that stay run - * into `stop`, which is the continuation. Only the leaving routes print as - * jumps; as jumps throughout, the staying route's steps would be hoisted out - * of the loop and their edge into its folded head dropped. A route straight - * into `stop` stays, `postDominates` being reflexive, and prints as an empty - * branch. + * An all-conditioned split in a loop body or branch where some routes leave: + * the staying routes continue at `stop` and only the leaving ones jump. + * Jumping throughout would hoist the staying steps out of the loop. */ private enclosingContinuation( splitId: string, @@ -1841,18 +1586,9 @@ class Emitter { } /** - * Walk or `goto` is decided per branch, since branches mix: one can flow back - * to the join while another jumps away. - * - * The walk installs `this.branch` the way {@link emitBranch} does: a - * branch's route can dip past its own join into a step a sibling branch - * owns (a nested if's route skipping its own merge to rejoin a step the - * outer if's other branch already owns), and without `this.branch` set here - * {@link liesOutsideBranch} never runs for the plain continuation loop in - * {@link emitFrom}, so the walk keeps going straight past the branch's own - * join. `ownEntry` marks the branch as an if-chain one for - * {@link leavesInnermostBranch} (see the `branch` field's doc above for - * why that matters for a sibling's `goto`). + * Walk or `goto` is decided per branch. `this.branch` is installed because a + * route can dip past its join into a sibling's step, which only + * {@link leavesInnermostBranch} stops. */ private emitIfBranch( route: SequenceFlow, @@ -1865,7 +1601,7 @@ class Emitter { if (join === undefined) { this.pushGoto(entry, body); } else if (entry === join) { - // Empty branch (default -> join): no body. + // Empty branch: no body. } else if ( !this.emittedNodes.has(entry) && this.branchStaysInRegion(entry, join, splitId) @@ -1881,13 +1617,9 @@ class Emitter { } /** - * A jump at the statement the block continues with means the same as - * falling through the join, and it prints as an empty branch: printed as a - * `goto`, the next pass reads it as a route the join no longer enters and - * picks another continuation. Both routes run the same once they meet, so - * only the gateways before that point count, and only exclusive merges with - * no settings leave the route unchanged: a parallel or inclusive join there - * would count the empty branch's token where the jump bypassed it. + * A jump to where the block continues prints as an empty branch; a `goto` + * would make the next pass pick another continuation. Only plain exclusive + * merges may lie between: a parallel/inclusive join would count the token. */ private runsInto(entry: string, join: string): boolean { const fromEntry = this.plainChain(entry); @@ -1904,16 +1636,12 @@ class Emitter { ); } - /** - * `id` and the nodes after it through gateways with one outgoing flow and no - * settings, ending on the first node that is not one. - */ private plainChain(id: string): string[] { const chain: string[] = []; for (let n = id; !chain.includes(n);) { chain.push(n); const el = this.byId.get(n); - const outs = this.outgoingBySource.get(n) ?? []; + const outs = this.routesOut(n); if ( el === undefined || !isGateway(el) || @@ -1928,32 +1656,14 @@ class Emitter { } /** - * A route into the body of a loop still to print is a jump (see - * {@link entersUnprintedLoop}). - * - * An authored entry with a chain of its own is decided by the block's - * tail, once a predecessor the flow after the block also reaches (other - * than the split's own route) is ruled out: that predecessor is the - * block's own tail re-entering the chain, so it stays a `goto` regardless - * of naming. A jump hoists the chain behind everything walked from the - * starts; where that ends on the plain end the printer leaves out, the - * chain would push that end off the one position the compiler re-derives - * it at, so the chain prints inside the branch. Where the block ends on - * authored terminals and the container has no loop of its own (see - * {@link noRealLoop}), a chain carrying an unnamed throw, catch or end - * stays a `goto` at its authored scope instead: hoisting there leaves - * that id's structural coordinate alone, while inlining it is what would - * re-derive it differently. A chain built only of authored ids walks - * inline instead, since no id in it depends on where it prints. A - * container with a loop, or a chain that opens a do-while of its own, - * keeps the chain hoisted clear of it regardless of naming: hoisted, a - * `do` would follow a terminator, which the validator refuses although - * the `do` lowers to no node of its own. A chain that reaches the elided - * end is that tail itself and stays a `goto` too, since inside the branch - * the end would print under its reserved id, unless it is reached only - * through code the flow after the block runs and every route out of the - * join passes through the entry, or a loop after the block runs back into - * it (see {@link wallStaysOff}). + * Whether the branch walks `entry` inline rather than jumping. Jump when + * entering an unprinted loop or when a predecessor outside the branch also + * enters. Gateways, routes into a gateway join, synthesized ids and + * single-route ends walk inline. With an elided tail, walk inline unless the + * chain reaches that end (hoisting would push the end off the tail). With + * none, a chain opening a do-while walks inline (hoisted, its `do` would + * follow a terminator); a chain the block's tail re-enters jumps; others + * walk inline only outside every loop and where ids stay stable. */ private branchStaysInRegion( entry: string, @@ -1963,18 +1673,9 @@ class Emitter { if (this.entersUnprintedLoop(splitId, entry)) return false; if (this.cfg.postDominates(join, entry)) return true; const el = this.byId.get(entry); - // A predecessor an `await` or `parallel` branch jumps to, one with no - // loop-immediate-dominator (unreachable, entering nothing), or one - // reached only through a boundary handler's own re-entry (rooted beside - // it at the virtual entry, not inside this split's branches) does not - // count against ownership. Nor does a predecessor this split, or an - // enclosing branch's own split, dominates: a later `goto` back into this - // branch, or from a sibling branch, both of which a nested branch keeps - // as its own entry the same way an outermost one does. Nor does the - // back edge of a loop `entry` heads ({@link doWhileBackEdges} and the - // `ownLoopTest` clause below): a `goto` into the body stops `entry` - // loop-dominating that source, but the edge still closes the loop this - // branch prints. + // Predecessors that leave the branch its entry: unreachable, dominated by + // this or an enclosing if-chain split, a back edge of a loop `entry` heads, + // an `await`/`parallel` branch jumping here, or a boundary handler body. const splits = [splitId]; for (let b = this.branch; b?.ownEntry !== undefined; b = b.outer) { splits.push(b.route.sourceRef); @@ -2013,15 +1714,6 @@ class Emitter { const after = this.reachable(join, splitId, undefined); if (this.elidedTail === undefined) { if (this.doWhileBackEdges(entry).length > 0) return true; - // Past this point `entry` is weighed only as the head of its own named - // chain, decided by whether that chain's ids stay stable inline (see - // {@link namedChainKeepsIdsInline}). A predecessor other than the - // split's own route that the flow after the block also reaches is that - // block's tail re-entering `entry` directly, not a step this branch - // alone owns, so the chain stays a `goto` at its authored scope - // regardless of what the id check says: a gateway entry, a route into - // a gateway join, a synthesized id, or a single-route end has already - // returned above. if (this.cfg.incoming(entry).some((p) => p !== splitId && after.has(p))) { return false; } @@ -2043,16 +1735,9 @@ class Emitter { } /** - * Whether `splitId` sits outside every do-while the printer recognizes: a - * do-while keeps a leaving branch hoisted clear of its body regardless of - * naming, so only a split outside every real loop is safe for - * {@link namedChainKeepsIdsInline} to weigh. A cycle with no head - * {@link doWhileBackEdges} recognizes never prints as `while`/`do`, so it - * does not count, however many nodes it runs through. A gateway that both - * closes a do-while and branches the choice after it, one node doing - * double duty as the loop's own test and a post-loop `if`, is inside its - * own loop by this measure, so a chain leaving it stays hoisted the same - * way a chain leaving the loop body proper does. + * Whether `splitId` is outside every recognized do-while (a do-while keeps a + * leaving branch hoisted). A do-while test that also branches counts as + * inside. */ private noRealLoop(splitId: string): boolean { return !this.cfg @@ -2064,12 +1749,8 @@ class Emitter { } /** - * Whether `p` is reached only through a boundary handler's own body: its - * loop-immediate-dominator chain (see {@link CfgAnalysis.loopImmediateDominator}) - * reaches a boundary event before it bottoms out, since a node inside a - * handler's body is rooted at that boundary the same way an ordinary node - * is rooted at the process start. An event sub-process carries no outgoing - * flow of its own, so it can never itself be a predecessor here. + * A node in a handler body is dominated by its boundary event as others are + * by the start. */ private reachedOnlyThroughBoundary(p: string): boolean { for ( @@ -2084,18 +1765,10 @@ class Emitter { } /** - * A chain of its own, walked inline, re-derives the same id everywhere: no - * node past `entry` is a synthesized throw, catch or end, whose id comes - * from its coordinate. A node `entry` does not loop-dominate ends the - * walk: its own id is still checked, since the chain prints a `goto` to - * it, but nothing past it is, since that belongs to another branch's - * chain. A back edge does not carry this walk on: a - * `goto` back into earlier flow is the printed jump itself, not the chain's - * own continuation, and following it would pull in nodes an unrelated - * branch owns. A bare entry with nothing past it (`join` excluded, which - * prints at the same position either way) is not a chain: several such - * routes into one authored terminal share it, and hoisting keeps neither - * claiming it over the other. + * Inline keeps ids stable only if the chain holds no synthesized throw, + * catch or end, whose id comes from its position. The walk stops at nodes + * `entry` does not loop-dominate and at back edges. A bare entry is no + * chain: several routes may share one authored terminal. */ private namedChainKeepsIdsInline( entry: string, @@ -2103,19 +1776,13 @@ class Emitter { join: string, ): boolean { const backEdgeIds = new Set(this.cfg.backEdges().map((f) => f.id)); - const chain = new Set(); - const queue = [entry]; - for (let i = 0; i < queue.length; i++) { - const n = queue[i]!; - if (n === splitId || chain.has(n)) continue; - chain.add(n); - if (n === join || !this.cfg.loopDominates(entry, n)) { - continue; - } - for (const f of this.outgoingBySource.get(n) ?? []) { - if (!backEdgeIds.has(f.id)) queue.push(f.targetRef); - } - } + const chain = closure(entry === splitId ? [] : [entry], (n) => + n === join || !this.cfg.loopDominates(entry, n) + ? [] + : this.routesOut(n) + .filter((f) => !backEdgeIds.has(f.id) && f.targetRef !== splitId) + .map((f) => f.targetRef), + ); if ([...chain].every((n) => n === entry || n === join)) return false; return [...chain].every( (n) => @@ -2125,9 +1792,8 @@ class Emitter { } /** - * With no `join` the body runs to its own end, which is how a race with no - * merge prints. `branch` is the fork or race route this body prints under; - * a loop body passes none and stays in its enclosing branch. + * `branch` is the fork/race route; a loop body passes none and stays in its + * enclosing branch. */ private emitBranch( entry: string, @@ -2151,16 +1817,14 @@ class Emitter { this.branch = outerBranch; } - /** The post-loop continuation, or `undefined` when the pattern misses. */ private tryWhile( loop: Extract, stop: string | undefined, lines: string[], depth: number, ): string | typeof STOP | undefined { - // Unconditioned mirrors `tryDoWhileEntry`'s conditioned requirement, so the - // two patterns never both fire. A route from the head back into itself is - // no body: matched here it would print the exit route as the body. + // Unconditioned, unlike a do-while back edge, so the two never both fire. + // A self-loop is no body. const backEdge = this.unconsumed(this.cfg.backEdges()).find( (f) => f.targetRef === loop.id && @@ -2180,7 +1844,6 @@ class Emitter { const settings = this.takeHeadSettings(loop.id); lines.push(`while (${renderCondition(cond)})${headSettings(settings)} {`); - // The back-edge is consumed, so the body walk stops at the head. this.emitBranch(cond.targetRef, loop.id, lines, depth); lines.push('}'); @@ -2188,21 +1851,9 @@ class Emitter { } /** - * An exclusive gateway with a conditioned back-edge into `node`, the body - * between them held whole (see {@link enclosesBody}). The condition is what - * tells a post-test loop from a pre-test `while`, which would otherwise - * match here through its join-to-head edge. Operaton tests a gateway's - * conditioned routes in order and the printed `while (c)` reads back as the - * first, so a back edge tested after another route would move that route's - * test behind it. - * - * Loops nested at one body entry all return to `node`, the outermost - * opening first. With more than one candidate, its own test is the one - * whose other (non-back-edge) route never runs on to another candidate's - * gateway along still-unprinted flows: a continue's other route runs on to - * the real test, where the real test's other route leaves the loop instead. - * With zero or one, the filter below holds vacuously, so that candidate (or - * none) is already the answer. + * Nested loops at one body entry all return to `node`. The outermost test is + * the candidate whose other routes never reach another candidate over + * unconsumed flows; a `continue` runs on to the real test. */ private tryDoWhileEntry( node: string, @@ -2212,7 +1863,7 @@ class Emitter { ): string | typeof STOP | undefined { const candidates = this.doWhileBackEdges(node); const outermost = candidates.filter((f) => - (this.outgoingBySource.get(f.sourceRef) ?? []) + this.routesOut(f.sourceRef) .filter((o) => o.id !== f.id) .every((o) => candidates.every( @@ -2246,47 +1897,41 @@ class Emitter { const candidates = this.unconsumed(this.cfg.backEdges()).filter( (f) => f.targetRef === node && this.isDoWhileTest(f), ); - // Where `ownLoopTest` already names a distinct gateway as `node`'s own - // test, only a back edge from that gateway is a candidate: a leftover - // back edge elsewhere must not stand in once the real test is known. - // `node` naming itself, or no owner at all, leaves every candidate in. + // Once `ownLoopTest` names another gateway, only its back edge qualifies. const owner = this.cfg.ownLoopTest(node); return owner === undefined || owner === node ? candidates : candidates.filter((f) => f.sourceRef === owner); } - /** Whether the back edge `f` closes a `do` block, tested at its source. */ + /** + * The condition tells a do-while from a `while`'s join-to-head edge. Operaton + * tests conditioned routes in order and `while (c)` reads back as the first, + * so only the first conditioned route qualifies. + */ private isDoWhileTest(f: SequenceFlow): boolean { - // A route back into the head itself has no body to run before the test. if (f.sourceRef === f.targetRef) return false; if (f.conditionExpression === undefined) return false; - // A jump-cycle head closes on a `goto` past an enclosing loop's exit - // route, not on this gateway's own test, however conditioned it prints. + // A jump-cycle head closes on a `goto`, not on this gateway's test. if (this.cfg.isJumpCycleHead(f.targetRef)) return false; const head = this.byId.get(f.sourceRef); - const firstTest = this.outgoingBySource - .get(f.sourceRef) - ?.find((o) => o.conditionExpression !== undefined); + const firstTest = this.routesOut(f.sourceRef).find( + (o) => o.conditionExpression !== undefined, + ); return ( head?.kind === 'exclusiveGateway' && firstTest === f && this.enclosesBody(f.targetRef, head.id) && - // ownMerges is not built yet here (pairMerges is still finding it), so - // this needs flowsOnTo's plain BFS with no hops taken, not - // reachableOverFlow's nested-split shortcut. + // pairMerges calls this before ownMerges exists, so no reachableOverFlow. this.flowsOnTo(f.targetRef, head.id, new Set()) ); } /** - * Whether a `do` opening at `node` and tested at `test` holds its whole - * body. A goto into the body lands on a statement; a flow from outside into - * a gateway on the way to the test closes a block the loop would open - * inside, so the test would print past that block's end. A gateway past - * the test that the body reaches around it, and that the test's exit - * reaches without first jumping back into the body, merges a block the - * test sits inside of, so the `while` cannot close the loop there. + * Whether the `do` body nests cleanly: no gateway before the test is entered + * from outside the body (the test would print past that block's end), and + * no gateway the body bypasses the test to reach is also reached from the + * test's exit (the test sits inside that block). */ private enclosesBody(node: string, test: string): boolean { const body = this.reachable(node, test, test); @@ -2306,9 +1951,8 @@ class Emitter { } /** - * The routes the loop pattern did not spend, marked printed before the body - * is walked so a jump landing on the head reads the same routing it did - * before; {@link emitRoutes} takes them where the loop leaves off. + * Consumed before the body walk so a jump onto the head sees the same + * routing. */ private takeRest(outs: SequenceFlow[]): SequenceFlow[] { const rest = this.unconsumed(outs); @@ -2317,11 +1961,8 @@ class Emitter { } /** - * Both fork kinds print one `parallel` block: a head on any branch reads - * back as the fork that weighs its branches, none anywhere as the one that - * opens them all. Keyed on the split's id and kind rather than on a gateway: - * a step whose own routes split reaches the same block with the kind its - * routes give it, and its head settings are none. + * Both fork kinds print as `parallel`: a condition on any branch reads back + * as inclusive, none as parallel. Keyed on id, since a step can split too. */ private emitFork( splitId: string, @@ -2359,23 +2000,18 @@ class Emitter { this.warnInventedFallback(splitId); this.warnUnweighedBranchCondition(fork, outs); - // The fallback running straight into the merge is the one the reader gets - // back for free by leaving it out, so writing it would put an `else { }` - // in the script the model never had. Two branches is the fewest the block - // form takes, so it is only left out while two remain. + // A fallback straight into the merge is implicit; omit it while two + // branches remain, the block's minimum. const kept = outs.filter((f) => !this.isImplicitFallback(fork, f, join)); const branches = kept.length >= 2 ? kept : outs; this.warnDeadFallback(fork, branches); - // The join is continued from, never pre-elided: a one-out parallel join is - // a transparent pass-through in `emitNode`. const settings = [ ...this.takeHeadSettings(splitId), ...this.takeJoinSettings(join), ]; lines.push(`parallel${headSettings(settings)} {`); branches.forEach((f) => { - // `emitBranch` prefixes one INDENT; wrap and re-indent for `parallel {`. const branchLines: string[] = []; this.emitBranch(f.targetRef, join, branchLines, depth, f); lines.push(INDENT + this.branchHead(fork, f) + '{'); @@ -2388,12 +2024,9 @@ class Emitter { } /** - * A fork no merge reconverges prints as a block whose every branch runs to - * its own end, the form the compiler lowers without a join. That holds only - * while each route is a region of its own, entered through its head alone, - * that terminates or jumps to a printed node a `goto` can name: a `goto` - * may not enter a branch from outside it, and a route reaching the block's - * exit would pull the exit into its branch. + * A joinless fork prints only if each route is its own region, entered + * through its head alone, ending or jumping to a nameable printed node, and + * not reaching the block exit. */ private routesEndApart( splitId: string, @@ -2435,9 +2068,8 @@ class Emitter { } /** - * The fallback heads its branch as `else` whatever it carries: the fork - * weighs no condition on it, and printing one would put the branch on a run - * of its own. + * The fork weighs no condition on its fallback, so it heads as `else` + * regardless. */ private branchHead(fork: Fork, flow: SequenceFlow): string { if (fork.kind !== 'inclusiveGateway') return ''; @@ -2447,10 +2079,6 @@ class Emitter { : `if (${renderCondition(flow)}) `; } - /** - * A condition on it changes nothing, the fork weighing none on its fallback, - * so it stays implicit; {@link warnFallbackCondition} reports the condition. - */ private isImplicitFallback( fork: Fork, flow: SequenceFlow, @@ -2463,25 +2091,17 @@ class Emitter { ); } - /** - * Read off the model rather than from the caller's list: the engine weighs - * every route the element has, so one an enclosing construct has already - * printed keeps the element from running out of routes all the same. - */ + /** Reads model routes: the engine weighs every route, printed or not. */ private warnInventedFallback(splitId: string): void { - // A split that takes every route whatever the conditions say never runs out - // of routes, so there is no failure here for a fall-through to paper over. if (this.byId.get(splitId)?.kind === 'parallelGateway') return; if (this.splitFallbackFlowId(splitId) !== undefined) return; - const outs = this.outgoingBySource.get(splitId) ?? []; + const outs = this.routesOut(splitId); if (outs.some((f) => f.conditionExpression === undefined)) return; this.warnings.push(inventedFallbackWarning(splitId)); } /** - * The route a split takes when no condition holds, for one that names it: - * the two split kinds that read a condition, and every step, whose `default` - * `BpmnActivityBehavior.handleNoTransitions` takes. + * A step's `default` is honoured too, by Operaton's `handleNoTransitions`. */ private splitFallbackFlowId(splitId: string): string | undefined { const el = this.byId.get(splitId); @@ -2490,22 +2110,14 @@ class Emitter { return 'defaultFlowId' in el ? el.defaultFlowId : undefined; } - /** - * `undefined` for a split naming a route it does not have: the engine looks - * the name up among the split's routes and raises where it finds none. - */ private splitFallbackFlow(splitId: string): SequenceFlow | undefined { const fallbackId = this.splitFallbackFlowId(splitId); - return (this.outgoingBySource.get(splitId) ?? []).find( - (f) => f.id === fallbackId, - ); + return this.routesOut(splitId).find((f) => f.id === fallbackId); } /** - * The mirror of {@link warnInventedFallback}: a fallback nothing can reach, - * written out as an `else` the validator refuses, since the model is where - * the refusal comes from. Reads the branches that print: a fallback going - * nowhere but the merge leaves no `else` behind and nothing to report. + * A fallback beside an unconditioned branch is unreachable, and the validator + * refuses its `else`. */ private warnDeadFallback(fork: Fork, branches: SequenceFlow[]): void { if (fork.kind !== 'inclusiveGateway') return; @@ -2519,10 +2131,8 @@ class Emitter { } /** - * Reported once per split off the model's routes, however the fallback - * prints. A choice weighs its fallback among the others and the engine - * refuses to deploy one carrying a condition; a fork and a step skip it while - * weighing, so there the condition is weighed nowhere. + * The engine refuses to deploy a choice whose fallback has a condition; a + * fork or step ignores it. */ private warnFallbackCondition(splitId: string): void { if (this.splitFallbackFlow(splitId)?.conditionExpression === undefined) { @@ -2535,11 +2145,7 @@ class Emitter { ); } - /** - * Dropped by {@link branchHead}: a condition on a branch is what tells the - * weighing fork from the opening one, so printing it would read back as the - * other. - */ + /** A printed branch condition would read back as an inclusive fork. */ private warnUnweighedBranchCondition(fork: Fork, outs: SequenceFlow[]): void { if (fork.kind !== 'parallelGateway') return; if (!outs.some((f) => f.conditionExpression !== undefined)) return; @@ -2547,20 +2153,11 @@ class Emitter { } /** - * With a branch ending at a `throw` or `end` the fork has no clean - * post-dominator, but the survivors still reconverge at a merge of - * `joinKind`. Each branch contributes, nearest first, the fork-dominated - * merges of that kind on its post-dominator chain; branches of one fork share - * no node before reconverging, so the first candidate common to every chain - * is the join, and nearest keeps the continuation at this fork rather than in - * a sibling's nested block. `cur !== forkId` rejects a back-edge fork that - * post-dominates itself. Only a merge counts: a gateway with one route out, - * or one that more than one forward route enters and that splits again. A - * lone surviving chain otherwise offers a nested split, a do-while condition - * or a while head, and a branch walked to one prints its tail behind the - * block. A merge right behind a split on the chain is that split's own join, - * whatever the split's kind: taken as a candidate, a nested block in a branch - * that ends would outvote the one-in join the surviving branch runs into. + * When a branch ends, the fork has no post-dominator, yet survivors still + * reconverge. Each branch lists the fork-dominated `joinKind` merges on its + * post-dominator chain; the first common to all is the join. A merge right + * behind a nested split is that split's own and is skipped. Only real merges + * count, not loop heads or tests. */ private recoveredForkJoin( forkId: string, @@ -2575,7 +2172,7 @@ class Emitter { const seen = new Set(); while (cur !== undefined && !seen.has(cur)) { seen.add(cur); - const nested = (this.outgoingBySource.get(prev) ?? []).length > 1; + const nested = this.routesOut(prev).length > 1; if ( cur !== forkId && !nested && @@ -2599,17 +2196,14 @@ class Emitter { return undefined; } - /** More than one route enters `id` and none of them is a back edge. */ private mergesForward(id: string): boolean { const ins = this.cfg.incoming(id); return ins.length > 1 && !ins.some((p) => this.cfg.dominates(id, p)); } /** - * Every branch opens on a wait, and the pattern is keyed on the waits alone, - * so it holds with no merge to return to, the shape of a race whose every - * branch ends. A branch opening on anything else degrades, every edge keeping - * a jump or a marker. + * Keyed on the waits alone, so it holds without a merge; any non-wait branch + * degrades. */ private emitRaceGateway( race: Extract, @@ -2634,8 +2228,6 @@ class Emitter { return STOP; } - // Below the degradation above, whose own report already covers the - // conditions the jumps carry for the split it could not print. if (outs.some((f) => f.conditionExpression !== undefined)) { this.warnings.push(raceConditionWarning(race.id)); } @@ -2655,8 +2247,6 @@ class Emitter { this.emittedNodes.add(el.id); const branchLines: string[] = []; if (body !== undefined) { - // Nothing is written between the wait and its body, so `consume` - // reports a condition on the edge as on a fall-through. this.consume(body); this.emitBranch(body.targetRef, join, branchLines, depth, outs[i]); } @@ -2675,11 +2265,6 @@ class Emitter { return join === undefined ? STOP : this.continueAt(join, stop, lines); } - /** - * `undefined` where the branch is not a wait the block can hold: an - * already-printed one would print twice, and one that splits has no single - * body. Reads only, so a sibling missing costs nothing. - */ private raceWait(target: string): RaceWait | undefined { const el = this.byId.get(target); if (el?.kind !== 'intermediateCatchEvent') return undefined; @@ -2690,9 +2275,8 @@ class Emitter { } /** - * A route printed as plain flow, with no place between the two statements - * for its condition, which is reported. Every such route goes through here, - * so the report cannot be forgotten at one of them. + * Every route printed as plain flow goes through here, so its dropped + * condition is always reported. */ private consume(flow: SequenceFlow): void { this.consumedFlows.add(flow.id); @@ -2711,12 +2295,8 @@ class Emitter { } /** - * Which report a dropped condition takes turns on what reads it: a fork that - * opens every route and a wait weigh it nowhere, everywhere else the engine - * reads it. The fallback itself is covered by {@link warnFallbackCondition}. - * A route beside an unconditioned fallback diverts the run rather than - * failing it, since the model has the fallback left to leave by; a fallback - * the split weighs is reported on the split and does not count. + * Beside an unconditioned fallback a dropped condition diverts the run rather + * than failing it. */ private droppedConditionWarning( flow: SequenceFlow, @@ -2740,12 +2320,8 @@ class Emitter { } /** - * Returning a merge does not elide it: a synthesized join has one out-edge - * left once the branch edges are consumed, so `emitNode` passes through it - * and prints nothing, while a real node at the merge prints its statement. - * A printed pass-through that runs into `stop` is fallen through, not jumped - * past: a second branch reaching a merge the first one walked is still in - * its block. + * A printed pass-through running into `stop` is fallen through, not jumped: + * it is still in its block. */ private continueAt( target: string, @@ -2762,9 +2338,8 @@ class Emitter { } /** - * The single jump site every other routes through, so "a `goto` never names a - * gateway" holds in one place. A jump into a gateway that still has a choice - * cannot be named at all, so that edge is dropped and marked. + * The single jump site: a `goto` never names a gateway; one still holding a + * choice drops the edge. */ private pushGoto(target: string, lines: string[]): void { const real = this.forwardToRealTarget(target, new Set()); @@ -2778,15 +2353,9 @@ class Emitter { } /** - * The first node with a statement form, the only kind a `goto` can name: a - * jump into a gateway re-runs its routing, so it means the same as a jump at - * the successor whenever that routing has one outcome. The walk crosses the - * single unrealized out-edge, or once all are realized the sole out-edge of a - * gateway that never had a choice, since consumption records that an edge - * printed as structured flow, not that it stopped existing. `undefined` for - * a routing with more than one outcome, a revisited gateway, or a node the - * emitter drops on print; an unknown id is named verbatim, being a dangling - * IR reference. + * A jump into a gateway with one outcome equals a jump to its successor. + * Crosses the single unconsumed out-edge, else the sole edge of a gateway + * that never had a choice (consumed means printed, not gone). */ private forwardToRealTarget( target: string, @@ -2799,7 +2368,7 @@ class Emitter { } if (seen.has(target)) return undefined; seen.add(target); - const outs = this.outgoingBySource.get(target) ?? []; + const outs = this.routesOut(target); const unconsumed = this.unconsumed(outs); let forward: SequenceFlow | undefined; if (unconsumed.length === 1) forward = unconsumed[0]; @@ -2809,9 +2378,8 @@ class Emitter { } /** - * Omitted for a synthesized id: the forward compiler re-derives the same - * `Throw_...`/`Catch_...` from the statement's coordinate, so dropping it is - * lossless. + * A synthesized id is omitted: the compiler re-derives it from the + * statement's position. */ private terminalNameSuffix( el: Extract< @@ -2824,7 +2392,6 @@ class Emitter { : ` ${nameOf(this.names, el.id)}`; } - /** `undefined` when the element has no statement form. */ private renderStatement(el: FlowElement): Lines | undefined { switch (el.kind) { case 'startEvent': @@ -2887,9 +2454,7 @@ class Emitter { return renderCallActivity(el, this.names); case 'scriptTask': return renderScriptTask(el, this.names); - // Printed by `emitNode` (a gateway as a construct, a sub-process and a - // boundary as a block); listed so the type checker still catches a new - // kind. + // Printed by `emitNode`; listed for exhaustiveness. case 'subProcess': case 'boundaryEvent': case 'exclusiveGateway': @@ -2909,10 +2474,8 @@ class Emitter { const STOP = Symbol('stop'); -/** The two kinds a `parallel` block prints: one weighs its branches, one takes them all. */ type ForkKind = 'parallelGateway' | 'inclusiveGateway'; -/** A split the fork block prints: a gateway of either kind, or a step whose routes give it one. */ interface Fork { id: string; kind: ForkKind; @@ -2926,16 +2489,14 @@ const SPLIT_KIND_WORD: Record = { eventBasedGateway: 'event-based', }; -/** One branch of a race: the wait it opens on, and the edge into its body. */ interface RaceWait { el: Extract; body?: SequenceFlow; } /** - * Printed where an edge the emitter can neither name nor place would have gone, - * instead of fabricating a target; the warning is the report, the marker the - * reader's pointer at the place needing repair. + * Printed where an edge that cannot be named or placed would have gone, instead + * of inventing a target. */ export const UNSTRUCTURED_MARKER = '// unstructured region: hand-repair required'; @@ -2959,10 +2520,6 @@ function droppedEdgeWarning(target: string): PrintWarning { }; } -/** - * An edge with no name to jump to takes {@link droppedEdgeWarning}'s marker - * instead, which is why this one counts no branches. - */ function degradedSplitWarning(splitId: string): PrintWarning { return { elementId: splitId, @@ -2981,23 +2538,21 @@ function degradedSplitWarning(splitId: string): PrintWarning { function emptySplitWarning(kind: Gateway['kind'], id: string): PrintWarning { const model = { - // `ParallelGatewayActivityBehavior.execute` leaves by no route and - // `PvmExecutionImpl.leaveActivityViaTransitions` ends the execution: the - // whole run at top level, one branch inside a fork. + // Operaton ends the execution: the whole run at top level, one branch in a + // fork. parallelGateway: "The engine ends the run here too, so outside a fork's branch the " + 'process runs the same without it; inside one, only that branch ends ' + 'and the fork never completes.', - // `InclusiveGatewayActivityBehavior.execute` throws `stuckExecutionException`. + // Operaton throws `stuckExecutionException`. inclusiveGateway: 'The engine stops the run with an error here, where the script ends ' + 'it, so what runs is not the same.', - // `EventBasedGatewayActivityBehavior.execute` is a wait state, and with - // nothing to wait for the instance never leaves it. + // A wait state with nothing to wait for never completes. eventBasedGateway: 'The engine waits here forever, where the script ends the run, so what ' + 'runs is not the same.', - // `BpmnParse.validateExclusiveGateway`: "has no outgoing sequence flows". + // Operaton's `BpmnParse` rejects it: "has no outgoing sequence flows". exclusiveGateway: 'The engine refuses to deploy the model as drawn, while the script ' + 'deploys and ends the run here.', @@ -3011,7 +2566,6 @@ function emptySplitWarning(kind: Gateway['kind'], id: string): PrintWarning { }; } -/** Written and reported rather than dropped: the model is where the route comes from. */ function crossBranchJumpWarning(target: string): PrintWarning { return { elementId: target, @@ -3148,31 +2702,31 @@ function droppedSettingWarning(gatewayId: string): PrintWarning { }; } -/** A pathological IR degrades to a `goto` rather than overflowing the stack. */ +/** Deeper nesting degrades to a `goto` rather than overflowing the stack. */ const MAX_NESTING_DEPTH = 1000; -/** - * In print order after {@link namedSettings}; the IR field name is the DSL key. - * Exported so a test can hold it against `PROCESS_HEADER_KEYS`, since a key the - * vocabulary gains and this table lacks would round-trip with no report. - */ -export const PROCESS_HEADER_SETTINGS = [ - ['versionTag', quoteLiteral], - ['historyTimeToLive', quote], - ['candidateStarterUsers', quote], - ['candidateStarterGroups', quote], -] as const; +/** Keyed by the vocabulary, so a new header key fails to compile here. */ +const PROCESS_HEADER_RENDER: Record< + (typeof PROCESS_ENGINE_HEADER_KEYS)[number], + (value: string) => string +> = { + versionTag: quoteLiteral, + historyTimeToLive: quote, + candidateStarterUsers: quote, + candidateStarterGroups: quote, +}; function buildProcessHeader(process: BpmnProcess, names: PrintNames): string { const settings: string[] = namedSettings(process); - for (const [key, render] of PROCESS_HEADER_SETTINGS) { + for (const key of PROCESS_ENGINE_HEADER_KEYS) { const value = process[key]; - if (value !== undefined) settings.push(setting(key, render(value))); + if (value !== undefined) { + settings.push(setting(key, PROCESS_HEADER_RENDER[key](value))); + } } return `process ${nameOf(names, process.id)}${parens(settings)} {`; } -/** Handlers carry no flow edges and print at the end of their container's body. */ function isHandler( el: FlowElement, ): el is Extract { @@ -3180,26 +2734,16 @@ function isHandler( } /** - * The link throws whose catch prints right behind them, mapped to that catch. - * The compiler lowers `emit link("X") await link("X")` inside a branch or a - * handler body as a throw with no route out and a catch no flow enters, whose - * chain carries on to a position that exists only inside the block. - * - * A throw owns its catch when the catch's chain, walked until it meets the - * throw's block, meets a node no `goto` can name: a gateway of the block, a - * merge the run of one-in steps out of the catch reaches without having opened - * the split it closes (the one-in join of an enclosing block, which the - * compiler keeps where every other branch ends), or the minted end of a - * boundary body holding the throw. A chain that meets a step instead - * jumps back by name, as the authored idiom of a catch after the process `end` - * does, and keeps its place among the entries, unless the tail rule in `owns` - * links it. + * Link throws whose catch prints right behind them. The compiler lowers + * `emit link("X") await link("X")` in a branch as a dead-end throw and an + * unentered catch whose chain continues inside the block. * - * The block is read off the throw's dominators, taken with the catches left - * out: the analysis enters a graph at every link catch, which would let a - * catch's chain bypass the very split that frames the throw. A catch two - * throws own stays an entry: it prints once, so it can follow only one of - * them, and every shape predicate has to agree on which. + * A throw owns its catch when the catch's chain meets a node no `goto` can + * name: a gateway of the throw's block, a merge reached on the catch's one-in + * run without opening its split, or a boundary body's minted end. A chain + * meeting a step jumps back by name and stays an entry, unless the tail rule + * links it. Dominators are computed without the catches, since the analysis + * enters at each one. A catch owned by two throws stays an entry. */ function linkedCatches( container: FlowContainer, @@ -3219,9 +2763,6 @@ function linkedCatches( } } const catchIds = new Set(catches.values()); - // No link catches to own means no throw can be linked either, so skip the - // two `analyzeCfg` passes below: every container without a link pair would - // otherwise pay for them. if (catchIds.size === 0) return new Map(); const cfg = analyzeCfg({ ...container, @@ -3237,14 +2778,13 @@ function linkedCatches( incoming.set(f.targetRef, (incoming.get(f.targetRef) ?? 0) + 1); } const next = (id: string): string[] => outgoing.get(id) ?? []; - // A loop's gateways open and close the loop itself, never a block around it. + // A loop's gateways open and close the loop, never a block around it. const backEdges = analyzeCfg(container).backEdges(); const loopGateways = new Set( backEdges.flatMap((f) => [f.sourceRef, f.targetRef]), ); - // A do-loop's back edge lands on its own first body step, not a gateway, so - // that step still shows two incoming flows in `incoming`. Its forward-only - // count is what decides whether the step stays on this chain's run. + // A do-loop's back edge lands on its first body step, so count forward edges + // only. const forwardIn = (id: string): number => (incoming.get(id) ?? 0) - backEdges.filter((f) => f.targetRef === id).length; @@ -3257,23 +2797,15 @@ function linkedCatches( : undefined; return hop === undefined ? next(id) : [hop]; }; - // Starts print before catch entries, so a start reaching the elided tail - // over plain flows claims that position first regardless of what the - // catch's own chain reaches. - const tailFromStarts = (() => { - if (elidedTail === undefined) return false; - const seen = new Set(); - const queue = container.flowElements - .filter((el) => el.kind === 'startEvent') - .map((el) => el.id); - while (queue.length > 0) { - const n = queue.pop()!; - if (seen.has(n)) continue; - seen.add(n); - queue.push(...next(n)); - } - return seen.has(elidedTail); - })(); + // Starts print before catch entries, so a start reaching the tail claims it. + const tailFromStarts = + elidedTail !== undefined && + closure( + container.flowElements + .filter((el) => el.kind === 'startEvent') + .map((el) => el.id), + next, + ).has(elidedTail); const owns = (throwId: string, catchId: string): boolean => { const dominators = new Set(); @@ -3284,20 +2816,16 @@ function linkedCatches( ) { dominators.add(d); } - const block = new Set(); - const pending = [...dominators].flatMap(next); - while (pending.length > 0) { - const n = pending.pop()!; - if (block.has(n) || dominators.has(n)) continue; - block.add(n); - pending.push(...next(n)); - } + const outOfDominators = (n: string) => + next(n).filter((m) => !dominators.has(m)); + const block = closure( + [...dominators].flatMap(outOfDominators), + outOfDominators, + ); const walk = (step: (id: string) => string[]) => { const chain = new Set(); - // Each entry carries the splits the chain has opened and not yet - // closed, or undefined once it has left the run of one-in steps out of - // the catch, where a goto could have named its way in. + // Open-split count, or undefined once off the catch's one-in run. const first = next(catchId); const queue: [string, number | undefined][] = first.map((n) => [ n, @@ -3334,18 +2862,13 @@ function linkedCatches( return { chain, unnameable }; }; const own = walk(next); - // The tail rule: where the container ends on the plain end the printer - // leaves out, a chain that never reaches that end links too. Printed as an - // entry, it would push that end off the tail, the one position the - // compiler re-derives it at. A chain that does reach the end is still an - // entry printed after it when a start already reaches that end: the start - // prints first and claims the tail, so this chain's own arrival there - // would still push it off. + // Tail rule: with an elided end, a catch chain printed as an entry would + // push the end off the tail, unless the chain itself is what reaches the + // end and no start does first. const displacesTail = elidedTail !== undefined && (!own.chain.has(elidedTail) || tailFromStarts); - // A chain ending in another throw rejoins the block through that throw's - // catch, wherever that catch prints. + // A chain ending in another throw continues through that throw's catch. return own.unnameable || displacesTail || walk(onward).unnameable; }; @@ -3366,7 +2889,10 @@ function linkedCatches( ); } -/** `container` with each throw -> catch hop drawn as a flow, which also stops the analysis entering at that catch. */ +/** + * Each throw -> catch hop drawn as a flow, which also stops the analysis + * entering at the catch. + */ function withLinkHops( container: FlowContainer, linked: ReadonlyMap, @@ -3384,7 +2910,6 @@ function withLinkHops( }; } -/** A boundary event has outgoing but no incoming flow, so a dedicated pass prints it. */ function isBoundary( el: FlowElement, ): el is Extract { @@ -3392,11 +2917,8 @@ function isBoundary( } /** - * The statement-order rank `id` would claim on the next compile if it were - * minted from `base` by {@link resolveCollision}: 1 for `base` itself, n for - * `base_n`. An authored id ranks after every minted one: the print drops it, - * so it is minted afresh on the next compile, and printed ahead of a minted - * sibling it would take that sibling's id. + * 1 for `base`, n for `base_n`. An authored id ranks last: the print drops it, + * so printed first it would take a minted sibling's id on the next compile. */ function boundaryIdRank(id: string, base: string): number { if (id === base) return 1; @@ -3404,10 +2926,8 @@ function boundaryIdRank(id: string, base: string): number { return /^[2-9]\d*$/.test(suffix) ? Number(suffix) : Infinity; } -/** The definitions an `end` spells in its own head rather than raising; the word printed is the kind. */ const END_CARRIED_KINDS = new Set(END_TRIGGERS); -/** Read by the print and by the elision, so the two cannot drift. */ function isEndCarried( def: EventDefinition, ): def is Extract { @@ -3415,15 +2935,10 @@ function isEndCarried( } /** - * Whether the id is absent from the printed form, leaving a `goto` nothing to - * resolve against; `xmlToIr` asks the same question to report the label an - * elided start or end takes with it, so the two answers cannot drift apart. - * For a plain end the answer is only that the printer may drop it, `emitNode` - * and `Emitter.emit` deciding by position whether it does, and - * `forwardToRealTarget` reads this half alone, so a jump into such an end is - * dropped and marked whether or not the end ends up printed. The compiler - * re-derives a dropped start at the body's head and nowhere else, so only the - * first plain start under `container`'s minted id can go. + * Whether the id is absent from the print, so no `goto` can name it; shared + * with `xmlToIr`. For a plain end it means only that the printer may drop it, + * which position decides. Only the first plain start can go: the compiler + * re-derives a start only at the body's head. */ export function isElidedOnPrint( el: FlowElement, @@ -3441,24 +2956,17 @@ export function isElidedOnPrint( ); case 'endEvent': if (!isSynthesizedTerminalId(el.id, el.kind, container)) return false; - // The compiler cannot re-derive a definition the `end` statement - // carries, so that always prints, and `end`'s mandatory `name=ID` means - // the synthesized id prints too. A `name` is the same: nothing but this - // statement can carry it, so it forces the print too, unlike a plain - // start (`isPlainUnnamed`), which drops a name at import instead. + // A carried definition or a `name` has no other home, so it forces the + // print. if (el.eventDefinition === undefined) return el.name === undefined && !carriesPrintableContent(el); - // Every other definition prints as a `throw`, which drops a synthesized name. return !isEndCarried(el.eventDefinition); case 'intermediateThrowEvent': case 'intermediateCatchEvent': - // Spelled through `terminalNameSuffix`, which drops a synthesized id. return isSynthesizedTerminalId(el.id, el.kind, container); case 'boundaryEvent': - // Prints as `on : `, keyed on the host. return true; case 'subProcess': - // An event sub-process prints as an `on` header, an ordinary one its id. return el.triggeredByEvent === true; case 'userTask': case 'serviceTask': @@ -3471,7 +2979,6 @@ export function isElidedOnPrint( case 'parallelGateway': case 'inclusiveGateway': case 'eventBasedGateway': - // A gateway never writes its id. return true; default: { const exhaustive: never = el; @@ -3482,17 +2989,16 @@ export function isElidedOnPrint( } } -/** A start with nothing of its own to print, so nothing is lost by dropping it. */ +/** + * A start with nothing of its own to print, so nothing is lost by dropping it. + */ function isPlainUnnamed( el: Extract, container: PrintContainer, startTriggerSuppressed: boolean, ): boolean { - // A trigger has nowhere else to print, so a start carrying one always - // prints. Inside an event sub-process the trigger prints in the `on` - // header instead, and the emitter suppresses it here; a timer head also - // carries the start's timer-job settings, so those alone do not make the - // start print either. + // A trigger forces the print unless the `on` header carries it, along with + // a timer start's job settings. if (el.eventDefinition !== undefined && !startTriggerSuppressed) return false; const own = startTriggerSuppressed && el.eventDefinition?.kind === 'timer' @@ -3505,12 +3011,8 @@ function isPlainUnnamed( } /** - * Every reader goes through {@link isElidedOnPrint}: a reason to print - * reaching one reader but not another would leave a jump naming a statement - * never emitted. A start's label is not such a reason: printing the id to - * carry one writes a name the validator rejects, so `isPlainUnnamed` reports - * it on import instead; an end's label has nowhere else to go, so its caller - * checks it separately. + * Read only through {@link isElidedOnPrint}, or a jump could name a statement + * never emitted. Labels are handled by the callers. */ function carriesPrintableContent( el: Extract, @@ -3518,7 +3020,6 @@ function carriesPrintableContent( return jobSettingItems(el).length > 0 || startOrEndMembers(el).length > 0; } -/** The form block leads the members every element shares. */ function startOrEndMembers( el: Extract, ): Lines[] { @@ -3529,12 +3030,11 @@ function startOrEndMembers( return [...form, ...structuredMembers(el)]; } -/** In print order. A kind-specific member is placed around this list. */ function structuredMembers(el: SettingsCarrier): Lines[] { return [...ioParameters(el), ...listenerMembers(el)]; } -/** In the vocabulary's order, so the parens stay stable across runs; `keyOf` respells each key for a second carrier sharing the parens. */ +/** `keyOf` respells each key for a second carrier sharing the parens. */ function jobSettingItems( el: JobSettings, keyOf: (key: string) => string = (key) => key, @@ -3554,11 +3054,7 @@ function jobSettingItems( ); } -/** - * Own settings, then the loop's under the `run` spellings (see - * `LoopCharacteristics` in `ir/types.ts`); guarded by {@link repeats} like - * `repeatClause`, so a loop with nothing to repeat over prints nothing. - */ +/** Own settings, then the loop's under the `run` spellings. */ function engineSettings(el: EngineAttributes & Repeatable): string[] { return [ ...jobSettingItems(el), @@ -3566,11 +3062,7 @@ function engineSettings(el: EngineAttributes & Repeatable): string[] { ]; } -/** - * Ahead of the io parameters wherever both print, since a field configures the - * implementation the head names. `structuredMembers` cannot place them: it - * reads an element's own lists and a field hangs off the binding. - */ +/** Fields hang off the binding, so `structuredMembers` cannot place them. */ function fieldMembers(binding: ServiceTaskBinding | ListenerBinding): Lines[] { const fields = carriesFields(binding) ? (binding.fields ?? []) : []; return fields.map((field) => [ @@ -3578,7 +3070,7 @@ function fieldMembers(binding: ServiceTaskBinding | ListenerBinding): Lines[] { ]); } -/** Inputs before outputs, each in IR order, which the engine evaluates in. */ +/** IR order is the engine's evaluation order. */ function ioParameters(el: IoMapped): Lines[] { const members: Lines[] = []; for (const param of el.inputParameters ?? []) { @@ -3595,11 +3087,8 @@ function ioParameters(el: IoMapped): Lines[] { } /** - * Text takes the quoting every string-valued attribute uses, so a `${...}` body - * re-parses as a raw expression. A map key always quotes, since the bare - * spelling is an identifier token and a key reading as a keyword would not lex; - * it is the `STRING` the grammar's `MapKey` takes, never an expression, so it - * quotes as prose. + * A map key is a plain `STRING` in the grammar, never an expression, so it + * quotes as a literal. */ function renderIoValue(value: IoValue): string { switch (value.kind) { @@ -3625,8 +3114,8 @@ function renderIoValue(value: IoValue): string { } /** - * Execution listeners before lifecycle listeners. The two event vocabularies - * are disjoint, so the event word alone tells them apart on the way back in. + * The two listener event vocabularies are disjoint, so the event word tells + * them apart on re-parse. */ function listenerMembers(el: SettingsCarrier): Lines[] { const members = (el.executionListeners ?? []).map((listener) => @@ -3658,10 +3147,6 @@ function renderListener( ); } -/** - * `delegate` is the DSL alias for `operaton:delegateExpression`. An expression - * quotes verbatim, its `${...}` wrapper being part of the value. - */ function renderCodeBinding(binding: CodeBinding): string { switch (binding.kind) { case 'class': @@ -3680,21 +3165,16 @@ function renderCodeBinding(binding: CodeBinding): string { } /** - * The closing fence sits directly after `code` with no injected newline: the - * split on the way in strips only the newline after the language tag, so a - * newline added here would be re-absorbed into the body on re-parse. + * No newline before the closing fence: the parser would absorb it into the + * body. */ function renderFence(format: string, code: string): string { return `\`\`\`${format}\n${code}\`\`\``; } /** - * An id carrying the form its own kind is minted with, which the validator - * refuses in authored source: the exact start and end minted for `container` - * (and for a boundary escape in it), and the positional `Throw_`/`Catch_` - * prefixes no modeler writes. An id carrying another kind's template is an - * authored name and keeps printing; an end answers to the throw prefix too, - * since `throw` lowers to an end event, and a catch to its own prefix only. + * An id in its own kind's minted form, which the validator refuses in source. + * An end also matches the throw prefix, since `throw` lowers to an end event. */ function isSynthesizedTerminalId( id: string, @@ -3721,7 +3201,6 @@ function isSynthesizedTerminalId( } } -/** An authored id prints so it survives as a goto target; `nameSuffix` is {@link Emitter.terminalNameSuffix}'s. */ function renderThrow( def: Exclude, nameSuffix: string, @@ -3752,10 +3231,8 @@ function renderThrow( } /** - * Engine attributes come off the sub-process, plus the timer job's off the - * start when that start prints no statement: the compiler puts them on the - * start a timer head synthesizes ({@link splitTimerJobSettings}), and a start - * that prints keeps them on its own line. + * An elided start's timer-job settings lift into the header, where the compiler + * puts them back. */ function buildOnHeader( handler: Extract, @@ -3786,7 +3263,6 @@ function buildOnHeader( ); } -/** `attachedToRef` prints under the host's name; refusing a bad host belongs to validation. */ function buildBoundaryHeader( boundary: Extract, names: PrintNames, @@ -3803,12 +3279,6 @@ function buildBoundaryHeader( ); } -/** - * The payload leads the items, so a name, a code and a duration are written - * the same way wherever a trigger is; a catch binding follows, and the - * element's own settings after that. A condition prints unquoted, being an - * expression rather than text. - */ function renderTrigger( def: EventDefinition, names: PrintNames, @@ -3863,15 +3333,10 @@ function renderTrigger( } } -/** Absent for a catch-all, which names no code and so carries no payload. */ function payloadItem(code: string | undefined): string[] { return code === undefined ? [] : [quote(code)]; } -/** - * A code is written as the name of the declaration carrying it, which is what - * lets `error OrderFailed(code: "order.failed")` be raised by a word. - */ function codeItem( names: ReadonlyMap, code: string | undefined, @@ -3879,10 +3344,6 @@ function codeItem( return code === undefined ? [] : [names.get(code) ?? code]; } -/** - * A duration is the payload a timer writes bare; a date and a cycle take the - * particle naming them as their key. - */ function renderTimerTrigger(def: Extract): { head: string; items: string[]; @@ -3895,7 +3356,6 @@ function renderTimerTrigger(def: Extract): { }; } -/** `code: x, message: y`. Only an error carries a message binding. */ function eventBindingSettings( def: Extract, ): string[] { @@ -3909,7 +3369,6 @@ function eventBindingSettings( return parts; } -/** A flag is bare and closes the parens, after the payload and the settings. */ function alongsideFlag(nonInterrupting: boolean): string[] { return nonInterrupting ? ['alongside'] : []; } @@ -3938,7 +3397,6 @@ function renderStartEvent( ); } -/** The label leads the assignment settings, and the form block leads the members. */ function renderUserTask( el: Extract, names: PrintNames, @@ -3970,7 +3428,7 @@ function renderUserTask( ); } -/** The braces are written even with no fields, `form` alone being no rule. */ +/** Braces even with no fields: `form` alone does not parse. */ function renderFormBlock(formFields: FormField[]): Lines { return [ 'form {', @@ -3982,7 +3440,6 @@ function renderFormBlock(formFields: FormField[]): Lines { ]; } -/** `: "`; - /** Every child of the form field, at the path each direction reads it from. */ function formFieldSnapshot(process: ModdleElement): unknown { const [formData] = extensionValues(elementsOf(process)[0]); const [field] = formData.get('fields') as ModdleElement[]; @@ -330,7 +313,6 @@ describe('every declared type parses with zero warnings, every child at its expe
`; - /** Every child `xmlToIr` reads, at the path it reads it from. */ function externalTaskSnapshot(process: ModdleElement): unknown { const task = elementsOf(process)[0]; const [propertiesEl, definitionEl] = extensionValues(task); @@ -385,7 +367,6 @@ describe('every declared type parses with zero warnings, every child at its expe EVERY_TYPE_EXPECTED, ], [ - // A single-valued `definition` property would keep only the map. 'InputOutputParameterDefinition is isMany: a parameter carrying a list and a map keeps both, in document order', LIST_AND_MAP_FIXTURE, (process: ModdleElement) => @@ -393,10 +374,7 @@ describe('every declared type parses with zero warnings, every child at its expe ['operaton:List', 'operaton:Map'], ], [ - // `Entry` subclasses `InputOutputParameterDefinition`, so it satisfies - // both `List.items` and `InputParameter.definitions`; refusing a stray - // entry outside a map is the importer's job, so the descriptor keeps it - // where the importer can see it. + // Refusing an entry outside a map is the importer's job, so the descriptor keeps it visible. 'operaton:entry is accepted inside a list and directly under an input parameter', ENTRY_FIXTURE, (process: ModdleElement) => { @@ -458,8 +436,6 @@ describe('every declared type parses with zero warnings, every child at its expe }, ], [ - // Drop the `extends` entry for the loop element and these typed reads - // answer `undefined`, the raw attributes sitting in `$attrs` instead. 'AsyncCapable extends the multi-instance element, so a repetition carries per-run job settings and its retry child', LOOP_SETTINGS_FIXTURE, (process: ModdleElement) => { @@ -535,8 +511,6 @@ describe('per-element attribution of a dropped extension child', () => { const { process, warnings } = await parseProcess(operatonModdle(), fixture); - // The surviving child is one this descriptor introduces, so the assertion - // measures the declaration rather than what `bpmn-moddle` already knew. expect(extensionValues(elementsOf(process)[0]).map((v) => v.$type)).toEqual( ['operaton:FailedJobRetryTimeCycle'], ); @@ -578,9 +552,7 @@ describe('a potential starter declares the formal expression BpmnParse.parseStar 'user(a), group(g)', ]); - // moddle writes the subtype through `xsi:type`, which the engine's - // `Element.element("formalExpression")` does not find by tag; that is why - // the import respells the starter into the two attributes. + // The engine finds formalExpression by tag, not through this xsi:type, so the import respells the starter. const { xml } = await moddle.toXML(definitions, { format: false }); expect(xml).toContain( '' + @@ -642,8 +614,6 @@ describe('a timer definition declares the lock BpmnParse.parseTimer reads off it const [start, wait] = elementsOf(process); const definitionOf = (el: ModdleElement): ModdleElement => (el.get('eventDefinitions') as ModdleElement[])[0]; - // Drop the type extending the definition and `get` answers `undefined` - // for both, the raw attribute sitting in `$attrs` instead. expect([ definitionOf(start).get('exclusive'), definitionOf(wait).get('exclusive'), diff --git a/packages/transform/test/synthesize-ids.test.ts b/packages/transform/test/synthesize-ids.test.ts index 222daba..ca821ab 100644 --- a/packages/transform/test/synthesize-ids.test.ts +++ b/packages/transform/test/synthesize-ids.test.ts @@ -1,9 +1,4 @@ -/** - * The templates are frozen by ADR 0010, Use Deterministic Structural Ids for - * Synthesized BPMN Elements, and the validator reserves every form they mint - * from a pattern list it spells itself, so each row pins the exact id and - * that `isReservedName` refuses it. - */ +// The templates are frozen (ADR 0010) and the validator reserves their forms from its own pattern list. import { describe, expect, it } from 'vitest'; import { isReservedName } from '@bpmn-script/language'; @@ -83,9 +78,7 @@ describe('every template mints its documented form, which the validator reserves new Set(['Boundary_Pack_timer', 'Boundary_Pack_timer_2']), ), ], - // A boundary escape's end and a handler body's own start: minted off a - // coordinate no container name reaches, so the validator reserves the - // prefix rather than the exact form (no `container` argument here). + // Minted off a coordinate no container name reaches, so reserved by prefix. [ 'EndEvent_Boundary_Pack_timer', () => makeEndEventId('Boundary_Pack_timer', new Set()), @@ -97,14 +90,10 @@ describe('every template mints its documented form, which the validator reserves ])('%s', (expected, make, container) => { const id = make(); expect(id).toBe(expected); - // The validator's package cannot import these templates, so this row is - // what keeps its pattern list and the templates the same set. expect(isReservedName(id, container)).toBe(true); if (container !== undefined) expect(isReservedName(id)).toBe(false); }); - // The suffixed id minted past a taken name is an ordinary name: the - // validator does not reserve it, and the printer does not elide it. it('the minted start and end are recognized exactly, per container', () => { const boundaries = ['Boundary_Pack_error']; expect( diff --git a/packages/transform/test/xml-to-ir.test.ts b/packages/transform/test/xml-to-ir.test.ts index 8e2ea5a..bf87c04 100644 --- a/packages/transform/test/xml-to-ir.test.ts +++ b/packages/transform/test/xml-to-ir.test.ts @@ -1,16 +1,6 @@ -/** - * `xmlToIr` against real BPMN XML. Content the IR has no slot for is dropped - * with a warning; content it cannot express without changing what runs is - * refused with an `UnsupportedConstructError` before any IR is produced. - */ - -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; -import { EmptyFileSystem } from 'langium'; -import { parseHelper } from 'langium/test'; -import { createBpmnScriptServices } from '@bpmn-script/language'; -import type { Model } from '@bpmn-script/language'; import { fileURLToPath } from 'node:url'; import { @@ -23,7 +13,7 @@ import { xmlToIr, } from '../src/xml-to-ir.js'; import type { ImportWarning } from '../src/xml-to-ir.js'; -import { HISTORY_TIME_TO_LIVE, irToXml } from '../src/ir-to-xml.js'; +import { irToXml } from '../src/ir-to-xml.js'; import { irToDsl } from '../src/ir-to-dsl.js'; import { UnsupportedCallActivityError, @@ -45,40 +35,28 @@ import { } from '../src/errors.js'; import type { BpmnProcess, - CallActivity, EventDefinition, FlowElement, FormField, - IntermediateCatchEvent, - ListenerBinding, ServiceTaskBinding, - VersionBinding, } from '../src/ir/types.js'; import { isGateway } from '../src/ir/types.js'; import { expectRefusal } from './helpers/expect-refusal.js'; import { boundaryEvent, - around, builtinBinding, - chained, - chainedSub, classBinding, conditionDef, delegateBinding, errorDef, escalationDef, - eventSubProcess, exprBinding, externalBinding, HANDWRITTEN_IMPORT_IR, ioParam, listValue, - mapEntry, - mapValue, messageDef, minimalProcess, - processIr, - scriptValue, signalDef, textValue, timerDef, @@ -89,7 +67,6 @@ import type { XmlTag } from './helpers/bpmn-doc.js'; import { bpmnDefs, bpmnDoc, - camundaDefs, camundaDoc, dualDefs, dualDoc, @@ -100,7 +77,8 @@ import { operatonDoc, } from './helpers/bpmn-doc.js'; import { importById, importOnly } from './helpers/import-node.js'; -import { byId, only, subProcess } from './helpers/ir-query.js'; +import { byId, subProcess } from './helpers/ir-query.js'; +import { parse } from './helpers/parse.js'; const here = dirname(fileURLToPath(import.meta.url)); const HANDWRITTEN_XML = readFileSync( @@ -108,23 +86,12 @@ const HANDWRITTEN_XML = readFileSync( 'utf-8', ); -// Only the tests that print DSL (the compensation-rewrite preview, the -// none-throw rewrite, the boundary-escape label) re-parse it; every other -// test asserts against the IR alone. -let parse: ReturnType>; - -beforeAll(() => { - const services = createBpmnScriptServices(EmptyFileSystem); - parse = parseHelper(services.BpmnScript); -}); - const expectParses = async (source: string): Promise => { const doc = await parse(source, { validation: true }); expect(doc.parseResult.parserErrors).toEqual([]); expect(doc.diagnostics ?? []).toEqual([]); }; -/** The rewrite a compensation refusal proposes: the whole text, and it re-parses. */ const expectRewrite = async ( e: UnsupportedEventFeatureError, lines: readonly string[], @@ -137,15 +104,9 @@ const expectRewrite = async ( await expectParses(`process Preview {\n${rewrite}\n}\n`); }; -/** Only the warnings reporting dropped Operaton/camunda extension content. */ const extensionWarnings = (warnings: ImportWarning[]): ImportWarning[] => warnings.filter((w) => w.category === 'extensionAttribute'); -/** Only the warnings reporting BPMN content the transform does not map. */ -const unmappedWarnings = (warnings: ImportWarning[]): ImportWarning[] => - warnings.filter((w) => w.category === 'unmappedConstruct'); - -/** One expected warning, matched on its message; a dropped extension unless told otherwise. */ const warning = ( elementId: string, message: RegExp, @@ -156,10 +117,6 @@ const warning = ( message: expect.stringMatching(message), }); -/** - * The report every setting no IR node reads draws: the tag it sat on, and - * the engine method that does read it there, when one does. - */ const unread = ( subject: string, id: string, @@ -177,11 +134,6 @@ const CAMUNDA_ALIAS_WARNING = { message: CAMUNDA_ALIAS_MESSAGE, }; -/** - * Exactly one warning, naming the element and the drop. The count is half the - * contract: a drop reported twice, or against a clean element, is as wrong as - * one never reported. - */ const expectOneWarning = ( warnings: ImportWarning[], expected: { @@ -204,7 +156,6 @@ const expectOneWarning = ( return warning; }; -/** Root declarations, then a process opening on `` and continuing with `body`. */ const rootedDoc = (roots: string, body: string, defs = bpmnDefs): string => defs`${roots} @@ -212,25 +163,25 @@ ${body} `; describe('xmlToIr: canonical handwritten file', () => { - it('imports to the canonical IR, with the diagram shapes, the incoming/outgoing wiring and the derivable process name left out, and no warnings', async () => { - const { ir, warnings } = await xmlToIr(HANDWRITTEN_XML); - expect(ir).toEqual(HANDWRITTEN_IMPORT_IR); - expect(warnings).toEqual([]); - }); -}); - -describe('xmlToIr: camunda: prefix alias', () => { - it('parsing the same file with camunda: prefixes yields the same IR', async () => { - const camundaXml = HANDWRITTEN_XML.replace( - /xmlns:operaton="http:\/\/operaton\.org\/schema\/1\.0\/bpmn"/g, - 'xmlns:camunda="http://camunda.org/schema/1.0/bpmn"', - ).replace(/operaton:/g, 'camunda:'); - - const { ir } = await xmlToIr(camundaXml); - expect(ir).toEqual(HANDWRITTEN_IMPORT_IR); - }); + it.each([ + ['operaton:', HANDWRITTEN_XML, []], + [ + 'camunda:', + HANDWRITTEN_XML.replace( + /xmlns:operaton="http:\/\/operaton\.org\/schema\/1\.0\/bpmn"/g, + 'xmlns:camunda="http://camunda.org/schema/1.0/bpmn"', + ).replace(/operaton:/g, 'camunda:'), + [{ ...CAMUNDA_ALIAS_WARNING, elementId: HANDWRITTEN_IMPORT_IR.id }], + ], + ])( + 'the %s spelling imports to the canonical IR, without diagram shapes, wiring or a derivable process name', + async (_prefix, xml, expected) => { + const { ir, warnings } = await xmlToIr(xml); + expect(ir).toEqual(HANDWRITTEN_IMPORT_IR); + expect(warnings).toEqual(expected); + }, + ); - /** A user task carrying an attribute and three extension elements, and a call activity carrying an in mapping. */ const constructsBody = ( prefix: 'operaton' | 'camunda', ): string => ` @@ -279,545 +230,144 @@ describe('xmlToIr: camunda: prefix alias', () => { expectConstructsImported(ir); expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); }); - - it('a dual-prefix document imports both spellings with the same one warning', async () => { - const mixedBody = ` - - - - - 42 - - R3/PT10M - - - - - - - - - - - `; - - const { ir, warnings } = await xmlToIr(dualDoc`${mixedBody}`); - expectConstructsImported(ir); - expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); - }); - - it('a document declaring xmlns:camunda on the task element rather than the root imports the same way', async () => { - const xml = bpmnDoc` - - - - - - - - `; - - const { ir, warnings } = await xmlToIr(xml); - const task = byId(ir, 'T'); - expect(task.kind === 'userTask' && task.assignee).toBe('alice'); - expect(task.kind === 'userTask' && task.taskListeners).toEqual([ - { event: 'create', binding: classBinding('com.example.C') }, - ]); - expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); - }); - - it('an operaton: document draws no namespace warning', async () => { - const { ir, warnings } = await xmlToIr( - operatonDoc`${constructsBody('operaton')}`, - ); - expectConstructsImported(ir); - expect(warnings).toEqual([]); - }); - - it('the camunda URI inside a documentation body is text, not a declaration: kept verbatim and no warning', async () => { - const { ir, warnings } = await xmlToIr( - operatonDoc` - - See http://camunda.org/schema/1.0/bpmn for the schema. - - - - `, - ); - expect(byId(ir, 'T')).toEqual({ - kind: 'task', - id: 'T', - documentation: 'See http://camunda.org/schema/1.0/bpmn for the schema.', - }); - expect(warnings).toEqual([]); - }); }); describe('xmlToIr: service task binding forms', () => { - const importServiceTask = (attrs: string, doc = operatonDoc) => - importOnly(oneNodeDoc('serviceTask', { attrs, doc }), 'serviceTask'); - - it.each([ - [ - 'operaton:class', - 'operaton:class="com.example.Svc"', - classBinding('com.example.Svc'), - ], - [ - 'operaton:expression, carrying the raw text', - 'name="Expr Task" operaton:expression="${someBean.execute(execution)}"', - exprBinding('${someBean.execute(execution)}'), - ], - [ - 'operaton:delegateExpression', - 'name="Delegate Task" operaton:delegateExpression="${myDelegate}"', - delegateBinding('${myDelegate}'), - ], - [ - 'operaton:type="external" with a topic', - 'name="Ship It" operaton:type="external" operaton:topic="shipping"', - externalBinding('shipping'), - ], - [ - 'operaton:type="External", which runs the external worker as it does in the engine', - 'operaton:type="External" operaton:topic="shipping"', - externalBinding('shipping'), - ], - ] as const)( - 'a lone %s imports to the binding it names, reporting nothing', - async (_title, attrs, binding) => { - const { node, warnings } = await importServiceTask(attrs); - expect(node.binding).toEqual(binding); - expect(warnings).toEqual([]); - }, - ); - - it('operaton:type="external" WITHOUT a topic stays refused', async () => { - await expect( + it('an external type without a topic refuses', async () => { + await expectRefusal( xmlToIr(oneNodeDoc('serviceTask', { attrs: 'operaton:type="external"' })), - ).rejects.toBeInstanceOf(UnsupportedServiceTaskFormError); + UnsupportedServiceTaskFormError, + ); }); - it.each([ - [ - 'a topic beside a class names no worker the engine reaches', - 'operaton:class="com.example.Svc" operaton:topic="shipping"', - classBinding('com.example.Svc'), - "The 'topic' setting on 'T' has no effect alongside operaton:class and was not imported.", - ], - [ - 'an expression beside a class is never reached', - 'operaton:class="com.example.Svc" operaton:expression="${someBean.execute(execution)}"', - classBinding('com.example.Svc'), - "The 'expression' setting on 'T' has no effect alongside operaton:class and was not imported.", - ], - [ - 'a delegateExpression outranks an expression, as it does in the engine', - 'operaton:expression="${someBean.execute(execution)}" operaton:delegateExpression="${myDelegate}"', - delegateBinding('${myDelegate}'), - "The 'expression' setting on 'T' has no effect alongside operaton:delegateExpression and was not imported.", - ], - [ - 'an external type outranks every code attribute, as it does in the engine', - 'operaton:class="com.example.Svc" operaton:type="external" operaton:topic="shipping"', - externalBinding('shipping'), - "The 'class' setting on 'T' has no effect alongside operaton:type=\"external\" and was not imported.", - ], - ] as const)( - 'the binding the engine resolves wins and the rest are reported: %s', - async (_title, attrs, binding, message) => { - const { node, warnings } = await importServiceTask(attrs); - expect(node.binding).toEqual(binding); - expect(warnings.map((w) => w.message)).toEqual([message]); - }, - ); - - it.each(['sendTask', 'businessRuleTask'] as const)( - 'a bpmn:%s reports the passed-over attribute the same way', - async (tag) => { - const { node, warnings } = await importOnly( - oneNodeDoc(tag, { - attrs: 'operaton:class="com.example.Svc" operaton:topic="shipping"', - }), - 'serviceTask', - ); - expect(node.binding).toEqual(classBinding('com.example.Svc')); - expect(warnings.map((w) => w.message)).toEqual([ - "The 'topic' setting on 'T' has no effect alongside operaton:class and was not imported.", - ]); - }, - ); - - it('the refusal names every code attribute the type shadows', async () => { + it('a connector refuses as a connector', async () => { const e = await expectRefusal( xmlToIr( oneNodeDoc('serviceTask', { - attrs: - 'operaton:class="com.example.Svc" operaton:delegateExpression="${d}" operaton:type="External"', + children: extensionElements( + 'http-connector', + ), }), ), UnsupportedServiceTaskFormError, ); expect(e.construct).toBe( - 'operaton:type="External" without an operaton:topic, which Operaton ' + - 'resolves ahead of the operaton:class and operaton:delegateExpression ' + - 'alongside it', - ); - }); - - it('a task naming nothing at all is refused with no shadowing clause', async () => { - const e = await expectRefusal( - xmlToIr(oneNodeDoc('serviceTask', { attrs: 'name="Bare"' })), - UnsupportedServiceTaskFormError, - ); - expect(e.construct).toBe('no execution discriminator'); - expect(e.message).toContain('no execution discriminator'); - expect(e.message).toContain( - 'Supported forms are a Java class, an expression, a delegate expression, ' + - 'an external task topic, a built-in mail or shell task with its ' + - 'fields, or, on a business rule task, a decision reference.', + 'an element, which the Connect plugin runs in ' + + 'place of whatever operaton:class, expression, delegateExpression, or ' + + 'type names beside it, and which an engine without the plugin runs ' + + 'instead of, so the same file has two possible executions', ); + expect(e.subject).toBe('Service task'); }); +}); - /** The connector construct sentence every row below must refuse with. */ - const CONNECTOR_CONSTRUCT = - 'an element, which the Connect plugin runs in ' + - 'place of whatever operaton:class, expression, delegateExpression, or ' + - 'type names beside it, and which an engine without the plugin runs ' + - 'instead of, so the same file has two possible executions'; - - const connectorChild = (prefix: 'operaton' | 'camunda'): string => - ` <${prefix}:connector><${prefix}:connectorId>http-connector` + - ``; +describe('xmlToIr: script task support', () => { + const scriptTask = (attrs: string, children = '') => + oneNodeDoc('scriptTask', { id: 'ST', attrs, children, doc: bpmnDoc }); it.each([ [ - 'a connector alone refuses, no longer as "no execution discriminator"', - 'serviceTask', - '', - 'operaton', - 'Service task', - ], - [ - 'a connector beside operaton:class refuses the connector, not the class', - 'serviceTask', - 'operaton:class="com.example.Svc"', - 'operaton', - 'Service task', - ], - [ - 'a connector beside operaton:type="external" and a topic refuses the connector', - 'serviceTask', - 'operaton:type="external" operaton:topic="shipping"', - 'operaton', - 'Service task', - ], - [ - 'the deprecated camunda:connector prefix beside a class refuses alike', - 'serviceTask', - 'camunda:class="com.example.Svc"', - 'camunda', - 'Service task', - ], - [ - 'a bpmn:sendTask carrying a connector refuses, named as a send task', - 'sendTask', + 'a missing scriptFormat imports as juel, naming the engine default', '', - 'operaton', - 'Send task', - ], - [ - 'a bpmn:businessRuleTask naming a decision and a connector refuses on the connector', - 'businessRuleTask', - 'operaton:decisionRef="dec1"', - 'operaton', - 'Business rule task', - ], - ] as const)('%s', async (_title, tag, attrs, prefix, subject) => { - const e = await expectRefusal( - xmlToIr( - oneNodeDoc(tag, { - attrs, - children: extensionElements(connectorChild(prefix)), - doc: prefix === 'camunda' ? camundaDoc : operatonDoc, - }), - ), - UnsupportedServiceTaskFormError, - ); - expect(e.construct).toBe(CONNECTOR_CONSTRUCT); - expect(e.subject).toBe(subject); - }); -}); - -describe('xmlToIr: parallel gateway support', () => { - it('a fork and a join import with their labels, and no condition is invented on the branches', async () => { - const { ir, warnings } = await xmlToIr( - bpmnDefs` - - - - - - - - - - - - - `, - ); - - expect(ir).toEqual( - processIr( - 'parallel-proc', - [ - { kind: 'startEvent', id: 'Start' }, - { kind: 'parallelGateway', id: 'Fork', name: 'Fork' }, - // "Branch A" is humanize("BranchA"), so the label is derivable. - { kind: 'userTask', id: 'BranchA' }, - { kind: 'userTask', id: 'BranchB' }, - { kind: 'parallelGateway', id: 'Join', name: 'Join' }, - { kind: 'endEvent', id: 'End' }, - ], - [ - { id: 'F_Start_Fork', sourceRef: 'Start', targetRef: 'Fork' }, - { id: 'F_Fork_A', sourceRef: 'Fork', targetRef: 'BranchA' }, - { id: 'F_Fork_B', sourceRef: 'Fork', targetRef: 'BranchB' }, - { id: 'F_A_Join', sourceRef: 'BranchA', targetRef: 'Join' }, - { id: 'F_B_Join', sourceRef: 'BranchB', targetRef: 'Join' }, - { id: 'F_Join_End', sourceRef: 'Join', targetRef: 'End' }, + 'x = 1;', + { format: 'juel' }, + "The script on 'ST' has no scriptFormat; " + + '`BpmnParse.parseScriptTaskElement` substitutes ' + + '`ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE` (juel), and it was ' + + 'imported as such.', + ], + [ + 'a scriptFormat outside the alias table is carried as written', + 'scriptFormat="cobol"', + '1', + { format: 'cobol' }, + "The script on 'ST' names the language 'cobol', which the DSL " + + 'has no fence alias for; it was imported as written, and the ' + + 'printed script draws an error there.', + ], + [ + 'a self-closing bpmn:script imports as an empty body, since ScriptUtil.getScript deploys it', + 'scriptFormat="javascript"', + '', + { code: '' }, + "The body of the script on 'ST' is empty: ScriptUtil.getScript " + + 'deploys it, since it checks the source for null and not for ' + + 'emptiness, and the printed script draws an empty-body error there.', + ], + [ + 'a self-closing operaton:script on a listener imports as an empty body, since ScriptUtil.getScript deploys it', + 'scriptFormat="javascript"', + ` + + + + + x = 1;`, + { + code: 'x = 1;', + executionListeners: [ + { + event: 'start', + binding: { kind: 'script', format: 'groovy', code: '' }, + }, ], - ), - ); - expect(warnings).toEqual([]); - }); -}); - -describe('xmlToIr: script task support', () => { - const scriptTaskDoc = (body: string, attrs = '') => - oneNodeDoc('scriptTask', { - id: 'ST', - attrs: `scriptFormat="javascript" ${attrs}`, - children: `${body}`, - doc: bpmnDoc, - }); - - it('bpmn:scriptTask imports to a scriptTask IR carrying scriptFormat and the entity-decoded body', async () => { - const { node } = await importOnly( - scriptTaskDoc( - 'total = price < quantity && ok;', - 'name="Compute total"', - ), - 'scriptTask', - ); - expect(node.format).toBe('javascript'); - expect(node.code).toBe('total = price < quantity && ok;'); - expect(node.name).toBe('Compute total'); - }); - - it('a deployment resource refuses instead of importing an empty script, in the same words as the operaton:script spelling', async () => { - const resource = 'deployment://check.groovy'; - const e = await expectRefusal( - xmlToIr( - oneNodeDoc('scriptTask', { - id: 'ST', - attrs: `scriptFormat="javascript" operaton:resource="${resource}"`, - children: '1 + 1', - }), - ), - UnsupportedExtensionFormError, - `the script on 'ST' names an external resource ("${resource}"); ` + - 'ScriptUtil.getScript runs the resource in place of the body, and ' + - 'only an inline body can be written here', - ); - expect(e.elementId).toBe('ST'); - - // The listener spelling of the same attribute, which already refused: - // both must throw the identical sentence, so the two cannot drift apart. - const listenerErr = await expectRefusal( - xmlToIr( - oneNodeDoc('serviceTask', { - attrs: 'operaton:class="com.example.Svc"', - children: extensionElements( - ` - - `, - ), - }), - ), - UnsupportedExtensionFormError, - 'the operaton:script in an operaton:executionListener names an ' + - `external resource ("${resource}"); ScriptUtil.getScript runs the ` + - 'resource in place of the body, and only an inline body can be ' + - 'written here', - ); - expect(listenerErr.elementId).toBe('T'); - }); - - it('a missing scriptFormat imports as juel with a warning naming the engine default', async () => { + }, + 'The body of the operaton:script in an operaton:executionListener is ' + + 'empty: ScriptUtil.getScript deploys it, since it checks the source ' + + 'for null and not for emptiness, and the printed script draws an ' + + 'empty-body error there.', + ], + ])('%s', async (_title, attrs, children, carried, message) => { const { node, warnings } = await importOnly( - oneNodeDoc('scriptTask', { - id: 'ST', - children: 'x = 1;', - doc: bpmnDoc, - }), + scriptTask(attrs, children), 'scriptTask', ); - expect(node.format).toBe('juel'); + expect(node).toMatchObject(carried); expect(warnings).toEqual([ - { - elementId: 'ST', - category: 'extensionAttribute', - message: - "The script on 'ST' has no scriptFormat; " + - '`BpmnParse.parseScriptTaskElement` substitutes ' + - '`ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE` (juel), and it was ' + - 'imported as such.', - }, + { elementId: 'ST', category: 'extensionAttribute', message }, ]); }); - it('an empty scriptFormat refuses instead of importing an unparseable fence', async () => { - await expectRefusal( - xmlToIr( - oneNodeDoc('scriptTask', { - id: 'ST', - attrs: 'scriptFormat=""', - children: 'x = 1;', - doc: bpmnDoc, - }), - ), - UnsupportedExtensionFormError, + it.each([ + [ + 'an empty scriptFormat refuses', + 'scriptFormat=""', + 'x = 1;', "the script on 'ST' has an empty scriptFormat; " + '`ScriptUtil.getScript` refuses to deploy it', + ], + [ + 'no script body and no resource refuses', + 'scriptFormat="javascript"', + '', + "the script on 'ST' has neither a script body nor a resource; " + + '`ScriptUtil.getScript` refuses to deploy it with neither', + ], + ])('%s', async (_title, attrs, children, detail) => { + await expectRefusal( + xmlToIr(scriptTask(attrs, children)), + UnsupportedExtensionFormError, + detail, ); }); +}); - it('a script task with no script body and no resource refuses instead of importing an empty fence', async () => { - await expectRefusal( +describe('xmlToIr: unsupported element', () => { + it('bpmn:AdHocSubProcess raises UnsupportedElementError', async () => { + const e = await expectRefusal( xmlToIr( - oneNodeDoc('scriptTask', { - id: 'ST', - attrs: 'scriptFormat="javascript"', + oneNodeDoc('adHocSubProcess', { + children: '', doc: bpmnDoc, }), ), - UnsupportedExtensionFormError, - "the script on 'ST' has neither a script body nor a resource; " + - '`ScriptUtil.getScript` refuses to deploy it with neither', + UnsupportedElementError, ); + expect(e.qname).toBe('bpmn:AdHocSubProcess'); + expect(e.elementId).toBe('T'); + expect(e.message).toContain(SUPPORTED_KINDS_MESSAGE); }); - - it.each([ - { - title: 'a self-closing bpmn:script', - children: '', - where: "the script on 'ST'", - carried: { code: '', executionListeners: undefined }, - }, - { - title: 'a whitespace-only bpmn:script', - children: '\n ', - where: "the script on 'ST'", - carried: { code: '', executionListeners: undefined }, - }, - { - title: 'a self-closing operaton:script on a listener', - children: ` - - - - - x = 1;`, - where: 'the operaton:script in an operaton:executionListener', - carried: { - code: 'x = 1;', - executionListeners: [ - { - event: 'start', - binding: { kind: 'script', format: 'groovy', code: '' }, - }, - ], - }, - }, - ])( - '$title imports as an empty body with a warning, since ScriptUtil.getScript deploys it and the printed fence draws an error', - async ({ children, where, carried }) => { - const { node, warnings } = await importOnly( - oneNodeDoc('scriptTask', { - id: 'ST', - attrs: 'scriptFormat="javascript"', - children, - doc: bpmnDoc, - }), - 'scriptTask', - ); - expect({ - code: node.code, - executionListeners: node.executionListeners, - }).toEqual(carried); - expect(warnings).toEqual([ - { - elementId: 'ST', - category: 'extensionAttribute', - message: - `The body of ${where} is empty: ScriptUtil.getScript deploys ` + - 'it, since it checks the source for null and not for ' + - 'emptiness, and the printed script draws an empty-body error ' + - 'there.', - }, - ]); - }, - ); - - it('a script task body holding three backticks refuses, since no fence can enclose it', async () => { - await expectRefusal( - xmlToIr(scriptTaskDoc('a = "```";')), - UnsupportedExtensionFormError, - "the script on 'ST' contains three consecutive backticks, which no " + - 'script fence this language has can enclose', - ); - }); - - it('a scriptFormat outside the alias table is carried as written with a warning', async () => { - const { node, warnings } = await importOnly( - oneNodeDoc('scriptTask', { - id: 'ST', - attrs: 'scriptFormat="cobol"', - children: '1', - doc: bpmnDoc, - }), - 'scriptTask', - ); - expect(node.format).toBe('cobol'); - expect(warnings).toEqual([ - { - elementId: 'ST', - category: 'extensionAttribute', - message: - "The script on 'ST' names the language 'cobol', which the DSL " + - 'has no fence alias for; it was imported as written, and the ' + - 'printed script draws an error there.', - }, - ]); - }); -}); - -describe('xmlToIr: unsupported element (still refused kinds)', () => { - it.each([ - ['bpmn:AdHocSubProcess', 'adHocSubProcess', ''], - ['bpmn:ComplexGateway', 'complexGateway', ''], - ])('%s raises UnsupportedElementError', async (qname, tag, children) => { - const e = await expectRefusal( - xmlToIr(oneNodeDoc(tag, { children, doc: bpmnDoc })), - UnsupportedElementError, - ); - expect(e.qname).toBe(qname); - expect(e.elementId).toBe('T'); - expect(e.message).toContain(SUPPORTED_KINDS_MESSAGE); - }); -}); +}); describe('xmlToIr: the document level', () => { const STEPS = ` @@ -832,7 +382,6 @@ describe('xmlToIr: the document level', () => { ` \n${children}\n `; const unmapped = (elementId: string, message: RegExp) => warning(elementId, message, 'unmappedConstruct'); - /** The two processes of a Modeler file with one drawn pool and one empty one. */ const twoPools = collaboration(` `); @@ -860,29 +409,6 @@ describe('xmlToIr: the document level', () => { /Operaton's validating parse \(`Parse\.execute` against `BPMN20\.xsd`/; it.each([ - [ - 'one participant naming the process, and a lane, import with the pool and the lane warned', - bpmnDefs`${collaboration(' ')} -${process( - ' isExecutable="true"', - 'p', - ` \n${STEPS}`, -)}`, - { - imports: 'p', - warnings: [ - warning( - 'Lane_1', - /^Lane 'Clerk' \(Lane_1\) was not imported/, - 'lane', - ), - unmapped( - 'P1', - /^The pool 'Approval' \(P1\) names the imported process 'p'; `BpmnParse\.parseCollaboration` records it for the diagram alone, and the document written back has no pool\.$/, - ), - ], - }, - ], [ 'one participant plus a message flow import with both warned', bpmnDefs`${collaboration(` @@ -907,11 +433,6 @@ ${EXECUTABLE}`, bpmnDefs`${twoPools}\n${approval}\n${sketch}`, { imports: 'approval', warnings: twoPoolsWarnings }, ], - [ - 'two pools over one executable and one non-executable process import the executable one, written last', - bpmnDefs`${twoPools}\n${sketch}\n${approval}`, - { imports: 'approval', warnings: twoPoolsWarnings }, - ], [ 'two executable processes refuse, naming both', bpmnDefs`${process(' isExecutable="true"', 'p1')}\n${process(' isExecutable="true"', 'p2', ' ')}`, @@ -988,25 +509,6 @@ ${EXECUTABLE}`, ), }, ], - [ - 'triggeredByEvent outside true/false refuses the same way', - bpmnDefs`${process(' isExecutable="true"', 'p', `${STEPS}\n `)}`, - { - refusal: UnsupportedDocumentError, - detail: new RegExp( - `^triggeredByEvent="on" is outside true and false.*${VALIDATING_PARSE.source}`, - ), - }, - ], - [ - 'isSequential text inside documentation does not refuse: only an attribute in a tag does', - bpmnDefs`${process( - ' isExecutable="true"', - 'p', - ` Do not write isSequential="yes" here\n${STEPS}`, - )}`, - { imports: 'p', warnings: [] }, - ], [ 'an id written on two elements refuses, naming the id', bpmnDefs`${process(' isExecutable="true"', 'p', STEPS.replace('', '\n '))}`, @@ -1057,15 +559,6 @@ ${EXECUTABLE}`, /^it has no start event, which BpmnParse\.parseStartEvents fails the deployment on \("process must define a startEvent element"\)/, }, ], - [ - 'a process with steps but no start refuses the same way', - bpmnDefs`${process(' isExecutable="true"', 'p', ' \n \n ')}`, - { - refusal: UnsupportedEventFeatureError, - detail: - /^it has no start event, which BpmnParse\.parseStartEvents fails the deployment on \("process must define a startEvent element"\)/, - }, - ], ] as const)('%s', async (_title, xml, expected) => { if ('refusal' in expected) { await expectRefusal(xmlToIr(xml), expected.refusal, expected.detail); @@ -1080,7 +573,6 @@ ${EXECUTABLE}`, describe('xmlToIr: start, end, and emit triggers', () => { const MESSAGE_ROOT = ' \n'; - const SIGNAL_ROOT = ' \n'; const MESSAGE_DEF = ''; @@ -1090,7 +582,6 @@ describe('xmlToIr: start, end, and emit triggers', () => { defs?: typeof bpmnDefs; } - /** `TStart -> E`, where the start carries the given trigger definition. */ const startTriggerXml = ( definition: string, { attrs = '', roots = '', defs = bpmnDefs }: EventXmlOptions = {}, @@ -1103,10 +594,13 @@ describe('xmlToIr: start, end, and emit triggers', () => { `; - /** `S -> Typed`, where the end carries the given definition. */ const endTriggerXml = ( definition: string, - { attrs = '', roots = '', defs = bpmnDefs }: EventXmlOptions = {}, + { + attrs = '', + roots = MESSAGE_ROOT, + defs = operatonDefs, + }: EventXmlOptions = {}, ): string => defs`${roots} @@ -1116,14 +610,10 @@ describe('xmlToIr: start, end, and emit triggers', () => { `; - /** `S -> Emit1 -> E`, where the throw carries the given definition. */ - const emitXml = ( - definition: string, - { attrs = '', roots = '', defs = bpmnDefs }: EventXmlOptions = {}, - ): string => - defs`${roots} + const emitXml = (definition: string): string => + operatonDefs`${MESSAGE_ROOT} - + ${definition} @@ -1131,1171 +621,520 @@ describe('xmlToIr: start, end, and emit triggers', () => { `; - describe('a start carries message, signal, timer, or condition', () => { - const timer = (child: string, expression: string): string => - ` - ${expression} - `; - - it.each([ - [ - 'a timeDuration timer', - timer('timeDuration', 'PT1H'), - '', - timerDef('duration', 'PT1H'), - ], - [ - 'a timeDate timer', - timer('timeDate', '2026-08-01T09:00:00Z'), - '', - timerDef('date', '2026-08-01T09:00:00Z'), - ], - [ - 'a timeCycle timer', - timer('timeCycle', 'R/PT10M'), - '', - timerDef('cycle', 'R/PT10M'), - ], - [ - 'a message, named by the root it references', - MESSAGE_DEF, - MESSAGE_ROOT, - messageDef('OrderReceived'), - ], - [ - 'a signal, named by the root it references', - '', - SIGNAL_ROOT, - signalDef('StockLow'), - ], - [ - 'a condition, as the engine waits on it', - ` - \${stockLevel < 5} - `, - '', - conditionDef('${stockLevel < 5}'), - ], - ] as const)( - 'a start on %s imports the trigger verbatim, reporting nothing', - async (_title, definition, roots, expected) => { - const { node, warnings } = await importById( - startTriggerXml(definition, { roots }), - 'TStart', - 'startEvent', - ); - expect(node.eventDefinition).toEqual(expected); - expect(warnings).toEqual([]); - }, - ); - - it('a triggered start keeps its form fields alongside the trigger', async () => { - const { node } = await importById( - startTriggerXml( - `${MESSAGE_DEF} - - - - - `, - { roots: MESSAGE_ROOT, defs: operatonDefs }, - ), - 'TStart', - 'startEvent', - ); - expect(node.eventDefinition).toEqual(messageDef('OrderReceived')); - expect(node.formFields).toEqual([{ id: 'amount', type: 'number' }]); - }); - }); - - describe('a start refuses every other trigger', () => { - it.each([ - ['errorEventDefinition', 'an error', "Catch it with 'on error'"], - [ - 'escalationEventDefinition', - 'an escalation', - "Catch it with 'on escalation'", - ], - [ - 'compensateEventDefinition', - 'compensation', - "belongs in an 'on compensation' block", - ], - ])( - 'a start on %s refuses: Operaton would ignore the trigger', - async (tag, subject, remedy) => { - const e = await expectRefusal( - xmlToIr(startTriggerXml(``)), - UnsupportedEventFeatureError, - `a process cannot start on ${subject}; Operaton ignores the ` + - 'trigger and starts the process as if none were written, so ' + - 'importing it would write back a document the engine runs ' + - 'differently from what it says', - ); - expect(e.elementId).toBe('TStart'); - expect(e.message).toContain(remedy); - expect(e.message).not.toContain('Event handlers catch one'); - }, - ); - - it('a link start refuses, naming every trigger a process start does take', async () => { - const e = await expectRefusal( - xmlToIr( - startTriggerXml(''), - ), - UnsupportedEventDefinitionError, - ); - expect([e.elementId, e.eventKind, e.definitionType]).toEqual([ - 'TStart', - 'start', - 'bpmn:LinkEventDefinition', - ]); - expect(e.message).toContain( - "a process's start supports message, signal, timer, or condition", - ); - }); - - it('a start carrying two event definitions refuses, naming the count', async () => { - const e = await expectRefusal( - xmlToIr( - startTriggerXml( - ` - `, - { roots: MESSAGE_ROOT + SIGNAL_ROOT }, - ), - ), - UnsupportedEventFeatureError, - 'a start carries 2 event definitions: only a single message, ' + - 'signal, timer, or condition trigger is supported', - ); - expect(e.elementId).toBe('TStart'); - }); - - it('a message start whose message name embeds an expression refuses', async () => { - const e = await expectRefusal( - xmlToIr( - startTriggerXml(MESSAGE_DEF, { - roots: ' \n', - }), - ), - UnsupportedEventFeatureError, - 'a message start event\'s message name "Order-${type}" is an ' + - 'expression; Operaton rejects an expression there, because a ' + - 'process that has not started yet has no variables to evaluate it ' + - 'against', - ); - expect(e.elementId).toBe('TStart'); - expect(e.message).toContain('Give the message a fixed name'); - expect(e.message).not.toContain('Event handlers catch one'); - }); - }); - - describe('a process takes several starts, a subprocess or transaction takes one', () => { - const TIMER_START = `R/PT1H`; - const MESSAGE_START = ''; - const twoStarts = (second: string): string => - bpmnDefs` - - - ${second} - - - - `; - - it('a process with a plain start beside a message start imports both, each keeping its outgoing flow', async () => { - const { ir, warnings } = await xmlToIr(twoStarts(MESSAGE_START)); - expect(warnings).toEqual([]); - expect(ir.flowElements.map((fe) => [fe.kind, fe.id])).toEqual([ - ['startEvent', 'S1'], - ['startEvent', 'S2'], - ['endEvent', 'E'], - ]); - expect(ir.sequenceFlows.map((f) => [f.sourceRef, f.targetRef])).toEqual([ - ['S1', 'E'], - ['S2', 'E'], - ]); - }); + const sendDef = (attrs: string): string => + ``; - it.each([ - ['a second plain start', ''], - ['a timer start', TIMER_START], - ])( - 'a process with a plain start beside %s refuses with the sentence selectInitial fails the deployment with', - async (_title, second) => { - const e = await expectRefusal( - xmlToIr(twoStarts(second)), - UnsupportedEventFeatureError, - "the process 'p' has 2 plain or timer starts, which " + - 'BpmnParse.selectInitial fails the deployment on ("multiple ' + - 'none start events or timer start events not supported on ' + - 'process definition")', - ); - expect(e.elementId).toBe('S2'); - }, - ); + const messageDefWith = (attrs: string, children: string): string => + ` + +${children} + + `; - const noStart = (tag: string): string => - bpmnDoc` + const subWith = (tag: string, content: string): string => + bpmnDoc` <${tag} id="Sub"> - - - +${content} `; - it('a bpmn:subProcess with no start event refuses with the sentence parseStartEvents fails the deployment with', async () => { - const e = await expectRefusal( - xmlToIr(noStart('bpmn:subProcess')), - UnsupportedEventFeatureError, - 'it has no start event, which BpmnParse.parseStartEvents fails the ' + - 'deployment on ("subProcess must define a startEvent element")', - ); - expect(e.elementId).toBe('Sub'); - }); - - it('a bpmn:transaction with no start event imports and warns that the engine fails it on entry', async () => { - const { ir, warnings } = await xmlToIr(noStart('bpmn:transaction')); - expect(subProcess(ir, 'Sub').flowElements.map((fe) => fe.id)).toEqual([ - 'In', - 'SubEnd', - ]); - expect(warnings).toEqual([ - { - elementId: 'Sub', - category: 'unmappedConstruct', - message: - "The bpmn:transaction 'Sub' has no start event: Operaton " + - 'deploys it and SubProcessActivityBehavior.execute fails on ' + - 'entering it ("No initial activity found"); the script adds a ' + - 'start, so the imported block runs.', - }, - ]); - }); - - it.each(['bpmn:subProcess', 'bpmn:transaction'])( - 'a %s with two start events refuses, attributed to it', - async (tag) => { - const e = await expectRefusal( - xmlToIr(bpmnDoc` - <${tag} id="Sub"> - + it.each([ + [ + 'a start on an error, which Operaton ignores', + startTriggerXml(''), + 'TStart', + 'a process cannot start on an error; Operaton ignores the trigger and ' + + 'starts the process as if none were written, so importing it would ' + + 'write back a document the engine runs differently from what it says', + "Catch it with 'on error' inside the scope that raises it.", + ], + [ + 'a start on an escalation, which Operaton ignores', + startTriggerXml(''), + 'TStart', + 'a process cannot start on an escalation; Operaton ignores the trigger ' + + 'and starts the process as if none were written, so importing it ' + + 'would write back a document the engine runs differently from ' + + 'what it says', + "Catch it with 'on escalation' inside the scope that raises it.", + ], + [ + 'a start on compensation, which Operaton ignores', + startTriggerXml(''), + 'TStart', + 'a process cannot start on compensation; Operaton ignores the trigger ' + + 'and starts the process as if none were written, so importing it ' + + 'would write back a document the engine runs differently from ' + + 'what it says', + "Compensation undoes a subprocess's completed work, so it belongs " + + "in an 'on compensation' block inside that subprocess.", + ], + [ + 'a message start whose message name embeds an expression', + startTriggerXml(MESSAGE_DEF, { + roots: ' \n', + }), + 'TStart', + 'a message start event\'s message name "Order-${type}" is an ' + + 'expression; Operaton rejects an expression there, because a ' + + 'process that has not started yet has no variables to evaluate it ' + + 'against', + 'Give the message a fixed name.', + ], + [ + 'two plain starts in a process, which selectInitial fails the deployment on', + bpmnDefs` + + + + + + `, + 'S2', + "the process 'p' has 2 plain or timer starts, which " + + 'BpmnParse.selectInitial fails the deployment on ("multiple ' + + 'none start events or timer start events not supported on ' + + 'process definition")', + undefined, + ], + [ + 'two starts in a subprocess', + subWith( + 'bpmn:subProcess', + ` - - - - - `), - UnsupportedEventFeatureError, - 'it has 2 start events; this tool writes one entry point per ' + - 'subprocess or transaction, so a second start has nowhere to go', - ); - expect(e.elementId).toBe('Sub'); - }, + `, + ), + 'Sub', + 'it has 2 start events; this tool writes one entry point per ' + + 'subprocess or transaction, so a second start has nowhere to go', + undefined, + ], + [ + 'a connector on a message end, the element form of the send', + endTriggerXml( + messageDefWith( + '', + ` + http-connector + `, + ), + ), + 'Typed', + 'a thrown message carries a connector; that is what makes the ' + + 'engine really send it, and this surface has no place to keep it', + undefined, + ], + [ + 'a class with a resultVariable on an emit, quoting the engine', + emitXml( + sendDef( + 'operaton:class="com.example.Send" operaton:resultVariable="r"', + ), + ), + 'Emit1', + 'its message definition binds operaton:class with ' + + "operaton:resultVariable, which Operaton refuses to deploy: \"'resultVariableName' not supported " + + "for intermediateMessageThrowEvent elements using 'class'\" " + + '(BpmnParse.parseServiceTaskLike)', + undefined, + ], + [ + 'an expression with a resultVariable on a message end, whose value the engine stores', + endTriggerXml( + sendDef( + 'operaton:expression="${sender.send()}" operaton:resultVariable="r"', + ), + ), + 'Typed', + 'its message definition binds operaton:expression with ' + + 'operaton:resultVariable="r", under which ' + + "BpmnParse.parseServiceTaskLike stores the expression's value " + + '(ServiceTaskExpressionActivityBehavior); a thrown message in ' + + 'this script takes no result variable, so dropping it would ' + + 'change what runs', + undefined, + ], + ])('%s refuses', async (_title, xml, elementId, detail, remedy) => { + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedEventFeatureError, + detail, ); + expect(e.elementId).toBe(elementId); + if (remedy !== undefined) { + expect(e.message).toBe( + `The event construct at '${elementId}' cannot be imported: ${detail}. ${remedy}`, + ); + } }); - describe('an end carries terminate or a thrown message', () => { - it('a terminate end imports as a terminate and keeps its label', async () => { - const { node, warnings } = await importById( - endTriggerXml('', { - attrs: 'name="All Stop"', - }), - 'Typed', - 'endEvent', - ); - expect(node.eventDefinition).toEqual({ kind: 'terminate' }); - expect(node.name).toBe('All Stop'); - expect(warnings).toEqual([]); - }); - - it('an unread setting on a terminate definition warns rather than vanishing', async () => { - const { warnings } = await xmlToIr( - endTriggerXml( - '', - { defs: operatonDefs }, - ), - ); - expectOneWarning(extensionWarnings(warnings), { - elementId: 'Typed', - message: 'asyncBefore', - }); - }); - - it('an undeclared attribute on a terminate definition warns rather than vanishing', async () => { - const { warnings } = await xmlToIr( - endTriggerXml(''), - ); - expectOneWarning(unmappedWarnings(warnings), { - elementId: 'Typed', - message: 'foo', - }); - }); - - it('a message end imports the name of the message root it references, with no binding of its own', async () => { - const { node, warnings } = await importById( - endTriggerXml(MESSAGE_DEF, { roots: MESSAGE_ROOT }), - 'Typed', - 'endEvent', - ); - expect(node.eventDefinition).toEqual(messageDef('OrderReceived')); - expect('binding' in node).toBe(false); - expect(warnings).toEqual([]); - }); - - it('a message end with no messageRef refuses: nothing names it', async () => { - await expectRefusal( - xmlToIr(endTriggerXml('')), - UnsupportedEventFeatureError, - 'a message definition must reference a bpmn:Message root with a ' + - 'non-empty name', - ); - }); - }); - - describe('a thrown message carries its send implementation', () => { - /** The implementation sits on the definition, which is where the engine reads it. */ - const implementedMessageDef = (attrs: string): string => - ``; - - /** The element form of the same send, under the given prefix. */ - const connectorMessageDef = (prefix: string): string => - ` - - <${prefix}:connector> - <${prefix}:connectorId>http-connector - <${prefix}:inputOutput> - <${prefix}:inputParameter name="url">http://warehouse - - - - `; - - it.each([ - ['class="com.example.Send"', classBinding('com.example.Send')], - [ - 'expression="${sender.send(order)}"', - exprBinding('${sender.send(order)}'), - ], - ['delegateExpression="${senderBean}"', delegateBinding('${senderBean}')], - [ - 'type="external" operaton:topic="send-ack"', - externalBinding('send-ack'), - ], - ])( - 'operaton:%s on a message end imports as the binding it names', - async (attrs, expected) => { - const { node, warnings } = await importById( - endTriggerXml(implementedMessageDef(`operaton:${attrs}`), { - roots: MESSAGE_ROOT, - defs: operatonDefs, - }), - 'Typed', - 'endEvent', - ); - expect(node.binding).toEqual(expected); - expect(warnings).toEqual([]); - }, + it('a bpmn:transaction with no start event imports and warns that the engine fails it on entry', async () => { + const { ir, warnings } = await xmlToIr( + subWith( + 'bpmn:transaction', + ` + + `, + ), ); - - it('a message emit carries the implementation too', async () => { - const { node, warnings } = await importById( - emitXml( - implementedMessageDef('operaton:delegateExpression="${sender}"'), - { roots: MESSAGE_ROOT, defs: operatonDefs }, - ), - 'Emit1', - 'intermediateThrowEvent', - ); - expect(node.binding).toEqual(delegateBinding('${sender}')); - expect(warnings).toEqual([]); - }); - - it('an external type with no topic refuses: the send names no worker', async () => { - const e = await expectRefusal( - xmlToIr( - endTriggerXml(implementedMessageDef('operaton:type="external"'), { - roots: MESSAGE_ROOT, - defs: operatonDefs, - }), - ), - UnsupportedServiceTaskFormError, - ); - expect(e.subject).toBe('Thrown message'); - expect(e.construct).toBe( - 'operaton:type="external" without an operaton:topic', - ); - }); - - it('an external type on the definition outranks a class, which is reported', async () => { - const { node, warnings } = await importById( - endTriggerXml( - implementedMessageDef( - 'operaton:class="com.example.Send" operaton:type="external" operaton:topic="send-ack"', - ), - { roots: MESSAGE_ROOT, defs: operatonDefs }, - ), - 'Typed', - 'endEvent', - ); - expect(node.binding).toEqual(externalBinding('send-ack')); - expect(warnings.map((w) => w.message)).toEqual([ - "The 'class' setting on 'Typed' has no effect alongside operaton:type=\"external\" and was not imported.", - ]); - }); - - it('a topic with no external type warns: nothing names the worker', async () => { - const { node, warnings } = await importById( - endTriggerXml(implementedMessageDef('operaton:topic="send-ack"'), { - roots: MESSAGE_ROOT, - defs: operatonDefs, - }), - 'Typed', - 'endEvent', - ); - expect('binding' in node).toBe(false); - expectOneWarning(extensionWarnings(warnings), { - elementId: 'Typed', + expect(subProcess(ir, 'Sub').flowElements.map((fe) => fe.id)).toEqual([ + 'In', + 'SubEnd', + ]); + expect(warnings).toEqual([ + { + elementId: 'Sub', + category: 'unmappedConstruct', message: - "The 'topic' setting on 'Typed' only takes effect alongside " + - 'operaton:type="external"; on its own it names no external worker ' + - 'and was not imported.', - }); - }); - - it.each([ - [ - 'a message end', - endTriggerXml(connectorMessageDef('operaton'), { - roots: MESSAGE_ROOT, - defs: operatonDefs, - }), - 'Typed', - ], - [ - 'a message emit, under the camunda: prefix', - emitXml(connectorMessageDef('camunda'), { - roots: MESSAGE_ROOT, - defs: camundaDefs, - }), - 'Emit1', - ], - ] as const)( - 'a connector on the definition of %s refuses: the same send, in element form', - async (_title, xml, elementId) => { - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedEventFeatureError, - 'a thrown message carries a connector; that is what makes the ' + - 'engine really send it, and this surface has no place to keep it', - ); - expect(e.elementId).toBe(elementId); + "The bpmn:transaction 'Sub' has no start event: Operaton " + + 'deploys it and SubProcessActivityBehavior.execute fails on ' + + 'entering it ("No initial activity found"); the script adds a ' + + 'start, so the imported block runs.', }, - ); - - it('the same setting on the event itself warns: the engine reads it off the definition', async () => { - const { node, warnings } = await importById( - endTriggerXml(MESSAGE_DEF, { - attrs: 'operaton:class="com.example.Send"', - roots: MESSAGE_ROOT, - defs: operatonDefs, - }), - 'Typed', - 'endEvent', - ); - expect('binding' in node).toBe(false); - expectOneWarning(extensionWarnings(warnings), { - elementId: 'Typed', - message: "The 'operaton:class' setting on 'Typed' was not imported", - }); - }); - - it('an implementation on a caught message warns: only a throw sends one', async () => { - const { warnings } = await xmlToIr( - startTriggerXml( - implementedMessageDef('operaton:class="com.example.Send"'), - { roots: MESSAGE_ROOT, defs: operatonDefs }, - ), - ); - expectOneWarning(extensionWarnings(warnings), { - elementId: 'TStart', - message: "The 'class' setting on 'TStart' only takes effect on a throw", - }); - }); - - it('a connector on a signal end still warns, attributed to the event itself', async () => { - const { warnings } = await xmlToIr( - endTriggerXml( - ` - - http-connector - - - `, - { roots: SIGNAL_ROOT, defs: operatonDefs }, - ), - ); - expect(extensionWarnings(warnings)).toEqual([ - unread('operaton:connector', 'Typed', 'a '), - ]); - }); - - /** The definition with an extension block, for the fields and mappings the engine reads off it. */ - const messageDefWith = (attrs: string, children: string): string => - ` - -${children} - - `; + ]); + }); - /** A property list on the event element itself, where `parseServiceTaskLike` is handed it. */ - const EVENT_PROPERTIES = ` + const EVENT_PROPERTIES = ` `; - const resultVariableRefusal = ( - binding: string, - attr: string, - elementName: string, - ): string => - `its message definition binds operaton:${binding} with operaton:${attr}, ` + - `which Operaton refuses to deploy: "'resultVariableName' not supported ` + - `for ${elementName} elements using '${binding}'" ` + - '(BpmnParse.parseServiceTaskLike)'; + const neverWritten = (attr: string, binding: string): string => + `The '${attr}' setting on 'Typed' was not imported: ` + + 'BpmnParse.parseServiceTaskLike hands it to an expression binding ' + + `alone, so ${binding} never writes it.`; + + const readOffThrow = (what: string, carrier: string): string => + `The ${what} on 'Typed' was not imported: ` + + `BpmnParse.parseExternalServiceTask reads it off ${carrier} of a ` + + 'thrown message bound with operaton:type="external", and this ' + + "surface's throw has no position for it, so the document written " + + 'back runs without it.'; + + const neverReadOnThrow = (what: string): string => + `The ${what} on 'Typed' was not imported: Operaton reads it in ` + + 'parseExternalServiceTask alone, which only a thrown message bound ' + + 'with operaton:type="external" reaches, so the event runs as written ' + + 'without it.'; + + const throwOnly = (what: string): string => + `The ${what} on 'TStart' only takes effect on a throw ('throw ` + + "message' or 'emit message'); it has no effect on a catch and was " + + 'not imported.'; + + const FIELD_HOME = + 'this tool carries an injected field on the step or the listener ' + + 'whose class or delegate binding receives it, on the step whose ' + + 'built-in mail or shell behaviour does, and on no other position'; + + const ERROR_ROOTS = + MESSAGE_ROOT + ' \n'; - it.each([ - [ - 'operaton:class with operaton:resultVariable on an emit', - emitXml( - implementedMessageDef( - 'operaton:class="com.example.Send" operaton:resultVariable="r"', - ), - { roots: MESSAGE_ROOT, defs: operatonDefs }, - ), - 'Emit1', - resultVariableRefusal( - 'class', - 'resultVariable', - 'intermediateMessageThrowEvent', - ), + it.each([ + { + case: 'a message emit carries its implementation', + xml: emitXml(sendDef('operaton:delegateExpression="${sender}"')), + id: 'Emit1', + binding: delegateBinding('${sender}'), + warnings: [], + }, + { + case: 'a topic with no external type names no worker and is dropped', + xml: endTriggerXml(sendDef('operaton:topic="send-ack"')), + id: 'Typed', + binding: undefined, + warnings: [ + "The 'topic' setting on 'Typed' only takes effect alongside " + + 'operaton:type="external"; on its own it names no external worker ' + + 'and was not imported.', ], - [ - 'operaton:delegateExpression with the older operaton:resultVariableName on a message end', - endTriggerXml( - implementedMessageDef( - 'operaton:delegateExpression="${bean}" operaton:resultVariableName="r"', - ), - { roots: MESSAGE_ROOT, defs: operatonDefs }, - ), - 'Typed', - resultVariableRefusal( - 'delegateExpression', - 'resultVariableName', - 'messageEndEvent', + }, + { + case: 'a result variable beside an external type is dropped: the type branch never writes it', + xml: endTriggerXml( + sendDef( + 'operaton:type="external" operaton:topic="send-ack" operaton:resultVariable="r"', ), + ), + id: 'Typed', + binding: externalBinding('send-ack'), + warnings: [ + neverWritten('resultVariable', 'an operaton:type="external" binding'), ], - [ - 'operaton:expression with operaton:resultVariable on a message end, which the engine deploys and stores the value under', - endTriggerXml( - implementedMessageDef( - 'operaton:expression="${sender.send()}" operaton:resultVariable="r"', - ), - { roots: MESSAGE_ROOT, defs: operatonDefs }, + }, + { + case: 'a result variable on a definition naming no implementation is dropped: nothing runs to write it', + xml: endTriggerXml(sendDef('operaton:resultVariableName="r"')), + id: 'Typed', + binding: undefined, + warnings: [ + neverWritten( + 'resultVariableName', + 'a definition naming no implementation', ), - 'Typed', - 'its message definition binds operaton:expression with ' + - 'operaton:resultVariable="r", under which ' + - "BpmnParse.parseServiceTaskLike stores the expression's value " + - '(ServiceTaskExpressionActivityBehavior); a thrown message in ' + - 'this script takes no result variable, so dropping it would ' + - 'change what runs', ], - ])( - '%s refuses with UnsupportedEventFeatureError quoting the engine', - async (_title, xml, elementId, detail) => { - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedEventFeatureError, - detail, - ); - expect(e.elementId).toBe(elementId); - }, - ); - - const neverWritten = (attr: string, binding: string): string => - `The '${attr}' setting on 'Typed' was not imported: ` + - 'BpmnParse.parseServiceTaskLike hands it to an expression binding ' + - `alone, so ${binding} never writes it.`; - - const readOffThrow = (what: string, carrier: string): string => - `The ${what} on 'Typed' was not imported: ` + - `BpmnParse.parseExternalServiceTask reads it off ${carrier} of a ` + - 'thrown message bound with operaton:type="external", and this ' + - "surface's throw has no position for it, so the document written " + - 'back runs without it.'; - - const neverReadOnThrow = (what: string): string => - `The ${what} on 'Typed' was not imported: Operaton reads it in ` + - 'parseExternalServiceTask alone, which only a thrown message bound ' + - 'with operaton:type="external" reaches, so the event runs as written ' + - 'without it.'; - - const FIELD_HOME = - 'this tool carries an injected field on the step or the listener ' + - 'whose class or delegate binding receives it, on the step whose ' + - 'built-in mail or shell behaviour does, and on no other position'; - - it.each([ - { - case: 'a result variable beside an external type is dropped: the type branch never writes it', - definition: implementedMessageDef( - 'operaton:type="external" operaton:topic="send-ack" operaton:resultVariable="r"', - ), - binding: externalBinding('send-ack'), - warnings: [ - neverWritten('resultVariable', 'an operaton:type="external" binding'), - ], - }, - { - case: 'a result variable on a definition naming no implementation is dropped: nothing runs to write it', - definition: implementedMessageDef('operaton:resultVariableName="r"'), - binding: undefined, - warnings: [ - neverWritten( - 'resultVariableName', - 'a definition naming no implementation', - ), - ], - }, - { - case: 'a field beside a class is dropped, naming the definition the engine reads it off', - definition: messageDefWith( + }, + { + case: 'a field beside a class is dropped, naming the definition the engine reads it off', + xml: endTriggerXml( + messageDefWith( 'operaton:class="com.example.Send"', ' ', ), - binding: classBinding('com.example.Send'), - warnings: [ - "The injected field 'to' on the message definition of 'Typed' " + - `was not imported: ${FIELD_HOME}; BpmnParse.parseServiceTaskLike ` + - 'reads it off the definition into the class it names, so the ' + - 'document written back runs that without it.', - ], - }, - { - case: 'a field beside an expression is dropped: the engine injects into a class or a delegate alone', - definition: messageDefWith( + ), + id: 'Typed', + binding: classBinding('com.example.Send'), + warnings: [ + "The injected field 'to' on the message definition of 'Typed' " + + `was not imported: ${FIELD_HOME}; BpmnParse.parseServiceTaskLike ` + + 'reads it off the definition into the class it names, so the ' + + 'document written back runs that without it.', + ], + }, + { + case: 'a field beside an expression is dropped: the engine injects into a class or a delegate alone', + xml: endTriggerXml( + messageDefWith( 'operaton:expression="${sender.send()}"', ' ', ), - binding: exprBinding('${sender.send()}'), - warnings: [ - "The injected field 'to' on the message definition of 'Typed' " + - `was not imported: ${FIELD_HOME}, and Operaton injects a field ` + - 'into a class or a delegate binding and into no other.', - ], - }, - { - case: 'a task priority on the definition and a property list on the event beside an external type: two engine-read drops and nothing else', - definition: - EVENT_PROPERTIES + - implementedMessageDef( + ), + id: 'Typed', + binding: exprBinding('${sender.send()}'), + warnings: [ + "The injected field 'to' on the message definition of 'Typed' " + + `was not imported: ${FIELD_HOME}, and Operaton injects a field ` + + 'into a class or a delegate binding and into no other.', + ], + }, + { + case: 'a task priority and a property list beside an external type are two engine-read drops', + xml: endTriggerXml( + EVENT_PROPERTIES + + sendDef( 'operaton:type="external" operaton:topic="send-ack" operaton:taskPriority="7"', ), - binding: externalBinding('send-ack'), - warnings: [ - readOffThrow("'taskPriority' setting", 'the message definition'), - readOffThrow('operaton:properties block', 'the event element'), - ], - }, - { - case: 'the three extras beside a class are each a setting the engine never reads there', - roots: MESSAGE_ROOT + ' \n', - definition: - EVENT_PROPERTIES + + ), + id: 'Typed', + binding: externalBinding('send-ack'), + warnings: [ + readOffThrow("'taskPriority' setting", 'the message definition'), + readOffThrow('operaton:properties block', 'the event element'), + ], + }, + { + case: 'the three extras beside a class are each a setting the engine never reads there', + xml: endTriggerXml( + EVENT_PROPERTIES + messageDefWith( 'operaton:class="com.example.Send" operaton:taskPriority="7"', ' ', ), - binding: classBinding('com.example.Send'), - warnings: [ - neverReadOnThrow("'taskPriority' setting"), - neverReadOnThrow("operaton:errorEventDefinition 'Map_1'"), - neverReadOnThrow('operaton:properties block'), - ], - }, - { - case: 'a property list on a signal end is never read either', - roots: SIGNAL_ROOT, - definition: - EVENT_PROPERTIES + - '', - binding: undefined, - warnings: [neverReadOnThrow('operaton:properties block')], - }, - ])( - '$case', - async ({ - roots = MESSAGE_ROOT, - definition, - binding, - warnings: expected, - }) => { - const { node, warnings } = await importById( - endTriggerXml(definition, { roots, defs: operatonDefs }), - 'Typed', - 'endEvent', - ); - expect(node.binding).toEqual(binding); - expect(warnings.map((w) => w.message)).toEqual(expected); - }, - ); - - it('a task priority the engine refuses beside an external type refuses here too, quoting parsePriority', async () => { - const e = await expectRefusal( - xmlToIr( - endTriggerXml( - implementedMessageDef( - 'operaton:type="external" operaton:topic="send-ack" operaton:taskPriority="abc"', - ), - { roots: MESSAGE_ROOT, defs: operatonDefs }, - ), - ), - UnsupportedExtensionFormError, - priorityRefusal('taskPriority', 'abc'), - ); - expect(e.elementId).toBe('Typed'); - }); - - it('the extras on a caught message warn as throw-only settings, one each', async () => { - const { warnings } = await xmlToIr( - startTriggerXml( - messageDefWith( - 'operaton:taskPriority="7" operaton:resultVariable="r"', - ' \n' + - ' ', - ), - { - roots: - MESSAGE_ROOT + ' \n', - defs: operatonDefs, - }, + { roots: ERROR_ROOTS }, + ), + id: 'Typed', + binding: classBinding('com.example.Send'), + warnings: [ + neverReadOnThrow("'taskPriority' setting"), + neverReadOnThrow("operaton:errorEventDefinition 'Map_1'"), + neverReadOnThrow('operaton:properties block'), + ], + }, + { + case: 'the extras on a caught message are throw-only settings, one warning each', + xml: startTriggerXml( + messageDefWith( + 'operaton:taskPriority="7" operaton:resultVariable="r"', + ' \n' + + ' ', ), - ); - const throwOnly = (what: string): string => - `The ${what} on 'TStart' only takes effect on a throw ('throw ` + - "message' or 'emit message'); it has no effect on a catch and was " + - 'not imported.'; - expect(warnings.map((w) => w.message)).toEqual([ + { roots: ERROR_ROOTS, defs: operatonDefs }, + ), + id: 'TStart', + binding: undefined, + warnings: [ throwOnly("'taskPriority' setting"), throwOnly("'resultVariable' setting"), throwOnly("operaton:field 'to'"), throwOnly("operaton:errorEventDefinition 'Map_1'"), - ]); - }); + ], + }, + ])('$case', async ({ xml, id, binding, warnings: expected }) => { + const { ir, warnings } = await xmlToIr(xml); + const node = byId(ir, id); + expect('binding' in node ? node.binding : undefined).toEqual(binding); + expect(warnings.map((w) => w.message)).toEqual(expected); }); +}); - describe('a name opening with an expression imports as written and prints as a raw template', () => { - const EXPR_MESSAGE_ROOT = - ' \n'; - const EXPR_SIGNAL_ROOT = - ' \n'; - const SIGNAL_DEF = - ''; - const EXPR_SIGNAL: EventDefinition = { - kind: 'signal', - signalName: '${topic}', - }; +describe('xmlToIr: imports a repetition', () => { + const repeat = (attrs = '', children = ''): string => + `${children}`; - // `BpmnParse.parseMessages` evaluates every message name through - // `createExpression`, so the shape deploys; only a process start refuses - // it, since nothing has started when the engine would evaluate it. - it.each([ + const repeatedTaskDoc = (attrs = '', children = ''): string => + oneNodeDoc('userTask', { children: repeat(attrs, children) }); + + const on = ( + category: ImportWarning['category'], + message: string | ReturnType, + ) => ({ elementId: 'T', category, message }); + + const both = (pair: string, rule: string, kept: string, dropped: string) => + on( + 'extensionAttribute', + `Both ${pair} on 'T'; ${rule}, so '${kept}' was imported and '${dropped}' was dropped.`, + ); + + it.each<[string, string, string, object, unknown[]]>([ + [ + 'isSequential="true" imports the runs as sequential', + 'operaton:collection="lines" isSequential="true"', + '', + { collection: 'lines', sequential: true }, + [], + ], + [ + 'operaton:collection and bpmn:loopDataInputRef spelling two variable names import the second', + 'operaton:collection="items"', + 'lines', + { collection: 'lines' }, [ - 'a message end', - endTriggerXml(MESSAGE_DEF, { roots: EXPR_MESSAGE_ROOT }), - 'Typed', - messageDef('${orderType}'), + both( + 'operaton:collection and bpmn:loopDataInputRef name the collection', + 'parseMultiInstanceLoopCharacteristics writes both into the same field and bpmn:loopDataInputRef second', + 'lines', + 'items', + ), ], + ], + [ + 'an expression collection outranks a bpmn:loopDataInputRef variable name', + 'operaton:collection="${items}"', + 'lines', + { collection: '${items}' }, [ - 'a message emit', - emitXml(MESSAGE_DEF, { roots: EXPR_MESSAGE_ROOT }), - 'Emit1', - messageDef('${orderType}'), + both( + 'operaton:collection and bpmn:loopDataInputRef name the collection', + 'parseMultiInstanceLoopCharacteristics stores an expression (a value ' + + 'containing "{") and a variable name in two fields, and ' + + 'MultiInstanceActivityBehavior.resolveNrOfInstances reads the expression ' + + 'field first', + '${items}', + 'lines', + ), ], - [ - 'a signal end', - endTriggerXml(SIGNAL_DEF, { roots: EXPR_SIGNAL_ROOT }), - 'Typed', - EXPR_SIGNAL, - ], - [ - 'a message handler start', - handlerDoc(MESSAGE_DEF, { roots: EXPR_MESSAGE_ROOT, body: '' }), - 'HStart', - messageDef('${orderType}'), - ], - [ - 'a signal start', - startTriggerXml(SIGNAL_DEF, { roots: EXPR_SIGNAL_ROOT }), - 'TStart', - EXPR_SIGNAL, - ], - [ - 'a message end named with the "#{...}" spelling', - endTriggerXml(MESSAGE_DEF, { - roots: ' \n', - }), - 'Typed', - messageDef('#{orderType}'), - ], - [ - 'a message end named with an expression after a fixed prefix', - endTriggerXml(MESSAGE_DEF, { - roots: - ' \n', - }), - 'Typed', - messageDef('Order-${orderType}'), - ], - ] as const)( - '%s imports the name as written, with no warning', - async (_case, xml, elementId, definition) => { - const { ir, warnings } = await xmlToIr(xml); - expect(warnings).toEqual([]); - const container = - elementId === 'HStart' ? subProcess(ir, 'Handler') : ir; - const node = byId(container, elementId); - expect('eventDefinition' in node && node.eventDefinition).toEqual( - definition, - ); - }, - ); - - it.each(['${orderType}', '#{orderType}'])( - 'a message start named %s keeps its refusal: the engine rejects an expression there', - async (name) => { - const e = await expectRefusal( - xmlToIr( - startTriggerXml(MESSAGE_DEF, { - roots: ` \n`, - }), - ), - UnsupportedEventFeatureError, - `a message start event's message name "${name}" is an expression; ` + - 'Operaton rejects an expression there, because a process that ' + - 'has not started yet has no variables to evaluate it against', - ); - expect(e.elementId).toBe('TStart'); - }, - ); - }); - - it('a message emit imports the name of the message root it references', async () => { - const { node, warnings } = await importById( - emitXml(MESSAGE_DEF, { roots: MESSAGE_ROOT }), - 'Emit1', - 'intermediateThrowEvent', - ); - expect(node.eventDefinition).toEqual(messageDef('OrderReceived')); - expect(warnings).toEqual([]); - }); -}); - -describe('xmlToIr: imports a repetition', () => { - /** `` with the given attributes and children. */ - const repeat = (attrs = '', children = ''): string => - `${children}`; - - /** `S -> userTask -> E` where the task repeats. */ - const repeatedTaskDoc = (attrs = '', children = ''): string => - oneNodeDoc('userTask', { children: repeat(attrs, children) }); - - /** Every tag that can repeat, and the IR kind it imports as. */ - const REPEATABLE_TAGS = [ - ['userTask', 'userTask', ''], - ['task', 'task', ''], - ['serviceTask', 'serviceTask', 'operaton:class="com.example.Svc"'], - ['sendTask', 'serviceTask', 'operaton:class="com.example.Send"'], - ['businessRuleTask', 'serviceTask', 'operaton:decisionRef="riskRating"'], - ['receiveTask', 'receiveTask', ''], - ['scriptTask', 'scriptTask', 'scriptFormat="groovy"'], - ['subProcess', 'subProcess', ''], - ['callActivity', 'callActivity', 'calledElement="sub-process"'], - ] as const; - - it.each(REPEATABLE_TAGS)( - 'bpmn:%s imports its repetition, reporting nothing as dropped', - async (tag, kind, attrs) => { - const { node, warnings } = await importOnly( - oneNodeDoc(tag, { - attrs, - // A script task needs a body of its own and a block its own start - // to import at all; every other repeatable tag ignores the extra child. - children: - (tag === 'scriptTask' ? 'x = 1;' : '') + - repeat( - 'operaton:collection="lines" operaton:elementVariable="line"', - ) + - (tag === 'subProcess' ? '' : ''), - }), - kind, - ); - expect(node.loop).toEqual({ - collection: 'lines', - elementVariable: 'line', - }); - expect(warnings).toEqual([]); - }, - ); - - it.each([ - ['isSequential="true"', { sequential: true }], - ['isSequential="false"', {}], - ['', {}], - ])( - '%s imports the runs as sequential only when true', - async (attr, extra) => { - const { node } = await importOnly( - repeatedTaskDoc(`operaton:collection="lines" ${attr}`), - 'userTask', - ); - expect(node.loop).toEqual({ collection: 'lines', ...extra }); - }, - ); - - it.each([ - ['3', 0], - ['${lineCount}', 0], - // The rewrap this one is reported for is pinned by its own suite. - ['#{lineCount}', 1], - ] as const)( - 'a bpmn:loopCardinality of %s alone imports as the count', - async (body, warned) => { - const { node, warnings } = await importOnly( - repeatedTaskDoc( - '', - `${body}`, - ), - 'userTask', - ); - expect(node.loop).toEqual({ cardinality: body }); - expect(warnings).toHaveLength(warned); - }, - ); - - it('a count, a collection and a completion condition import together', async () => { - const { node, warnings } = await importOnly( - repeatedTaskDoc( - 'operaton:collection="${order.lines}" operaton:elementVariable="line" isSequential="true"', - `3 - \${nrOfCompletedInstances >= 2}`, - ), - 'userTask', - ); - expect(node.loop).toEqual({ - cardinality: '3', - collection: '${order.lines}', - elementVariable: 'line', - completionCondition: '${nrOfCompletedInstances >= 2}', - sequential: true, - }); - expect(warnings).toEqual([]); - }); - - it('a bpmn:loopDataInputRef naming a process variable imports as the collection', async () => { - const { node, warnings } = await importOnly( - repeatedTaskDoc( - '', - `assigneeList - `, - ), - 'userTask', - ); - expect(node.loop).toEqual({ - collection: 'assigneeList', - elementVariable: 'assignee', - }); - expect(warnings).toEqual([]); - }); - - // moddle resolves a reference that names an element in the document to that - // element, so this fixture declares one to cover the resolved path too. - it('a bpmn:loopDataInputRef naming a declared element imports as the collection', async () => { - const { node } = await importById( - oneNodeDoc('userTask', { - children: `${repeat('', 'lines')}`, - }), - 'T', - 'userTask', - ); - expect(node.loop).toEqual({ collection: 'lines' }); - }); - - const SECOND_WINS = - 'parseMultiInstanceLoopCharacteristics writes both into the same field ' + - 'and bpmn:loopDataInputRef second'; - const EXPRESSION_WINS = - 'parseMultiInstanceLoopCharacteristics stores an expression (a value ' + - 'containing "{") and a variable name in two fields, and ' + - 'MultiInstanceActivityBehavior.resolveNrOfInstances reads the expression ' + - 'field first'; - - it.each([ - ['two variable names', 'items', 'lines', 'lines', 'items', SECOND_WINS], - [ - 'two expressions', - '${items}', - '${lines}', - '${lines}', - '${items}', - SECOND_WINS, - ], - [ - 'an expression beside a variable name', - '${items}', - 'lines', - '${items}', - 'lines', - EXPRESSION_WINS, ], [ - 'a variable name beside an expression', - 'items', - '${lines}', - '${lines}', - 'items', - EXPRESSION_WINS, - ], - ])( - 'operaton:collection and bpmn:loopDataInputRef spelling %s import the one the engine iterates, and the drop is reported with the rule', - async (_title, setting, referenced, kept, dropped, rule) => { - const { node, warnings } = await importOnly( - repeatedTaskDoc( - `operaton:collection="${setting}"`, - `${referenced}`, + 'a bpmn:inputDataItem shadows operaton:elementVariable', + 'operaton:collection="lines" operaton:elementVariable="item"', + '', + { collection: 'lines', elementVariable: 'line' }, + [ + both( + 'bpmn:inputDataItem and operaton:elementVariable name what each run sees', + 'parseMultiInstanceLoopCharacteristics writes both into the same field and bpmn:inputDataItem second', + 'line', + 'item', ), - 'userTask', - ); - expect(node.loop).toEqual({ collection: kept }); - expect(warnings).toEqual([ - { - elementId: 'T', - category: 'extensionAttribute', - message: - 'Both operaton:collection and bpmn:loopDataInputRef name the ' + - `collection on 'T'; ${rule}, so '${kept}' was imported and ` + - `'${dropped}' was dropped.`, - }, - ]); - }, - ); - - const BARE_COMPLETION_WARNING = { - elementId: 'T', - category: 'unmappedConstruct', - message: - "The bpmn:completionCondition on 'T' is the bare text " + - '"nrOfCompletedInstances > 1" with no "${...}" or "#{...}" opener: ' + - 'parseMultiInstanceLoopCharacteristics hands it to createExpression as ' + - 'a literal and MultiInstanceActivityBehavior.completionConditionSatisfied ' + - 'throws expressionNotBooleanException when the first run completes; ' + - 'the script writes it inside "${...}", which evaluates it.', - }; - - it.each([ - ['a bare bpmn:completionCondition', '', [BARE_COMPLETION_WARNING]], + ], + ], [ - 'a bare bpmn:completionCondition carrying a language', - 'language="groovy"', + 'a bare bpmn:completionCondition carrying a language imports the text, warning about each thing the engine does with it', + 'operaton:collection="lines"', + 'nrOfCompletedInstances > 1', + { + collection: 'lines', + completionCondition: 'nrOfCompletedInstances > 1', + }, [ - BARE_COMPLETION_WARNING, - { - elementId: 'T', - category: 'unmappedConstruct', - message: - 'The language="groovy" on the bpmn:completionCondition of \'T\' ' + + on( + 'unmappedConstruct', + "The bpmn:completionCondition on 'T' is the bare text " + + '"nrOfCompletedInstances > 1" with no "${...}" or "#{...}" opener: ' + + 'parseMultiInstanceLoopCharacteristics hands it to createExpression as ' + + 'a literal and MultiInstanceActivityBehavior.completionConditionSatisfied ' + + 'throws expressionNotBooleanException when the first run completes; ' + + 'the script writes it inside "${...}", which evaluates it.', + ), + on( + 'unmappedConstruct', + 'The language="groovy" on the bpmn:completionCondition of \'T\' ' + 'was not imported: parseMultiInstanceLoopCharacteristics hands ' + 'the text alone to createExpression and reads no language, so ' + 'the imported step runs the same.', - }, + ), ], ], - ])( - '%s imports the text and warns about each thing the engine does with it', - async (_title, attrs, expected) => { - const { node, warnings } = await importOnly( - repeatedTaskDoc( - 'operaton:collection="lines"', - `nrOfCompletedInstances > 1`, - ), - 'userTask', - ); - expect(node.loop).toEqual({ - collection: 'lines', - completionCondition: 'nrOfCompletedInstances > 1', - }); - expect(warnings).toEqual(expected); - }, - ); - - // Every shape the grammar's ID terminal takes, the hyphen form included: the - // refusal beside it must not narrow what the clause can already write. - it.each(['line', '_line', 'l1', 'line-item'])( - 'an element variable spelled %s imports as written', - async (name) => { - const { node, warnings } = await importOnly( - repeatedTaskDoc( - `operaton:collection="lines" operaton:elementVariable="${name}"`, + [ + 'the older async spelling on the loop element carries asyncBefore and names the respelling', + 'operaton:collection="lines" operaton:async="true"', + '', + { collection: 'lines', asyncBefore: true }, + [ + on( + 'unmappedConstruct', + asyncRespellingMessage("the repetition of 'T'"), ), - 'userTask', - ); - expect(node.loop).toEqual({ - collection: 'lines', - elementVariable: name, - }); - expect(warnings).toEqual([]); - }, - ); - - it('a bpmn:inputDataItem shadows operaton:elementVariable, and the drop is reported', async () => { + ], + ], + ...( + [ + [ + 'a bpmn:loopDataOutputRef', + '', + 'results', + 'The bpmn:loopDataOutputRef on the repetition', + ], + [ + 'behavior="One"', + 'behavior="One"', + '', + 'The behavior="One" on the repetition', + ], + [ + 'an operaton:jobPriority', + 'operaton:jobPriority="10"', + '', + "The operaton:jobPriority on the repetition of 'T' was not imported: " + + 'Operaton does not read it, so the imported process runs the same.', + ], + [ + 'an attribute BPMN does not declare', + 'bogus="x"', + '', + "The 'bogus' attribute on 'T' is not declared by BPMN", + ], + ] as const + ).map( + ([what, attrs, children, message]): [ + string, + string, + string, + object, + unknown[], + ] => [ + `${what} on the repetition is reported as dropped`, + `operaton:collection="lines" ${attrs}`, + children, + { collection: 'lines' }, + [on('unmappedConstruct', expect.stringContaining(message))], + ], + ), + ])('%s', async (_title, attrs, children, loop, expected) => { const { node, warnings } = await importOnly( - repeatedTaskDoc( - 'operaton:collection="lines" operaton:elementVariable="item"', - '', - ), + repeatedTaskDoc(attrs, children), 'userTask', ); - expect(node.loop).toEqual({ collection: 'lines', elementVariable: 'line' }); - expectOneWarning(warnings, { - elementId: 'T', - category: 'extensionAttribute', - message: - "Both bpmn:inputDataItem and operaton:elementVariable name what each run sees on 'T'; " + - 'parseMultiInstanceLoopCharacteristics writes both into the same field and bpmn:inputDataItem second, ' + - "so 'line' was imported and 'item' was dropped.", - }); + expect(node.loop).toEqual(loop); + expect(warnings).toEqual(expected); }); - const IO_BLOCK = extensionElements( - ` - ok - `, - ); - - const countRefusalDetail = (body: string): string => - `its bpmn:loopCardinality is "${body}", which this tool cannot write back out unchanged; it ` + - 'writes a count as a plain whole number or as an expression, and this body is neither'; - - const elementNameRefusalDetail = (name: string): string => - `it names ${JSON.stringify(name)} for each run to see, which this tool cannot write back ` + - 'out unchanged; it writes that name as a plain identifier, and this name is not one'; - it.each([ [ 'neither a count nor a collection', @@ -2307,28 +1146,11 @@ describe('xmlToIr: imports a repetition', () => { repeatedTaskDoc('', ''), 'its bpmn:loopCardinality is empty, so Operaton has no number of runs to read', ], - [ - 'a bpmn:loopCardinality that is neither a whole number nor an expression', - repeatedTaskDoc( - '', - 'order.lines', - ), - countRefusalDetail('order.lines'), - ], [ 'a fractional bpmn:loopCardinality', repeatedTaskDoc('', '3.5'), - countRefusalDetail('3.5'), - ], - [ - 'a negative bpmn:loopCardinality', - repeatedTaskDoc('', '-1'), - countRefusalDetail('-1'), - ], - [ - 'a bpmn:loopCardinality carrying a leading plus', - repeatedTaskDoc('', '+3'), - countRefusalDetail('+3'), + 'its bpmn:loopCardinality is "3.5", which this tool cannot write back out unchanged; it ' + + 'writes a count as a plain whole number or as an expression, and this body is neither', ], [ 'an element variable with no collection', @@ -2338,25 +1160,23 @@ describe('xmlToIr: imports a repetition', () => { ), "it names 'line' for each run to see but no collection to take it from, and Operaton refuses to deploy that", ], - [ - 'an operaton:elementVariable outside the identifier the clause writes', - repeatedTaskDoc( - 'operaton:collection="lines" operaton:elementVariable="größe"', - ), - elementNameRefusalDetail('größe'), - ], [ 'a bpmn:inputDataItem name outside the identifier the clause writes', repeatedTaskDoc( 'operaton:collection="lines"', '', ), - elementNameRefusalDetail('my var'), + 'it names "my var" for each run to see, which this tool cannot write back ' + + 'out unchanged; it writes that name as a plain identifier, and this name is not one', ], [ 'an operaton:outputParameter on a repeated step', oneNodeDoc('userTask', { - children: `${IO_BLOCK}${repeat('operaton:collection="lines"')}`, + children: `${extensionElements( + ` + ok + `, + )}${repeat('operaton:collection="lines"')}`, }), 'it maps an \'operaton:outputParameter\', which BpmnParse.checkActivityOutputParameterSupported fails the deployment on ("operaton:outputParameter not allowed for multi-instance constructs")', ], @@ -2373,147 +1193,20 @@ describe('xmlToIr: imports a repetition', () => { ); }); - const retryChild = (cycle: string): string => - extensionElements( - ` ${cycle}`, - ); - const RUN_ATTRS = (prefix: 'operaton' | 'camunda'): string => - `${prefix}:asyncBefore="true" ${prefix}:asyncAfter="true" ${prefix}:exclusive="false"`; - const STEP_SETTINGS = { - asyncBefore: true, - asyncAfter: true, - exclusive: false, - jobPriority: '50', - retryCycle: 'R3/PT10M', - }; - const RUN_SETTINGS = { - asyncBefore: true, - asyncAfter: true, - exclusive: false, - retryCycle: 'R5/PT1M', - }; - - it.each([ - { - title: - "operaton: run settings on a loop element written after the step's own settings", - prefix: 'operaton' as const, - loopFirst: false, - loopAttrs: `operaton:collection="lines" ${RUN_ATTRS('operaton')}`, - loopChildren: retryChild('R5/PT1M'), - loop: { collection: 'lines', ...RUN_SETTINGS }, - warnings: [], - }, - { - title: - "camunda: run settings on a loop element written before the step's own settings", - prefix: 'camunda' as const, - loopFirst: true, - loopAttrs: `camunda:collection="lines" ${RUN_ATTRS('camunda')}`, - loopChildren: retryChild('R5/PT1M'), - loop: { collection: 'lines', ...RUN_SETTINGS }, - warnings: [], - }, - { - title: 'run flags written at their engine default carry nothing', - prefix: 'operaton' as const, - loopFirst: false, - loopAttrs: - 'operaton:collection="lines" operaton:asyncBefore="false" operaton:exclusive="true"', - loopChildren: '', - loop: { collection: 'lines' }, - warnings: [], - }, - { - title: - 'the older async spelling on the loop element carries asyncBefore and names the respelling', - prefix: 'operaton' as const, - loopFirst: false, - loopAttrs: 'operaton:collection="lines" operaton:async="true"', - loopChildren: '', - loop: { collection: 'lines', asyncBefore: true }, - warnings: [ - { - elementId: 'T', - category: 'unmappedConstruct', - message: asyncRespellingMessage("the repetition of 'T'"), - }, - ], - }, - { - title: - 'a job priority on the loop element draws the one ignored-content warning and reaches no field', - prefix: 'operaton' as const, - loopFirst: true, - loopAttrs: 'operaton:collection="lines" operaton:jobPriority="#{high}"', - loopChildren: '', - loop: { collection: 'lines' }, - warnings: [ - { - elementId: 'T', - category: 'unmappedConstruct', - message: - "The operaton:jobPriority on the repetition of 'T' was not " + - 'imported: Operaton does not read it, so the imported process runs the same.', - }, - ], - }, - ])( - 'the per-run settings import off the loop element: $title', - async ({ prefix, loopFirst, loopAttrs, loopChildren, loop, warnings }) => { - const loopEl = repeat(loopAttrs, loopChildren); - const stepChildren = retryChild('R3/PT10M'); - const imported = await importOnly( - oneNodeDoc('userTask', { - attrs: `${RUN_ATTRS(prefix)} ${prefix}:jobPriority="50"`, - children: loopFirst - ? `${loopEl}${stepChildren}` - : `${stepChildren}${loopEl}`, - doc: dualDoc, - }), - 'userTask', - ); - expect(imported.node).toEqual({ - kind: 'userTask', - id: 'T', - ...STEP_SETTINGS, - loop, - }); - expect(imported.warnings).toEqual([...warnings, CAMUNDA_ALIAS_WARNING]); - }, - ); - - it('a standard loop imports the step that runs once and reports the dropped element', async () => { - const { node, warnings } = await importOnly( - oneNodeDoc('serviceTask', { - id: 'RepeatSvc', - attrs: 'operaton:class="com.example.Svc"', - children: '', - }), - 'serviceTask', + const loopedHandler = (loop: string, roots = ''): string => + handlerDoc( + '', + { roots }, + ).replace( + '', + `\n ${loop}`, ); - expect(node.loop).toBeUndefined(); - expect(warnings).toEqual([ - { - elementId: 'RepeatSvc', - category: 'unmappedConstruct', - message: - "The bpmn:standardLoopCharacteristics on 'RepeatSvc' was not " + - 'imported: Operaton does not run one at all, it deploys the step ' + - 'and runs it once, so the imported step runs once too.', - }, - ]); - }); it('an event handler carrying a multi-instance repetition is refused', async () => { - await expectRefusal( + await expectRefusal( xmlToIr( - handlerDoc('', { - startAttrs: 'name="Escalate"', - }).replace( - '', - '\n ' + - repeat('', '2'), + loopedHandler( + repeat('', '2'), ), ), UnsupportedLoopCharacteristicsError, @@ -2521,19 +1214,14 @@ describe('xmlToIr: imports a repetition', () => { ); }); - it('an event handler carrying a standard loop imports as a step that runs once and reports the dropped element, same as any other host', async () => { + it('an event handler carrying a standard loop imports as a step that runs once, same as any other host', async () => { const { ir, warnings } = await xmlToIr( - handlerDoc( - '', - { roots: ' \n' }, - ).replace( - '', - '\n ' + - '', + loopedHandler( + '', + ' \n', ), ); - const handler = ir.flowElements.find((el) => el.id === 'Handler'); - expect(handler?.kind === 'subProcess' && handler.loop).toBeUndefined(); + expect(subProcess(ir, 'Handler').loop).toBeUndefined(); expect(warnings).toEqual([ { elementId: 'Handler', @@ -2545,78 +1233,7 @@ describe('xmlToIr: imports a repetition', () => { }, ]); }); - - it.each([ - [ - 'a bpmn:loopDataOutputRef', - '', - 'results', - 'The bpmn:loopDataOutputRef on the repetition', - ], - [ - 'a bpmn:loopDataOutputRef naming an element in the document', - '', - 'S', - 'The bpmn:loopDataOutputRef on the repetition', - ], - [ - 'a bpmn:outputDataItem', - '', - '', - "A bpmn:outputDataItem 'Item' on 'T' was not imported", - ], - [ - 'a bpmn:complexBehaviorDefinition', - '', - '', - "A bpmn:complexBehaviorDefinition 'CBD' on 'T' was not imported", - ], - [ - 'behavior="One"', - 'behavior="One"', - '', - 'The behavior="One" on the repetition', - ], - [ - 'a oneBehaviorEventRef', - 'oneBehaviorEventRef="throwIt"', - '', - 'The bpmn:oneBehaviorEventRef on the repetition', - ], - [ - 'a noneBehaviorEventRef', - 'noneBehaviorEventRef="skipIt"', - '', - 'The bpmn:noneBehaviorEventRef on the repetition', - ], - [ - 'an operaton:jobPriority', - 'operaton:jobPriority="10"', - '', - "The operaton:jobPriority on the repetition of 'T' was not imported: " + - 'Operaton does not read it, so the imported process runs the same.', - ], - [ - 'an attribute BPMN does not declare', - 'bogus="x"', - '', - "The 'bogus' attribute on 'T' is not declared by BPMN", - ], - ])( - '%s on the repetition is reported as dropped', - async (_title, attrs, children, message) => { - const { warnings } = await importOnly( - repeatedTaskDoc(`operaton:collection="lines" ${attrs}`, children), - 'userTask', - ); - expectOneWarning(warnings, { - elementId: 'T', - category: 'unmappedConstruct', - message, - }); - }, - ); -}); +}); describe('xmlToIr: a #{...} body the printer spells bare is rebuilt inside ${...}, and says so', () => { const loopDoc = (children: string): string => @@ -2629,118 +1246,20 @@ describe('xmlToIr: a #{...} body the printer spells bare is rebuilt inside ${... 'body as bare DSL and the rebuilt document writes it inside "${...}", ' + 'which Operaton evaluates identically.'; - const externalTaskErrorDoc = operatonDefs` - - - - - - - - - - - `; - it.each([ [ 'a loop cardinality', loopDoc('#{lineCount}'), rewrapped('bpmn:loopCardinality', 'T'), ], - [ - 'a completion condition', - loopDoc('#{done}'), - rewrapped('bpmn:completionCondition', 'T'), - ], - [ - 'a sequence flow condition', - bpmnDoc` - - - - - - #{amount > 1000} - - - `, - rewrapped('bpmn:conditionExpression', 'F2'), - ], - [ - 'a conditional trigger', - oneNodeDoc('intermediateCatchEvent', { - children: ` - #{stock < 5} - `, - doc: bpmnDoc, - }), - rewrapped('bpmn:condition', 'T'), - ], - [ - 'an external task error mapping', - externalTaskErrorDoc, - rewrapped('operaton:errorEventDefinition expression', 'Charge'), - ], ] as const)('%s reports the rewrap', async (_title, xml, message) => { const { warnings } = await xmlToIr(xml); expect(warnings.map((w) => w.message)).toEqual([message]); expect(warnings.map((w) => w.category)).toEqual(['unmappedConstruct']); }); - - // A body outside the printer's bare subset prints as a quoted raw template - // with its opener, so it comes back character for character. - it.each(['${lineCount}', '${a} #{b}', '#{bean.count()}'])( - 'a cardinality the printer keeps as written imports verbatim and reports nothing: %s', - async (body) => { - const { node, warnings } = await importOnly( - loopDoc(`${body}`), - 'userTask', - ); - expect(node.loop).toEqual({ collection: 'lines', cardinality: body }); - expect(warnings).toEqual([]); - }, - ); - - it.each([ - [ - 'a job priority', - oneNodeDoc('userTask', { attrs: 'operaton:jobPriority="#{high}"' }), - { jobPriority: '#{high}' }, - ], - [ - 'a job priority on a gateway', - oneNodeDoc('exclusiveGateway', { - attrs: 'operaton:jobPriority="#{high}"', - }), - { jobPriority: '#{high}' }, - ], - [ - 'a user task priority', - oneNodeDoc('userTask', { attrs: 'operaton:priority="#{high}"' }), - { priority: '#{high}' }, - ], - [ - 'a call activity version binding', - oneNodeDoc('callActivity', { - attrs: - 'calledElement="sub" operaton:calledElementBinding="version" ' + - 'operaton:calledElementVersion="#{v}"', - }), - { binding: { kind: 'version', version: '#{v}' } }, - ], - ] as const)( - '%s opening with #{ is carried as written and reports nothing', - async (_title, xml, carried) => { - const { ir, warnings } = await xmlToIr(xml); - expect(warnings).toEqual([]); - expect(byId(ir, 'T')).toMatchObject(carried); - }, - ); }); describe('xmlToIr: a scripted condition on a sequence flow or a conditional event definition', () => { - /** `S -> X -> {T, E}`, `F2` carrying the condition under test. */ const flowDoc = ( conditionAttrs: string, doc: XmlTag = operatonDoc, @@ -2762,29 +1281,6 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even ir.sequenceFlows.find((f) => f.id === 'F2')?.conditionExpression; it.each([ - [ - 'language alone names the language the engine would run it in', - 'language="groovy"', - operatonDoc, - 'it declares language="groovy", which Operaton runs in a script ' + - 'engine rather than evaluating as UEL', - ], - [ - 'language with operaton:resource names both, since the resource is what actually runs', - 'language="groovy" operaton:resource="deployment://check.groovy"', - operatonDoc, - 'it declares language="groovy" with ' + - 'operaton:resource="deployment://check.groovy", which Operaton ' + - 'runs as that deployed script rather than the body written here', - ], - [ - 'language with camunda:resource behaves the same as the operaton: spelling', - 'language="groovy" camunda:resource="deployment://check.groovy"', - camundaDoc, - 'it declares language="groovy" with ' + - 'operaton:resource="deployment://check.groovy", which Operaton ' + - 'runs as that deployed script rather than the body written here', - ], [ 'an unprefixed xsi:type dropped by moddle still reads language and resource, since parseConditionExpression resolves it against BPMN20_NS regardless', 'xsi:type="tFormalExpression" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ' + @@ -2802,37 +1298,6 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even ); }); - it.each([ - [ - 'operaton:resource', - 'operaton:resource="deployment://check.groovy"', - operatonDoc, - ], - [ - 'camunda:resource', - 'camunda:resource="deployment://check.groovy"', - camundaDoc, - ], - ] as const)( - 'a lone %s imports the inline expression and reports the dropped resource', - async (_prefix, attrs, doc) => { - const { ir, warnings } = await xmlToIr(flowDoc(attrs, doc)); - expect(conditionOf(ir)).toBe('${amount > 1000}'); - expect(warnings).toEqual([ - { - elementId: 'F2', - category: 'extensionAttribute', - message: - "The 'operaton:resource' setting on 'F2' only takes effect " + - 'alongside a language attribute; on its own the condition ' + - 'runs as the expression written in the body, and the ' + - 'attribute was not imported.', - }, - ...(doc === camundaDoc ? [CAMUNDA_ALIAS_WARNING] : []), - ]); - }, - ); - it.each([ ['xsi:type="bpmn:tExpression"', 'bpmn:tExpression'], [ @@ -2858,7 +1323,6 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even ); it.each([ - ['no xsi:type at all', flowDoc('')], [ 'an unprefixed xsi:type="tFormalExpression" in a bpmn:-prefixed document, which moddle drops and parseConditionExpression resolves against BPMN20_NS', flowDoc( @@ -2892,31 +1356,16 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even }, ); - it('a whitespace-only recovered body imports as no condition, as moddle imports one', async () => { - const { ir, warnings } = await xmlToIr( - flowDoc( - 'xsi:type="tFormalExpression" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"', - operatonDoc, - ' ', - ), - ); - expect(conditionOf(ir)).toBeUndefined(); - expect(warnings).toEqual([ - { - elementId: 'F2', - category: 'unmappedConstruct', - message: - "The condition on 'F2' has an empty body: " + - 'UelExpressionCondition.evaluate reads it as a string and fails ' + - 'the flow on every run ("condition expression returns ' + - 'non-Boolean"); the flow was imported with no condition.', - }, - ]); - }); + const EMPTY_BODY = + "The condition on 'F2' has an empty body: " + + 'UelExpressionCondition.evaluate reads it as a string and fails the ' + + 'flow on every run ("condition expression returns non-Boolean"); ' + + 'the flow was imported with no condition.'; it.each([ [ 'a body with no "${" or "#{" opener', + 'bpmn:tFormalExpression', 'amount > 1000', 'amount > 1000', 'The condition on \'F2\' is the bare text "amount > 1000" with no ' + @@ -2925,21 +1374,20 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even 'expression returns non-Boolean"); the script writes it inside ' + '"${...}", which evaluates it.', ], + ['an empty body', 'bpmn:tFormalExpression', '', undefined, EMPTY_BODY], [ - 'an empty body', - '', + 'a whitespace-only body recovered past moddle, as moddle imports one', + 'tFormalExpression', + ' ', undefined, - "The condition on 'F2' has an empty body: " + - 'UelExpressionCondition.evaluate reads it as a string and fails the ' + - 'flow on every run ("condition expression returns non-Boolean"); ' + - 'the flow was imported with no condition.', + EMPTY_BODY, ], ])( 'a condition with %s warns that the engine fails the flow on every run', - async (_title, body, imported, message) => { + async (_title, xsiType, body, imported, message) => { const { ir, warnings } = await xmlToIr( flowDoc( - 'xsi:type="bpmn:tFormalExpression" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"', + `xsi:type="${xsiType}" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`, operatonDoc, body, ), @@ -2982,13 +1430,6 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even }, ], ], - [ - 'a conditioned flow named exactly its condition text', - 'name="amount > 1000"', - '', - [], - ], - ['an unconditioned flow named the empty string', '', 'name=""', []], ])( '%s reports the label only when the rebuilt document would lose it', async (_title, flowAttrs, plainFlowAttrs, expected) => { @@ -3005,8 +1446,6 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even }, ); - // `BpmnParse.parseConditionExpression` reads a conditional event - // definition's condition the same way it reads a flow's, at every position. const conditionalDoc = ( conditionAttrs: string, position: 'boundary' | 'event-subprocess start' | 'intermediate catch', @@ -3035,19 +1474,7 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even }); }; - it.each([ - ['a boundary conditional event', 'boundary', 'Owner'], - [ - 'an event sub-process start conditional trigger', - 'event-subprocess start', - 'HStart', - ], - [ - 'an intermediate catch conditional trigger', - 'intermediate catch', - 'Owner', - ], - ] as const)( + it.each([['a boundary conditional event', 'boundary', 'Owner']] as const)( '%s refuses a language and warns on a lone operaton:resource on its bpmn:condition', async (_title, position, ownerId) => { await expectRefusal( @@ -3079,34 +1506,6 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even }); describe('xmlToIr: warns for dropped extension attributes', () => { - it.each([ - ['operaton', operatonDoc], - ['camunda', camundaDoc], - ] as const)( - 'a %s:formHandlerClass is reported against the task carrying it, while the assignee beside it is read', - async (prefix, doc) => { - const { node, warnings } = await importOnly( - oneNodeDoc('userTask', { - id: 'FormHandlerTask', - attrs: - `name="Form Handler Task" ${prefix}:assignee="alice" ` + - `${prefix}:formHandlerClass="com.example.FormHandler"`, - doc, - }), - 'userTask', - ); - expect(node.assignee).toBe('alice'); - expectOneWarning(extensionWarnings(warnings), { - elementId: 'FormHandlerTask', - category: 'extensionAttribute', - message: 'formHandlerClass', - }); - expect(unmappedWarnings(warnings)).toEqual( - prefix === 'camunda' ? [CAMUNDA_ALIAS_WARNING] : [], - ); - }, - ); - it('does NOT warn for the supported assignee/formKey/class attributes', async () => { const xml = operatonDoc` @@ -3119,7 +1518,6 @@ describe('xmlToIr: warns for dropped extension attributes', () => { expect(warnings).toEqual([]); }); - /** `start -> E`, with the given attributes on the process and on its start. */ const headerDoc = ( processAttrs: string, startAttrs = '', @@ -3131,50 +1529,13 @@ describe('xmlToIr: warns for dropped extension attributes', () => { `; - // Each is declared in the moddle extension, so it parses into a typed - // property (not `$attrs`) that the raw-attribute sweep never sees. - it.each([ - [ - 'a process keeps the history time to live it authored', - 'operaton:historyTimeToLive="P90D"', - '', - (ir: BpmnProcess) => ir.historyTimeToLive, - 'P90D', - ], - [ - 'the value the exporter stamps for a process that authored none reads as unwritten', - `operaton:historyTimeToLive="${HISTORY_TIME_TO_LIVE}"`, - '', - (ir: BpmnProcess) => ir.historyTimeToLive, - undefined, - ], - [ - 'a process keeps the users allowed to start it', - 'operaton:candidateStarterUsers="demo,manager"', - '', - (ir: BpmnProcess) => ir.candidateStarterUsers, - 'demo,manager', - ], - [ - 'a process keeps the groups allowed to start it', - 'operaton:candidateStarterGroups="adjusters"', - '', - (ir: BpmnProcess) => ir.candidateStarterGroups, - 'adjusters', - ], - [ - 'a start keeps the variable the engine writes the starting user into', - '', - 'operaton:initiator="claimant"', - (ir: BpmnProcess) => only(ir, 'startEvent').initiator, - 'claimant', - ], - ])('%s', async (_title, processAttrs, startAttrs, read, expected) => { - const { ir, warnings } = await xmlToIr(headerDoc(processAttrs, startAttrs)); - expect([read(ir), warnings]).toEqual([expected, []]); + it('a process keeps the history time to live it authored', async () => { + const { ir, warnings } = await xmlToIr( + headerDoc('operaton:historyTimeToLive="P90D"'), + ); + expect([ir.historyTimeToLive, warnings]).toEqual(['P90D', []]); }); - /** `S -> E` under a process carrying the given attributes and extension children. */ const starterDoc = (processAttrs: string, children: string): string => operatonDefs` @@ -3251,89 +1612,73 @@ ${children} }, ); - it.each([ - { - title: - "async and an operaton:in on a thrown signal's definition name parseSignalEventDefinition", - xml: rootedDoc( - ' \n', - ` - + const signalDoc = (tag: string, defAttrs = ''): string => + rootedDoc( + ' \n', + ` + - + - - `, - operatonDefs, - ), + + `, + operatonDefs, + ); + + it.each([ + { + title: + 'a retry cycle written as an attribute says the engine reads the element form alone', + xml: oneNodeDoc('serviceTask', { + id: 'Work', + attrs: + 'operaton:class="com.example.W" operaton:failedJobRetryTimeCycle="R3/PT1M"', + }), + warnings: [ + { + elementId: 'Work', + category: 'extensionAttribute', + message: + "The 'operaton:failedJobRetryTimeCycle' setting on 'Work' was " + + 'not imported: Operaton reads a retry cycle as an ' + + ' element and never as an ' + + 'attribute (DefaultFailedJobParseListener.' + + 'setFailedJobRetryTimeCycleValue through ' + + 'BpmnParseUtil.findOperatonExtensionElement), so the document ' + + 'written back runs the same.', + }, + ], + }, + { + title: + "async and an operaton:in on a thrown signal's definition name parseSignalEventDefinition", + xml: signalDoc('intermediateThrowEvent', 'operaton:async="true"'), warnings: [ unread( "'operaton:async' setting", - 'Throw', + 'Ev', 'a ', 'reads it on a thrown signal as its async delivery ' + '(BpmnParse.parseSignalEventDefinition)', ), unread( "operaton:in 'a'", - 'Throw', + 'Ev', 'a ', 'reads it on a thrown signal as its payload ' + '(BpmnParse.parseSignalEventDefinition through parseInputParameter)', ), ], }, - { - title: - "a form key on the process's own start names parseStartFormHandlers", - xml: headerDoc('', 'operaton:formKey="embedded:app:forms/start.html"'), - warnings: [ - unread( - "'operaton:formKey' setting", - 'S', - 'a ', - "reads it on the process's own start (BpmnParse.parseStartFormHandlers)", - ), - ], - }, { title: "an operaton:in on a caught signal's definition reads nowhere, since parseSignalEventDefinition only reads it on a throw", - xml: rootedDoc( - ' \n', - ` - - - - - - - - - `, - operatonDefs, - ), - warnings: [ - unread("operaton:in 'a'", 'Wait', 'a '), - ], - }, - { - title: - "a form key on a nested start reads nowhere, since parseStartFormHandlers runs for the process's own start alone", - xml: operatonDoc` - - - - - - - - `, + xml: signalDoc('intermediateCatchEvent'), warnings: [ - unread("'operaton:formKey' setting", 'Inner', 'a '), + unread("operaton:in 'a'", 'Ev', 'a '), ], }, { @@ -3363,1210 +1708,456 @@ ${children} ), ], }, - { - title: - 'a retry cycle written as an attribute says the engine reads the element form alone', - xml: oneNodeDoc('serviceTask', { - id: 'Work', - attrs: - 'operaton:class="com.example.W" operaton:failedJobRetryTimeCycle="R3/PT1M"', - }), - warnings: [ - { - elementId: 'Work', - category: 'extensionAttribute', - message: - "The 'operaton:failedJobRetryTimeCycle' setting on 'Work' was " + - 'not imported: Operaton reads a retry cycle as an ' + - ' element and never as an ' + - 'attribute (DefaultFailedJobParseListener.' + - 'setFailedJobRetryTimeCycleValue through ' + - 'BpmnParseUtil.findOperatonExtensionElement), so the document ' + - 'written back runs the same.', - }, - ], - }, - { - title: - 'a setting no engine method reads says only that nothing carries it', - xml: headerDoc('', 'operaton:mystery="1"'), - warnings: [ - unread("'operaton:mystery' setting", 'S', 'a '), - ], - }, ])('$title', async ({ xml, warnings: expected }) => { const { warnings } = await xmlToIr(xml); expect(warnings).toEqual(expected); }); - // The printer leaves out a start under the id this tool mints for its - // container, and the unread-attribute sweep does not cover what goes with it. it("a process's own start printing no statement says its initiator went with it", async () => { const { warnings } = await xmlToIr( headerDoc('', 'operaton:initiator="claimant"', 'StartEvent_p'), ); expect(warnings).toEqual([ - { - elementId: 'StartEvent_p', - category: 'extensionAttribute', - message: - "The 'operaton:initiator' setting on 'StartEvent_p' was not " + - "written to the script: 'StartEvent_p' is the kind of name this " + - 'tool generates for itself, which a script cannot repeat, so this ' + - 'start is left out entirely and its initiator with it. Rename it ' + - 'in the diagram to keep the initiator.', - }, - ]); - }); -}); - -describe('xmlToIr: warns for dropped lanes', () => { - const lanesXml = bpmnDoc` - - S - - - E - - - - - `; - - it('surfaces one lane warning per lane, naming the lane, and still imports the body', async () => { - const { ir, warnings } = await xmlToIr(lanesXml); - expect(ir.flowElements.map((fe) => fe.id)).toEqual(['S', 'E']); - expect(warnings.map((w) => [w.category, w.elementId])).toEqual([ - ['lane', 'Lane_Sales'], - ['lane', 'Lane_Support'], - ]); - expect(warnings[0].message).toContain('Sales'); - }); - - it('names a lane nested in a childLaneSet as well as the lane holding it', async () => { - const xml = bpmnDoc` - - - - - - - - - `; - - const { warnings } = await xmlToIr(xml); - const laneWarnings = warnings.filter( - (w: ImportWarning) => w.category === 'lane', - ); - expect(laneWarnings.map((w) => w.elementId)).toEqual([ - 'Lane_Outer', - 'Lane_Inner', - ]); - expect(laneWarnings[1].message).toContain('Dispatch'); - }); -}); - -describe('xmlToIr: an empty extensionElements is not flagged beside a real drop', () => { - // A document-level "unparsable content" boolean could not tell the stray - // empty block from the real drop; the typed extension elements can. - it('reports the field against the service task alone, because an operaton:expression binding never receives an injected field, and reads the assignee off the clean task', async () => { - const { ir, warnings } = await xmlToIr( - operatonDoc` - - - - - - - - - - - - `, - ); - - const clean = byId(ir, 'CleanTask'); - expect(clean.kind === 'userTask' && clean.assignee).toBe('alice'); - expectOneWarning(warnings, { - elementId: 'ConfiguredSvc', - category: 'extensionAttribute', - message: "'greeting'", - }); - }); -}); - -describe('xmlToIr: undeclared operaton extension element residual', () => { - const residualXml = operatonDoc` - - - - - - - - - - - - `; - - it('reports the undeclared element once (no silent loss) without flagging the clean task', async () => { - const { warnings } = await xmlToIr(residualXml); - // The process id is the coarse attribution for a residual drop moddle - // cannot tie to a specific step. - expectOneWarning(extensionWarnings(warnings), { - elementId: 'p', - message: /formProperty/i, - }); - expect(warnings.some((w) => w.elementId === 'CleanTask')).toBe(false); - }); - - it('reports extension content on a referenced root element, attributed to the root', async () => { - const { warnings } = await xmlToIr( - operatonDefs` - - - http://x - - - - - - - - - - `, - ); - expect(extensionWarnings(warnings)).toEqual([ - unread('operaton:inputOutput', 'Message_1', 'a '), - ]); - }); -}); - -describe('xmlToIr: bpmn:documentation', () => { - const doc = (text: string): string => - `${text}`; - - /** Every `(id, documentation)` the IR holds, the process itself included. */ - const documented = (ir: BpmnProcess): [string, string][] => { - const carried: [string, string][] = []; - const visit = (node: BpmnProcess | FlowElement): void => { - if ('documentation' in node && node.documentation !== undefined) { - carried.push([node.id, node.documentation]); - } - if ('flowElements' in node) node.flowElements.forEach(visit); - }; - visit(ir); - return carried; - }; - - const reported = (warnings: ImportWarning[]): unknown[] => - warnings.map((w) => [w.category, w.elementId, w.message]); - - const expected = ( - rows: readonly (readonly [ImportWarning['category'], string, string])[], - ): unknown[] => - rows.map(([category, id, detail]) => [ - category, - id, - expect.stringContaining(detail), - ]); - - const TIMER = ` - P1D - `; - - /** - * One row per position `bpmn:documentation` can sit on: what the IR carries - * and the whole warning list, so a position wired to neither fails both. - */ - const POSITIONS: [ - title: string, - xml: string, - carried: readonly (readonly [string, string])[], - warned: readonly (readonly [ImportWarning['category'], string, string])[], - ][] = [ - [ - 'a process, its start and its end each carry their own', - bpmnDoc` ${doc('Onboarding, end to end.')} - ${doc('Fires when HR files the request.')} - ${doc('The hire is on the payroll.')} - `, - [ - ['p', 'Onboarding, end to end.'], - ['S', 'Fires when HR files the request.'], - ['E', 'The hire is on the payroll.'], - ], - [], - ], - ...( - [ - ['a user task', 'userTask', '', ''], - [ - 'a service task', - 'serviceTask', - 'operaton:class="com.example.Svc"', - '', - ], - ['a send task', 'sendTask', 'operaton:class="com.example.Svc"', ''], - [ - 'a business rule task', - 'businessRuleTask', - 'operaton:class="com.example.Svc"', - '', - ], - [ - 'a script task', - 'scriptTask', - 'scriptFormat="javascript"', - 'total = 1;', - ], - ['a receive task', 'receiveTask', '', ''], - ['a step', 'task', '', ''], - ['a call activity', 'callActivity', 'calledElement="other"', ''], - [ - 'a subprocess', - 'subProcess', - '', - ` - - `, - ], - ['a branch point', 'exclusiveGateway', '', ''], - ['a fork', 'inclusiveGateway', '', ''], - ['a parallel fork', 'parallelGateway', '', ''], - ] as const - ).map(([subject, tag, attrs, extra]): (typeof POSITIONS)[number] => [ - `${subject} carries it`, - oneNodeDoc(tag, { attrs, children: `${doc(`On ${tag}.`)}${extra}` }), - [['T', `On ${tag}.`]], - [], - ]), - [ - "the text is carried verbatim, the body's own whitespace included", - oneNodeDoc('userTask', { - children: - '\n Two lines.\n ', - }), - [['T', '\n Two lines.\n ']], - [], - ], - [ - 'an empty documentation body carries as an empty string', - oneNodeDoc('userTask', { - children: '', - }), - [['T', '']], - [], - ], - [ - 'an event handler reports it, and the trigger start under it carries its own', - bpmnDefs` - - - - ${doc('Runs when the deadline passes.')} - ${doc('The deadline itself.')} - ${TIMER} - - - - - `, - [['HStart', 'The deadline itself.']], - [['documentation', 'Handler', 'an event handler has no documentation']], - ], - [ - 'a start and an end whose ids this tool writes for itself carry it and report that no script can spell it back', - bpmnDoc` ${doc('Where it begins.')} - ${doc('Where it stops.')} - `, - [ - ['StartEvent_p', 'Where it begins.'], - ['EndEvent_p', 'Where it stops.'], - ], - [ - [ - 'documentation', - 'StartEvent_p', - 'this start is left out entirely and its documentation with it', - ], - [ - 'documentation', - 'EndEvent_p', - 'Where the script can do without this end, it is left out and its documentation with it', - ], - ], - ], - [ - 'the definitions root reports it against the process', - bpmnDefs` ${doc('Exported by hand.')} - - - - - `, - [], - [['documentation', 'p', 'the definitions root has no documentation']], - ], - [ - 'an Error, an Escalation, a Message and a Signal root each report it', - bpmnDefs` ${doc('Raised by the vendor.')} - ${doc('Raised on day three.')} - ${doc('Sent by billing.')} - ${doc('Broadcast by the warehouse.')} - - - - - `, - [], - [ - ['unreferencedRoot', 'Msg', 'is never used by an on/throw/emit'], - ['unreferencedRoot', 'Sig', 'is never used by an on/throw/emit'], - ['documentation', 'Err', 'a bpmn:error root has no documentation'], - ['documentation', 'Esc', 'a bpmn:escalation root has no documentation'], - ['documentation', 'Msg', 'a bpmn:message root has no documentation'], - ['documentation', 'Sig', 'a bpmn:signal root has no documentation'], - ], - ], - [ - 'a repetition reports it, and the step it repeats carries its own', - oneNodeDoc('userTask', { - children: `${doc('Review one application.')} - ${doc('Once per applicant.')} - 3 - `, - }), - [['T', 'Review one application.']], - [['documentation', 'T', 'a repetition has no documentation']], - ], - [ - 'a sequence flow reports it', - bpmnDoc` - - ${doc('Straight through.')}`, - [], - [['documentation', 'F1', 'a sequence flow has no documentation']], - ], - [ - 'a boundary event reports it', - bpmnDoc` - - ${doc('Give up after a day.')} - ${TIMER} - - - - - - `, - [], - [['documentation', 'B', 'a boundary event has no documentation']], - ], - [ - 'an await reports it, and so does the event definition inside it', - bpmnDoc` - ${doc('Wait a day.')} - ${doc('The day itself.')} - P1D - - - - - `, - [], - [ - ['documentation', 'Await', 'an event definition has no documentation'], - ['documentation', 'Await', 'an await has no documentation'], - ], - ], - [ - 'an emit reports it, and so does the event definition inside it', - bpmnDefs` - - - ${doc('Tell the warehouse.')} - ${doc('The broadcast itself.')} - - - - - `, - [], - [ - ['documentation', 'Emit', 'an event definition has no documentation'], - ['documentation', 'Emit', 'an emit has no documentation'], - ], - ], - [ - 'a throw reports it, and so does the event definition inside it', - bpmnDefs` - - - ${doc('Give up here.')} - ${doc('The error itself.')} - - - `, - [], - [ - ['documentation', 'Throw', 'an event definition has no documentation'], - ['documentation', 'Throw', 'a throw has no documentation'], - ], - ], - [ - 'an end carrying a terminate definition carries its own and reports the definition', - bpmnDoc` - ${doc('Nothing else runs.')} - ${doc('The terminate itself.')} - - `, - [['Stop', 'Nothing else runs.']], - [['documentation', 'Stop', 'an event definition has no documentation']], - ], - ]; - - it.each(POSITIONS)('%s', async (_title, xml, carried, warned) => { - const { ir, warnings } = await xmlToIr(xml); - expect(documented(ir)).toEqual(carried); - expect(reported(warnings)).toEqual(expected(warned)); - }); - - it.each([ - [ - 'a second documentation child leaves the element with none', - `${doc('The first.')}${doc('The second.')}`, - "this surface holds one and 'T' carries 2", - ], - [ - 'a textFormat naming anything but plain text leaves the element with none', - '<p>Hi</p>', - "this surface holds plain text and its textFormat is 'text/html'", - ], - ])('%s', async (_title, children, detail) => { - const { ir, warnings } = await xmlToIr( - oneNodeDoc('userTask', { children }), - ); - expect(documented(ir)).toEqual([]); - expect(reported(warnings)).toEqual( - expected([['documentation', 'T', detail]]), - ); - }); -}); - -describe('xmlToIr: warns for unmapped BPMN content', () => { - const reviewTaskDoc = (children: string) => - oneNodeDoc('userTask', { id: 'Review', children, doc: bpmnDoc }); - - it.each([ - [ - 'an artifact against the container holding it', - bpmnDoc` - - - - Check the customer tier first. - - - `, - [ - ['p', "bpmn:textAnnotation 'Note_1'"], - ['p', "bpmn:association 'Assoc_1'"], - ['p', "bpmn:group 'Group_1'"], - ], - ], - [ - 'an artifact inside a sub-process against that sub-process', - bpmnDoc` - - - - - - inner note - - - - - `, - [['Sub', "bpmn:textAnnotation 'Note_Inner'"]], - ], - [ - 'an activity ioSpecification and every property on it', - reviewTaskDoc(` - - - - - - `), - [ - ['Review', "bpmn:ioSpecification 'IO_1'"], - ['Review', "bpmn:property 'Prop_1'"], - ['Review', "bpmn:property 'Prop_2'"], - ], - ], - [ - 'a data association on a user task, and a resource assignment on a service task, where the engine reads none', - operatonDoc` - - - - - - - managers - - - - - - - `, - [ - ['Review', "bpmn:dataOutputAssociation 'DataOut_1'"], - ['Svc', "bpmn:potentialOwner 'Owner_1'"], - ], - ], - [ - 'an attribute BPMN does not declare, against each element carrying it', - bpmnDoc` - - - - `, - [ - ['S', "The 'wobble' attribute on 'S'"], - ['Review', "The 'wobble' attribute on 'Review'"], - ], - ], - ] as const)( - // Each expected message names the construct's own id, so siblings of one - // kind stay tellable apart. - 'reports %s', - async (_title, xml, expected) => { - const warnings = unmappedWarnings((await xmlToIr(xml)).warnings); - expect(warnings.map((w) => [w.elementId, w.message])).toEqual( - expected.map(([id, text]) => [id, expect.stringContaining(text)]), - ); - }, - ); - - it('leaves a foreign-namespace attribute on a mapped element unreported', async () => { - const xml = ` - - - - - - - - -`; - - const { warnings } = await xmlToIr(xml); - expect(warnings).toEqual([]); - }); - - it.each([ - [ - 'reports a root element the transform does not handle', - ' ', - { elementId: 'Store_1', message: "bpmn:dataStore 'Store_1'" }, - ], - [ - 'reports content on bpmn:definitions itself against the process', - ' ', - { elementId: 'p', message: 'bpmn:extension' }, - ], - ])('%s', async (_title, root, expected) => { - const xml = bpmnDefs`${root} - - - - - `; - - expectOneWarning(unmappedWarnings((await xmlToIr(xml)).warnings), expected); - }); - - it('reports isExecutable="false", which imports as executable regardless', async () => { - const xml = bpmnDefs` - - - - `; - - const { ir, warnings } = await xmlToIr(xml); - expect(ir.isExecutable).toBe(true); - - const reported = unmappedWarnings(warnings); - expectOneWarning(reported, { - elementId: 'p', - message: 'isExecutable="false"', - }); - expect(reported[0].message).toMatch(/deploy/i); - }); -}); - -describe('xmlToIr: a data object, its reference, or a data store reference is dropped, not refused', () => { - const DATA_OBJECT = - ''; - const DATA_OBJECT_REF = ''; - const DATA_STORE_REF = ''; - - /** No `bpmn:sequenceFlow` names the data element: nothing can point at one. */ - const atProcessLevel = (el: string) => - operatonDoc` - ${el} - - `; - - const insideSubProcess = (el: string) => - operatonDoc` - - - ${el} - - - - - - `; - - const dropped = (tag: string) => - `A bpmn:${tag} 'Data1' was not imported: Operaton keeps process ` + - 'variables in its own store and never dispatches on it, so the ' + - 'imported process runs identically.'; - - it.each([ - [ - 'a dataObject with a dataState child at process level is reported once, whole', - atProcessLevel(DATA_OBJECT), - dropped('dataObject'), - ], - [ - 'a dataObject with a dataState child inside a sub-process is reported once, whole', - insideSubProcess(DATA_OBJECT), - dropped('dataObject'), - ], - [ - 'a dataObjectReference at process level is reported once, whole', - atProcessLevel(DATA_OBJECT_REF), - dropped('dataObjectReference'), - ], - [ - 'a dataObjectReference inside a sub-process is reported once, whole', - insideSubProcess(DATA_OBJECT_REF), - dropped('dataObjectReference'), - ], - [ - 'a dataStoreReference at process level is reported once, whole', - atProcessLevel(DATA_STORE_REF), - dropped('dataStoreReference'), - ], - [ - 'a dataStoreReference inside a sub-process is reported once, whole', - insideSubProcess(DATA_STORE_REF), - dropped('dataStoreReference'), - ], - ] as const)('%s', async (_title, xml, message) => { - const { warnings } = await xmlToIr(xml); - expect(warnings).toEqual([ - { elementId: 'Data1', category: 'unmappedConstruct', message }, - ]); - }); -}); - -describe("xmlToIr: a root of a kind this tool does not model reports its own children once, and no one else's", () => { - // `GlobalScriptTask.script` is declared `isAttr: true` in the moddle - // schema, but the BPMN XSD makes `` an element, so moddle - // cannot parse it and files it as residual "unparsable content" with no - // owning element attached, only a source position. - const GLOBAL_SCRIPT_TASK = - ' \n' + - ' x\n' + - ' '; - - const ROOT_DROP_NOTE = - '(this tool imports the executable flow and the engine settings on ' + - 'its steps, and nothing declared or drawn beside it).'; - - it.each([ - [ - 'a globalScriptTask root is reported once, and its own script child is not blamed on the process', - bpmnDefs`${GLOBAL_SCRIPT_TASK} - - - - - `, - [ - { - elementId: 'G3', - category: 'unmappedConstruct', - message: `A bpmn:globalScriptTask 'G3' root element was not imported ${ROOT_DROP_NOTE}`, - }, - ], - ], - [ - 'a globalScriptTask root written after the process is reported once, and its script child is still not blamed on the process', - bpmnDefs` - - - - - - -${GLOBAL_SCRIPT_TASK}`, - [ - { - elementId: 'G3', - category: 'unmappedConstruct', - message: `A bpmn:globalScriptTask 'G3' root element was not imported ${ROOT_DROP_NOTE}`, - }, - ], - ], - [ - 'a self-closing root before a globalScriptTask leaves it recognised as a root, so its script child is still not blamed on the process', - bpmnDefs` -${GLOBAL_SCRIPT_TASK} - - - - - `, - [ - { - elementId: 'G1', - category: 'unmappedConstruct', - message: `A bpmn:globalTask 'G1' root element was not imported ${ROOT_DROP_NOTE}`, - }, - { - elementId: 'G3', - category: 'unmappedConstruct', - message: `A bpmn:globalScriptTask 'G3' root element was not imported ${ROOT_DROP_NOTE}`, - }, - ], - ], - [ - 'a bpmn:name written as an element on a mapped task is still reported, with no unmapped root to hide behind', - bpmnDoc` - x - - - `, - [ - { - elementId: 'p', - category: 'extensionAttribute', - message: `Extra engine-specific configuration (bpmn:name at line 5) was not imported; it could not be attributed to a specific step.`, - }, - ], - ], - [ - 'unmapped roots beside a mapped task written with a bpmn:name element draw one warning each, neither root swallowing the task', - bpmnDefs`${GLOBAL_SCRIPT_TASK} - - - - x - - - - `, - [ - { - elementId: 'G3', - category: 'unmappedConstruct', - message: `A bpmn:globalScriptTask 'G3' root element was not imported ${ROOT_DROP_NOTE}`, - }, - { - elementId: 'G1', - category: 'unmappedConstruct', - message: `A bpmn:globalTask 'G1' root element was not imported ${ROOT_DROP_NOTE}`, - }, - { - elementId: 'p', - category: 'extensionAttribute', - message: `Extra engine-specific configuration (bpmn:name at line 9) was not imported; it could not be attributed to a specific step.`, - }, - ], - ], - ] as const)('%s', async (_title, xml, expected) => { - const { warnings } = await xmlToIr(xml); - expect(warnings).toEqual(expected); - }); -}); - -describe('xmlToIr: embedded sub-process imports recursively', () => { - const subProcessDoc = (children: string, attrs = '', doc = bpmnDoc) => - oneNodeDoc('subProcess', { id: 'Sub', attrs, children, doc }); - - const nestedSubProcessXml = subProcessDoc( - ` - - - - `, - 'name="Sub Process"', - operatonDoc, - ); - - it('maps to a recursive IR SubProcess carrying its own nested body, and leaks none of it into the parent', async () => { - const { ir, warnings } = await xmlToIr(nestedSubProcessXml); - expect(ir).toEqual( - around({ - kind: 'subProcess', - id: 'Sub', - name: 'Sub Process', - flowElements: [ - { kind: 'startEvent', id: 'SubStart' }, - { kind: 'userTask', id: 'Review', assignee: 'demo' }, - { kind: 'endEvent', id: 'SubEnd' }, - ], - sequenceFlows: [ - { id: 'SF1', sourceRef: 'SubStart', targetRef: 'Review' }, - { id: 'SF2', sourceRef: 'Review', targetRef: 'SubEnd' }, - ], - }), - ); - expect(warnings).toEqual([]); - }); - - it('drops a sub-process name that exactly equals humanize(id)', async () => { - const xml = subProcessDoc( - ` - - `, - 'name="Sub"', - ); - const { node: sub } = await importOnly(xml, 'subProcess'); - expect('name' in sub).toBe(false); + { + elementId: 'StartEvent_p', + category: 'extensionAttribute', + message: + "The 'operaton:initiator' setting on 'StartEvent_p' was not " + + "written to the script: 'StartEvent_p' is the kind of name this " + + 'tool generates for itself, which a script cannot repeat, so this ' + + 'start is left out entirely and its initiator with it. Rename it ' + + 'in the diagram to keep the initiator.', + }, + ]); }); +}); - it('refuses a trigger on a start event nested inside a sub-process', async () => { - const xml = subProcessDoc( - ` - - - - `, - ); +describe('xmlToIr: warns for dropped lanes', () => { + it('names a lane nested in a childLaneSet as well as the lane holding it', async () => { + const xml = bpmnDoc` + + + + + + + + + `; - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedEventFeatureError, - 'a subprocess cannot start on a trigger: Operaton rejects one there ' + - 'when it parses the file; a subprocess is entered from the ' + - 'surrounding process, not by an event of its own', + const { warnings } = await xmlToIr(xml); + const laneWarnings = warnings.filter( + (w: ImportWarning) => w.category === 'lane', ); - expect(e.elementId).toBe('SubStart'); - expect(e.message).toContain("Put the trigger on an 'on' handler"); - expect(e.message).not.toContain('Event handlers catch one'); + expect(laneWarnings.map((w) => w.elementId)).toEqual([ + 'Lane_Outer', + 'Lane_Inner', + ]); + expect(laneWarnings[1].message).toContain('Dispatch'); }); }); -describe('xmlToIr: callActivity import', () => { - const callDoc = (attrs = '', children = '', doc = operatonDoc) => - oneNodeDoc('callActivity', { - id: 'CallSub', - attrs: `calledElement="sub-process" ${attrs}`, - children, - doc, +describe('xmlToIr: undeclared operaton extension element residual', () => { + const residualXml = operatonDoc` + + + + + + + + + + + + `; + + it('reports the undeclared element once (no silent loss) without flagging the clean task', async () => { + const { warnings } = await xmlToIr(residualXml); + // Moddle ties a residual drop to no step, so it lands on the process. + expectOneWarning(extensionWarnings(warnings), { + elementId: 'p', + message: /formProperty/i, }); + expect(warnings.some((w) => w.elementId === 'CleanTask')).toBe(false); + }); +}); - const richCallXml = callDoc( - 'name="Call sub" operaton:calledElementBinding="version" operaton:calledElementVersion="3"', - extensionElements(` - - - - - `), - ); +describe('xmlToIr: bpmn:documentation', () => { + const doc = (text: string): string => + `${text}`; - const EXPECTED_RICH_CALL: CallActivity = { - kind: 'callActivity', - id: 'CallSub', - name: 'Call sub', - calledElement: 'sub-process', - binding: { kind: 'version', version: '3' }, - businessKey: '${execution.processBusinessKey}', - inMappings: [ - { kind: 'all' }, - { kind: 'variable', source: 'amount', target: 'amount' }, - { - kind: 'expression', - sourceExpression: '${total * 2}', - target: 'doubled', - local: true, - }, - ], - outMappings: [ - { kind: 'variable', source: 'result', target: 'outcome' }, - { kind: 'expression', sourceExpression: '${status}', target: 'final' }, - ], + const documented = (ir: BpmnProcess): [string, string][] => { + const carried: [string, string][] = []; + const visit = (node: BpmnProcess | FlowElement): void => { + if ('documentation' in node && node.documentation !== undefined) { + carried.push([node.id, node.documentation]); + } + if ('flowElements' in node) node.flowElements.forEach(visit); + }; + visit(ir); + return carried; }; - it('a fully-featured call activity imports to the exact expected IR node, reporting nothing', async () => { - const { node, warnings } = await importOnly(richCallXml, 'callActivity'); - expect(node).toEqual(EXPECTED_RICH_CALL); - expect(warnings).toEqual([]); + const dropped = (id: string, position: string): ImportWarning => ({ + elementId: id, + category: 'documentation', + message: `The documentation on '${id}' was not imported: ${position} has no documentation in this tool's surface.`, }); - const callXmlWithBindingAttrs = (attrs: string): string => - callDoc(attrs, '', dualDoc); + const TIMER = + 'P1D'; - const importCall = (attrs: string) => - importOnly(callXmlWithBindingAttrs(attrs), 'callActivity'); - - it.each([ - [ - 'calledElementBinding="latest" -> { kind: "latest" }', - 'operaton:calledElementBinding="latest"', - { kind: 'latest' }, - ], - [ - 'calledElementBinding="deployment" -> { kind: "deployment" }', - 'operaton:calledElementBinding="deployment"', - { kind: 'deployment' }, - ], - [ - 'calledElementBinding="version" with calledElementVersion -> { kind: "version", version }', - 'operaton:calledElementBinding="version" operaton:calledElementVersion="7"', - { kind: 'version', version: '7' }, - ], - [ - 'camunda:calledElementBinding is honored, matching the assignee dual-namespace contract', - 'camunda:calledElementBinding="latest"', - { kind: 'latest' }, - ], - ] as const)('%s', async (_title, attrs, binding) => { - const { node } = await importCall(attrs); - expect(node.binding).toEqual(binding); - }); + const KINDS = [ + ['userTask', ''], + ['serviceTask', 'operaton:class="com.example.Svc"'], + ['sendTask', 'operaton:class="com.example.Svc"'], + ['businessRuleTask', 'operaton:class="com.example.Svc"'], + ['scriptTask', 'scriptFormat="javascript"'], + ['receiveTask', ''], + ['task', ''], + ['callActivity', 'calledElement="other"'], + ['subProcess', ''], + ['exclusiveGateway', ''], + ['inclusiveGateway', ''], + ['parallelGateway', ''], + ] as const; - it.each([ - [ - 'calledElementBinding="version" WITHOUT a version is refused', - 'operaton:calledElementBinding="version"', - 'calledElementBinding="version" is set without a calledElementVersion, ' + - 'so the engine cannot resolve which version to use', - ], - [ - 'calledElementBinding="versionTag" is refused as this surface\'s limit', - 'operaton:calledElementBinding="versionTag"', - 'calledElementBinding="versionTag" pins a version tag, which this ' + - 'surface has no setting for', - ], - ])('%s', async (_title, attrs, detail) => { - await expectRefusal( - xmlToIr(callXmlWithBindingAttrs(attrs)), - UnsupportedCallActivityError, - detail, - ); - }); + const CONTENT: Partial> = { + scriptTask: 'total = 1;', + subProcess: + '', + }; - it('a binding word parseBinding does not know imports as latest, naming the rewrite', async () => { - const { node, warnings } = await importCall( - 'operaton:calledElementBinding="bogus"', - ); - expect(node.binding).toEqual({ kind: 'latest' }); + it('every position that holds documentation carries it verbatim, and every other one reports it', async () => { + const { ir, warnings } = + await xmlToIr(operatonDefs` ${doc('Exported by hand.')} + ${doc('Raised by the vendor.')} + ${doc('Raised on day three.')} + ${doc('Sent by billing.')} + ${doc('Broadcast by the warehouse.')} + ${doc('Onboarding, end to end.')} + ${doc('Fires when HR files the request.')} +${KINDS.map(([tag, attrs]) => ` ${doc(`On ${tag}.`)}${CONTENT[tag] ?? ''}`).join('\n')} + + Two lines. + + + ${doc('Review one application.')} + ${doc('Once per applicant.')} + 3 + + + ${doc('Give up after a day.')}${TIMER} + ${doc('Wait a day.')} + ${doc('The day itself.')}P1D + + ${doc('Tell the warehouse.')} + ${doc('The broadcast itself.')} + + ${doc('Give up here.')} + ${doc('The error itself.')} + + ${doc('Nothing else runs.')} + ${doc('The terminate itself.')} + + ${doc('The hire is on the payroll.')} + ${doc('Straight through.')} +${KINDS.filter(([tag]) => tag.endsWith('Gateway')) + .map( + ([tag]) => + ` `, + ) + .join('\n')} + ${doc('Runs when the deadline passes.')} + ${doc('The deadline itself.')}${TIMER} + + + + `); + expect(documented(ir)).toEqual([ + ['p', 'Onboarding, end to end.'], + ['S', 'Fires when HR files the request.'], + ...KINDS.map(([tag]) => [tag, `On ${tag}.`]), + ['Verbatim', '\n Two lines.\n '], + ['Empty', ''], + ['Rep', 'Review one application.'], + ['Stop', 'Nothing else runs.'], + ['E', 'The hire is on the payroll.'], + ['HStart', 'The deadline itself.'], + ]); expect(warnings).toEqual([ + dropped('Rep', 'a repetition'), + dropped('B', 'a boundary event'), + dropped('Await', 'an event definition'), + dropped('Await', 'an await'), + dropped('Emit', 'an event definition'), + dropped('Emit', 'an emit'), + dropped('Throw', 'an event definition'), + dropped('Throw', 'a throw'), + dropped('Stop', 'an event definition'), + dropped('F1', 'a sequence flow'), + dropped('Handler', 'an event handler'), { - elementId: 'CallSub', - category: 'unmappedConstruct', + elementId: 'Msg', + category: 'unreferencedRoot', message: - 'The calledElementBinding="bogus" on \'CallSub\' imports as ' + - 'binding: latest: BpmnParse.parseBinding sets no binding for that ' + - 'word and BaseCallableElement.isLatestBinding reads none as ' + - 'latest, and this tool writes it back as ' + - 'calledElementBinding="latest", which the engine reads the same.', + 'The message "Paid" declared by root \'Msg\' is never used by an on/throw/emit; it was not imported.', }, - CAMUNDA_ALIAS_WARNING, + dropped('p', 'the definitions root'), + dropped('Err', 'a bpmn:error root'), + dropped('Esc', 'a bpmn:escalation root'), + dropped('Msg', 'a bpmn:message root'), + dropped('Sig', 'a bpmn:signal root'), ]); }); - it.each([ - [ - 'a caseRef and no calledElement', - 'operaton:caseRef="claims"', - 'it names operaton:caseRef="claims" and no calledElement, so ' + - 'BpmnParse.parseCallActivity runs a case through ' + - 'CaseCallActivityBehavior, which this surface has no form for', - ], - [ - 'neither a calledElement nor a caseRef', - '', - 'it names neither a calledElement nor an operaton:caseRef, which ' + - 'BpmnParse.parseCallActivity refuses to deploy ("Missing attribute ' + - "'calledElement' or 'caseRef'\")", - ], - [ - 'a calledElement beside a caseRef', - 'calledElement="sub-process" operaton:caseRef="claims"', - 'it names a calledElement beside operaton:caseRef="claims", which ' + - 'BpmnParse.parseCallActivity refuses to deploy ("The attributes ' + - "'calledElement' or 'caseRef' cannot be used together\")", - ], - ])( - 'a call activity with %s is refused, saying what it names', - async (_title, attrs, detail) => { - await expectRefusal( - xmlToIr(oneNodeDoc('callActivity', { id: 'CallSub', attrs })), - UnsupportedCallActivityError, - detail, - ); - }, - ); - - it('a dangling calledElementVersion (binding absent) imports with NO binding and exactly one warning', async () => { - const { node, warnings } = await importCall( - 'operaton:calledElementVersion="#{v}"', + it('a start and an end whose ids this tool writes for itself carry it and report that no script can spell it back', async () => { + const { ir, warnings } = await xmlToIr( + bpmnDoc` ${doc('Where it begins.')} + ${doc('Where it stops.')} + `, ); - expect('binding' in node).toBe(false); + expect(documented(ir)).toEqual([ + ['StartEvent_p', 'Where it begins.'], + ['EndEvent_p', 'Where it stops.'], + ]); expect(warnings).toEqual([ - { - elementId: 'CallSub', - category: 'extensionAttribute', - message: - "The 'calledElementVersion' setting on 'CallSub' has no effect " + - 'without calledElementBinding="version" and was not imported.', - }, - CAMUNDA_ALIAS_WARNING, + warning( + 'StartEvent_p', + /this start is left out entirely and its documentation with it/, + 'documentation', + ), + warning( + 'EndEvent_p', + /Where the script can do without this end, it is left out and its documentation with it/, + 'documentation', + ), ]); }); it.each([ [ - 'operaton:calledElementTenantId is refused, naming the tenant attribute', - 'operaton:calledElementTenantId="tenant-a"', - /calledElementTenantId/, + 'a second documentation child leaves the element with none', + `${doc('The first.')}${doc('The second.')}`, + /this surface holds one and 'T' carries 2/, ], [ - 'camunda:calledElementTenantId is refused too, matching the dual-namespace contract', - 'camunda:calledElementTenantId="tenant-a"', - /calledElementTenantId/, + 'a textFormat naming anything but plain text leaves the element with none', + '<p>Hi</p>', + /this surface holds plain text and its textFormat is 'text\/html'/, ], - ] as const)('%s', async (_title, attributes, detail) => { - await expectRefusal( - xmlToIr(callXmlWithBindingAttrs(attributes)), - UnsupportedCallActivityError, - detail, + ])('%s', async (_title, children, detail) => { + const { ir, warnings } = await xmlToIr( + oneNodeDoc('userTask', { children }), ); + expect(documented(ir)).toEqual([]); + expect(warnings).toEqual([warning('T', detail, 'documentation')]); }); +}); + +describe('xmlToIr: warns for unmapped BPMN content', () => { + const withRoots = ( + roots: string, + content = '', + attrs = 'isExecutable="true"', + ) => + bpmnDefs`${roots} + + + ${content} + + + `; it.each([ [ - 'operaton:variableMappingClass imports as a class mapper with no warning', - 'operaton:variableMappingClass="com.acme.Mapper"', - { kind: 'class', className: 'com.acme.Mapper' }, - [], - ], - [ - 'operaton:variableMappingDelegateExpression imports as a delegate mapper with no warning', - 'operaton:variableMappingDelegateExpression="${mapperBean}"', - { kind: 'delegateExpression', expression: '${mapperBean}' }, - [], - ], - [ - 'camunda:variableMappingClass imports the same way, matching the dual-namespace contract', - 'camunda:variableMappingClass="com.acme.Mapper"', - { kind: 'class', className: 'com.acme.Mapper' }, - [], + 'content on bpmn:definitions itself is reported against the process', + withRoots(' '), + [warning('p', /bpmn:extension/, 'unmappedConstruct')], ], [ - 'both attributes import the class and report the delegate as shadowed', - 'operaton:variableMappingClass="com.acme.Mapper" ' + - 'operaton:variableMappingDelegateExpression="${mapperBean}"', - { kind: 'class', className: 'com.acme.Mapper' }, + 'a dataObject with a dataState child at process level is reported once, whole', + withRoots( + '', + '', + ), [ { - elementId: 'CallSub', - category: 'extensionAttribute', + elementId: 'Data1', + category: 'unmappedConstruct', message: - "The 'variableMappingDelegateExpression' setting on 'CallSub' " + - 'has no effect alongside operaton:variableMappingClass and was ' + - 'not imported.', + "A bpmn:dataObject 'Data1' was not imported: Operaton keeps process " + + 'variables in its own store and never dispatches on it, so the ' + + 'imported process runs identically.', }, ], ], - ] as const)( - '%s', - async (_title, attributes, expectedMapper, expectedWarnings) => { - const { node, warnings } = await importCall(attributes); - expect(node.mapper).toEqual(expectedMapper); - expect(warnings).toEqual([...expectedWarnings, CAMUNDA_ALIAS_WARNING]); - }, - ); - - const callXmlWithExtension = (extension: string): string => - callDoc('', extensionElements(extension)); - - it.each([ - [ - 'an operaton:in with source but no target is refused, naming the shape', - '', - 'an operaton:in carries source without a target', - ], - [ - 'an operaton:in with variables="foo" is refused, naming the shape', - '', - 'an operaton:in carries variables="foo", which this tool cannot import (only variables="all" is supported)', - ], [ - 'an empty operaton:in with no recognized attribute is refused', - '', - 'an operaton:in carries none of the recognized shapes (source+target, sourceExpression+target, variables="all", or businessKey)', - ], - [ - 'an operaton:in with an empty source is refused, quoting the strict validation', - '', - 'an operaton:in carries source="", which BpmnParse.parseCallableElementProvider refuses to deploy ("Empty attribute \'source\' when passing variables")', - ], - [ - 'an operaton:in with sourceExpression but no target is refused, naming the shape', - '', - 'an operaton:in carries sourceExpression without a target', + 'a globalScriptTask root is reported once, and its own script child is not blamed on the process', + withRoots( + ' \n x\n ', + ), + [ + { + elementId: 'G3', + category: 'unmappedConstruct', + message: + "A bpmn:globalScriptTask 'G3' root element was not imported " + + '(this tool imports the executable flow and the engine settings on ' + + 'its steps, and nothing declared or drawn beside it).', + }, + ], ], [ - 'an operaton:out with source but no target is refused, naming the out tag', - '', - 'an operaton:out carries source without a target', + 'isExecutable="false" is reported, and the process imports as executable regardless', + withRoots('', '', 'isExecutable="false"'), + [warning('p', /isExecutable="false".*deploy/i, 'unmappedConstruct')], ], - ] as const)('%s', async (_title, extension, detail) => { - await expectRefusal( - xmlToIr(callXmlWithExtension(extension)), - UnsupportedCallActivityError, - detail, + ])('%s', async (_title, xml, expected) => { + const { ir, warnings } = await xmlToIr(xml); + expect(ir.isExecutable).toBe(true); + expect(warnings).toEqual(expected); + }); + + it('a trigger on a start event nested inside a sub-process refuses', async () => { + const e = await expectRefusal( + xmlToIr( + oneNodeDoc('subProcess', { + id: 'Sub', + doc: bpmnDoc, + children: ` + + + + `, + }), + ), + UnsupportedEventFeatureError, + 'a subprocess cannot start on a trigger: Operaton rejects one there ' + + 'when it parses the file; a subprocess is entered from the ' + + 'surrounding process, not by an event of its own', + ); + expect(e.elementId).toBe('SubStart'); + expect(e.message).toContain("Put the trigger on an 'on' handler"); + expect(e.message).not.toContain('Event handlers catch one'); + }); +}); + +describe('xmlToIr: callActivity import', () => { + const callDoc = (attrs = '', extension = '', doc = operatonDoc) => + oneNodeDoc('callActivity', { + id: 'CallSub', + attrs: `calledElement="sub-process" ${attrs}`, + children: extension && extensionElements(extension), + doc, + }); + + it('a fully-featured call activity imports to the exact expected IR node, reporting nothing', async () => { + const { node, warnings } = await importOnly( + callDoc( + 'name="Call sub" operaton:calledElementBinding="version" operaton:calledElementVersion="3"', + ` + + + + + `, + ), + 'callActivity', ); + expect(node).toEqual({ + kind: 'callActivity', + id: 'CallSub', + name: 'Call sub', + calledElement: 'sub-process', + binding: { kind: 'version', version: '3' }, + businessKey: '${execution.processBusinessKey}', + inMappings: [ + { kind: 'all' }, + { kind: 'variable', source: 'amount', target: 'amount' }, + { + kind: 'expression', + sourceExpression: '${total * 2}', + target: 'doubled', + local: true, + }, + ], + outMappings: [ + { kind: 'variable', source: 'result', target: 'outcome' }, + { kind: 'expression', sourceExpression: '${status}', target: 'final' }, + ], + }); + expect(warnings).toEqual([]); }); - /** The warning one ignored attribute draws on an `operaton:in` of `CallSub`. */ - const inIgnored = (attr: string, winner: string, why: string) => ({ + const onCall = (message: string, category = 'extensionAttribute') => ({ elementId: 'CallSub', - category: 'extensionAttribute', - message: - `The '${attr}' on an operaton:in of 'CallSub' has no effect alongside ` + - `${winner} and was not imported: ${why}.`, + category, + message, }); + + const inIgnored = (attr: string, winner: string, why: string) => + onCall( + `The '${attr}' on an operaton:in of 'CallSub' has no effect alongside ` + + `${winner} and was not imported: ${why}.`, + ); const PROVIDER_READS_ALL = 'BpmnParse.parseCallableElementProvider passes every variable and reads nothing else'; const KEY_ALONE = 'BpmnParse.parseInputParameter reads the business key alone off that element'; it.each([ + { + title: + 'a binding word parseBinding does not know imports as latest, naming the rewrite', + xml: callDoc('camunda:calledElementBinding="bogus"', '', dualDoc), + node: { binding: { kind: 'latest' } }, + warnings: [ + onCall( + 'The calledElementBinding="bogus" on \'CallSub\' imports as ' + + 'binding: latest: BpmnParse.parseBinding sets no binding for that ' + + 'word and BaseCallableElement.isLatestBinding reads none as ' + + 'latest, and this tool writes it back as ' + + 'calledElementBinding="latest", which the engine reads the same.', + 'unmappedConstruct', + ), + CAMUNDA_ALIAS_WARNING, + ], + }, + { + title: + 'operaton:variableMappingDelegateExpression imports as a delegate mapper', + xml: callDoc( + 'operaton:variableMappingDelegateExpression="${mapperBean}"', + ), + node: { + mapper: { kind: 'delegateExpression', expression: '${mapperBean}' }, + }, + warnings: [], + }, + { + title: + 'both mapper attributes import the class and report the delegate as shadowed', + xml: callDoc( + 'operaton:variableMappingClass="com.acme.Mapper" ' + + 'operaton:variableMappingDelegateExpression="${mapperBean}"', + ), + node: { mapper: { kind: 'class', className: 'com.acme.Mapper' } }, + warnings: [ + onCall( + "The 'variableMappingDelegateExpression' setting on 'CallSub' " + + 'has no effect alongside operaton:variableMappingClass and was ' + + 'not imported.', + ), + ], + }, { title: 'source beside sourceExpression keeps source', - extension: + xml: callDoc( + '', '', + ), node: { inMappings: [{ kind: 'variable', source: 'a', target: 'c' }] }, warnings: [ inIgnored( @@ -4578,7 +2169,7 @@ describe('xmlToIr: callActivity import', () => { }, { title: 'variables="all" beside source and target keeps all', - extension: '', + xml: callDoc('', ''), node: { inMappings: [{ kind: 'all' }] }, warnings: [ inIgnored('source', 'variables="all"', PROVIDER_READS_ALL), @@ -4588,8 +2179,10 @@ describe('xmlToIr: callActivity import', () => { { title: 'a businessKey beside source, target, variables and local keeps the key', - extension: + xml: callDoc( + '', '', + ), node: { businessKey: '${k}' }, warnings: [ inIgnored('source', 'businessKey', KEY_ALONE), @@ -4600,57 +2193,100 @@ describe('xmlToIr: callActivity import', () => { }, { title: 'a second businessKey replaces the first', - extension: + xml: callDoc( + '', '', + ), node: { businessKey: '${b}' }, warnings: [ - { - elementId: 'CallSub', - category: 'extensionAttribute', - message: - 'The operaton:in businessKey="${a}" on \'CallSub\' has no effect ' + + onCall( + 'The operaton:in businessKey="${a}" on \'CallSub\' has no effect ' + 'alongside a later one and was not imported: ' + 'BpmnParse.parseInputParameter hands each to ' + 'setBusinessKeyValueProvider, and the last stands.', - }, + ), ], }, - ])( - 'a mapping shape the engine deploys imports its pick and reports the rest: $title', - async ({ extension, node, warnings }) => { - const imported = await importOnly( - callXmlWithExtension(extension), - 'callActivity', - ); - expect(imported.node).toEqual({ - kind: 'callActivity', - id: 'CallSub', - calledElement: 'sub-process', - ...node, - }); - expect(imported.warnings).toEqual(warnings); - }, - ); + ])('$title', async ({ xml, node, warnings }) => { + const imported = await importOnly(xml, 'callActivity'); + expect(imported.node).toEqual({ + kind: 'callActivity', + id: 'CallSub', + calledElement: 'sub-process', + ...node, + }); + expect(imported.warnings).toEqual(warnings); + }); it.each([ - ['equals humanize(id) is dropped', 'Fulfil Order', undefined], [ - 'differs from humanize(id) is kept', - 'Send the order to fulfilment', - 'Send the order to fulfilment', + 'calledElementBinding="version" without a version', + callDoc('operaton:calledElementBinding="version"'), + 'calledElementBinding="version" is set without a calledElementVersion, ' + + 'so the engine cannot resolve which version to use', + ], + [ + 'calledElementBinding="versionTag", which this surface has no setting for', + callDoc('operaton:calledElementBinding="versionTag"'), + 'calledElementBinding="versionTag" pins a version tag, which this ' + + 'surface has no setting for', + ], + [ + 'operaton:calledElementTenantId', + callDoc('operaton:calledElementTenantId="tenant-a"'), + /calledElementTenantId/, ], - ['is the empty string is kept as written', '', ''], - ])('a call-activity label that %s', async (_title, written, kept) => { - const { node } = await importOnly( + [ + 'a caseRef and no calledElement', oneNodeDoc('callActivity', { - id: 'Fulfil_Order', - attrs: `name="${written}" calledElement="sub-process"`, - doc: bpmnDoc, + id: 'CallSub', + attrs: 'operaton:caseRef="claims"', }), - 'callActivity', - ); - expect('name' in node).toBe(kept !== undefined); - expect(node.name).toBe(kept); + 'it names operaton:caseRef="claims" and no calledElement, so ' + + 'BpmnParse.parseCallActivity runs a case through ' + + 'CaseCallActivityBehavior, which this surface has no form for', + ], + [ + 'neither a calledElement nor a caseRef', + oneNodeDoc('callActivity', { id: 'CallSub' }), + 'it names neither a calledElement nor an operaton:caseRef, which ' + + 'BpmnParse.parseCallActivity refuses to deploy ("Missing attribute ' + + "'calledElement' or 'caseRef'\")", + ], + [ + 'a calledElement beside a caseRef', + callDoc('operaton:caseRef="claims"'), + 'it names a calledElement beside operaton:caseRef="claims", which ' + + 'BpmnParse.parseCallActivity refuses to deploy ("The attributes ' + + "'calledElement' or 'caseRef' cannot be used together\")", + ], + [ + 'an operaton:in with source but no target', + callDoc('', ''), + 'an operaton:in carries source without a target', + ], + [ + 'an operaton:in with variables="foo"', + callDoc('', ''), + 'an operaton:in carries variables="foo", which this tool cannot import (only variables="all" is supported)', + ], + [ + 'an empty operaton:in', + callDoc('', ''), + 'an operaton:in carries none of the recognized shapes (source+target, sourceExpression+target, variables="all", or businessKey)', + ], + [ + 'an operaton:in with an empty source, quoting the strict validation', + callDoc('', ''), + 'an operaton:in carries source="", which BpmnParse.parseCallableElementProvider refuses to deploy ("Empty attribute \'source\' when passing variables")', + ], + [ + 'an operaton:in with sourceExpression but no target', + callDoc('', ''), + 'an operaton:in carries sourceExpression without a target', + ], + ])('a call activity with %s is refused', async (_title, xml, detail) => { + await expectRefusal(xmlToIr(xml), UnsupportedCallActivityError, detail); }); }); @@ -4725,49 +2361,7 @@ describe('xmlToIr: event layer import', () => { expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); }); - it.each([ - [ - 'a handler definition without errorRef imports with the code absent (catch-all)', - handlerDoc(''), - [], - ], - [ - 'a ref to a code-less bpmn:Error root imports with the code absent, and the root warns about the missing code rather than "never caught"', - handlerDoc( - '', - { roots: ' \n' }, - ), - [ - { - elementId: 'Error_NoCode', - category: 'unreferencedRoot', - message: - "The error root 'Error_NoCode' has no code, so it cannot be " + - 'keyed or represented in the model; it was not imported.', - }, - ], - ], - ])('%s', async (_title, xml, expected) => { - const { node, warnings } = await importOnly(xml, 'subProcess'); - const start = byId(node, 'HStart'); - expect(start.kind === 'startEvent' && start.eventDefinition).toEqual( - errorDef(), - ); - expect(warnings).toEqual(expected); - }); - describe('refusals', () => { - it('a terminate definition on a handler start still refuses with UnsupportedEventDefinitionError', async () => { - const e = await expectRefusal( - xmlToIr( - handlerDoc('', { body: '' }), - ), - UnsupportedEventDefinitionError, - ); - expect(e.eventKind).toBe('start'); - expect(e.definitionType).toBe('bpmn:TerminateEventDefinition'); - }); - it.each([ [ 'an event handler with zero start events', @@ -4776,16 +2370,6 @@ describe('xmlToIr: event layer import', () => { - `, - ], - [ - 'an event handler with two start events', - bpmnDoc` - - - - - `, ], [ @@ -4796,20 +2380,6 @@ describe('xmlToIr: event layer import', () => { { body: '' }, ), ], - [ - 'an event handler with an incoming flow', - bpmnDoc` - - F1 - - - - - - - - `, - ], [ 'isInterrupting="false" on an error handler', handlerDoc('', { @@ -4817,16 +2387,6 @@ describe('xmlToIr: event layer import', () => { startAttrs: 'isInterrupting="false"', }), ], - [ - 'an error end event with no resolvable code', - rootedDoc( - '', - ` - - - `, - ), - ], [ 'an error definition on an intermediate throw', rootedDoc( @@ -4864,7 +2424,6 @@ describe('xmlToIr: event layer import', () => { ); }); - /** `S -> E` beside a handler, with one extra flow wired by the row and no incoming/outgoing children. */ const wiredHandler = (flow: string): string => bpmnDoc` @@ -4908,7 +2467,6 @@ describe('xmlToIr: event layer import', () => { }, ); - /** `S -> Work -> E`, with the given boundary block on the subprocess `Work`. */ const workBoundaryDoc = (boundary: string, roots = ''): string => rootedDoc( roots, @@ -4927,7 +2485,6 @@ describe('xmlToIr: event layer import', () => { `, ); - /** `S -> Throw`, an end carrying the given definition beside the given roots. */ const throwDoc = (definition: string, roots = ''): string => rootedDoc( roots, @@ -4953,16 +2510,6 @@ describe('xmlToIr: event layer import', () => { 'createEscalationEventDefinitionForEscalationHandler', ), ], - [ - 'a handler start whose escalationRef names no root', - handlerDoc( - '', - ), - 'HStart', - danglingEscalation( - 'createEscalationEventDefinitionForEscalationHandler', - ), - ], [ 'an escalation end whose escalationRef names no root', throwDoc( @@ -5034,102 +2581,27 @@ describe('xmlToIr: event layer import', () => { "dangling reference's text as the code " + '(BpmnParse.parseBoundaryErrorEventDefinition, ' + 'parseErrorStartEventDefinition, parseEndEvents, ' + - 'parseOperatonErrorEventDefinitions), and the document written ' + - 'back declares an error root carrying it.', - }); - - it.each([ - [ - 'a boundary catch', - workBoundaryDoc( - '', - ), - 'B', - ], - [ - 'a handler start', - handlerDoc(''), - 'HStart', - ], - [ - 'an error end', - throwDoc(''), - 'Throw', - ], - ])( - "%s whose errorRef names no root imports the reference text as the code, the engine's reading, with one warning", - async (_title, xml, ownerId) => { - const { ir, warnings } = await xmlToIr(xml); - const owner = - ownerId === 'HStart' - ? byId(subProcess(ir, 'Handler'), ownerId) - : byId(ir, ownerId); - expect('eventDefinition' in owner && owner.eventDefinition).toEqual( - errorDef('Error_Missing'), - ); - expect(warnings).toEqual([danglingErrorRef(ownerId)]); - expect(ir.errorDecls).toBeUndefined(); - expect(await irToXml(ir)).toContain( - '', - ); - }, - ); - }); - - describe('warn-drops', () => { - const ERROR_X_ROOT = ' \n'; - - it('a genuine label on an event handler start is kept, not dropped', async () => { - const { ir, warnings } = await xmlToIr( - bpmnDoc` - - - - - - - `, - ); - const start = byId(subProcess(ir, 'Handler'), 'HandlerStart'); - expect(start.kind === 'startEvent' && start.name).toBe('Cancelled'); - expect(warnings.filter((w) => w.category === 'label')).toEqual([]); - }); - - it('operaton:errorCodeVariable on an error end event (throw side) warns: it has no effect there', async () => { - const { ir, warnings } = await xmlToIr( - rootedDoc( - ERROR_X_ROOT, - ` - - - `, - operatonDefs, - ), - ); - const end = byId(ir, 'ThrowX'); - expect(end.kind === 'endEvent' && end.eventDefinition).toEqual( - errorDef('X'), - ); - expectOneWarning(warnings, { - elementId: 'ThrowX', - message: 'errorCodeVariable', - }); + 'parseOperatonErrorEventDefinitions), and the document written ' + + 'back declares an error root carrying it.', }); - it('an unrelated operaton: attribute on a mapped event definition warns', async () => { - const { warnings } = await xmlToIr( - handlerDoc( - '', - { roots: ERROR_X_ROOT, defs: operatonDefs }, - ), + it("an error end whose errorRef names no root imports the reference text as the code, the engine's reading, with one warning", async () => { + const { ir, warnings } = await xmlToIr( + throwDoc(''), + ); + const owner = byId(ir, 'Throw'); + expect('eventDefinition' in owner && owner.eventDefinition).toEqual( + errorDef('Error_Missing'), + ); + expect(warnings).toEqual([danglingErrorRef('Throw')]); + expect(ir.errorDecls).toBeUndefined(); + expect(await irToXml(ir)).toContain( + '', ); - expectOneWarning(warnings, { - elementId: 'HStart', - message: 'asyncBefore', - }); }); + }); - /** A lone declared root with a `S -> E` process that never references it. */ + describe('warn-drops', () => { const unusedRootXml = (root: string, defs = bpmnDefs) => rootedDoc( `${root}\n`, @@ -5138,203 +2610,22 @@ describe('xmlToIr: event layer import', () => { defs, ); - it('an unreferenced error root and an unreferenced escalation root both import as declarations, warning about neither', async () => { - const { ir, warnings } = await xmlToIr( - unusedRootXml( - ` - `, - ), - ); - expect(warnings).toEqual([]); - expect(ir.errorDecls).toEqual([{ name: 'Unused', code: 'UNUSED' }]); - expect(ir.escalationDecls).toEqual([{ name: 'Review', code: 'REVIEW' }]); - }); - - it('an unreferenced root with a code and a message imports as a declaration carrying it', async () => { + it('an error root with no code warns and is dropped', async () => { const { ir, warnings } = await xmlToIr( - unusedRootXml( - ' ', - operatonDefs, - ), + unusedRootXml(' '), ); - expect(ir.errorDecls).toEqual([ - { name: 'Declared', code: 'DECL', message: 'declared but unused' }, - ]); - expect(warnings).toEqual([]); - }); - - it.each([ - [ - 'an error root with no code warns and is dropped', - ' ', - 'Error_NoCode', - ], - [ - 'an escalation root with no code warns and is dropped', - ' ', - 'Escalation_NoCode', - ], - ])('%s', async (_title, root, elementId) => { - const { ir, warnings } = await xmlToIr(unusedRootXml(root)); expect(ir.errorDecls).toBeUndefined(); expect(ir.escalationDecls).toBeUndefined(); expect( warnings .filter((w) => w.category === 'unreferencedRoot') .map((w) => w.elementId), - ).toEqual([elementId]); - }); - - it.each([ - [ - 'a name no declaration could be written with is minted from the code', - '', - [{ name: 'order_failed', code: 'order.failed' }], - ], - [ - 'a missing name falls back to the code where the code can be written as one', - '', - [{ name: 'OUT_OF_STOCK', code: 'OUT_OF_STOCK' }], - ], - [ - 'a name that is a reserved word is prefixed, since a keyword cannot be an identifier', - '', - [{ name: '_while', code: 'while' }], - ], - [ - 'a code opening on a digit is prefixed', - '', - [{ name: '_404', code: '404' }], - ], - [ - 'two codes minting one name are told apart by a numeric suffix', - ` - `, - [ - { name: 'order_failed', code: 'order.failed' }, - { name: 'order_failed_2', code: 'order/failed' }, - ], - ], - ])('%s', async (_title, roots, expected) => { - const { ir } = await xmlToIr(unusedRootXml(` ${roots}`)); - expect(ir.errorDecls).toEqual(expected); - - const reexported = await irToXml(ir); - expect( - [ - ...reexported.matchAll( - / ({ name, code })), - ).toEqual(expected); + ).toEqual(['Error_NoCode']); }); }); - - it('a normal (non-handler) start event with an error definition still refuses', async () => { - const xml = bpmnDefs` - - - - - - - `; - - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedEventFeatureError, - 'a process cannot start on an error; Operaton ignores the trigger ' + - 'and starts the process as if none were written, so importing it ' + - 'would write back a document the engine runs differently from what ' + - 'it says', - ); - expect(e.elementId).toBe('S'); - }); }); describe('xmlToIr: message/signal/timer/conditional import', () => { - const fullNewKindsXml = bpmnDefs` - - - - - - - - - - - - - - - - - - - - - PT1H - - - - - - - - - 2026-08-01T09:00:00 - - - - - - - - - \${amount > 100} - - - - - - - - - - - - - - `; - - const PING: EventDefinition = signalDef('Ping'); - - const EXPECTED_NEW_KINDS_IR: BpmnProcess = minimalProcess( - [ - { kind: 'startEvent', id: 'PStart' }, - eventSubProcess('Msg', messageDef('PaymentReceived')), - eventSubProcess('Sig', PING, { isInterrupting: false }), - eventSubProcess('Dur', timerDef('duration', 'PT1H'), { - id: 'DurationHandler', - }), - eventSubProcess('Date', timerDef('date', '2026-08-01T09:00:00')), - eventSubProcess('Cond', conditionDef('${amount > 100}')), - typedEvent('intermediateThrowEvent', 'EmitSig', PING), - typedEvent('endEvent', 'ThrowSig', PING), - ], - [ - { id: 'F1', sourceRef: 'PStart', targetRef: 'EmitSig' }, - { id: 'F2', sourceRef: 'EmitSig', targetRef: 'ThrowSig' }, - ], - ); - - it('imports a message handler, a non-interrupting signal handler, duration/date timer handlers, a conditional handler, and a signal end+emit sharing one root, into the exact expected IR (deep equality), warnings: []', async () => { - const { ir, warnings } = await xmlToIr(fullNewKindsXml); - expect(ir).toEqual(EXPECTED_NEW_KINDS_IR); - expect(warnings).toEqual([]); - }); - describe('refusals', () => { it.each([ [ @@ -5343,15 +2634,6 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { 'a message definition must reference a bpmn:Message root with a ' + 'non-empty name', ], - [ - 'a signal ref to a nameless root', - handlerDoc( - '', - { roots: ' \n', body: '' }, - ), - 'a signal definition must reference a bpmn:Signal root with a ' + - 'non-empty name', - ], [ 'a prefixed messageRef, which the engine resolves and this tool does not', handlerDoc( @@ -5372,18 +2654,6 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { 'a timer definition must carry exactly one of ' + 'timeDuration/timeDate/timeCycle (found 0)', ], - [ - 'a timer definition with two time children', - handlerDoc( - ` - PT1H - 2026-08-01T09:00:00 - `, - { body: '' }, - ), - 'a timer definition must carry exactly one of ' + - 'timeDuration/timeDate/timeCycle (found 2)', - ], [ 'a timer definition with an empty body', handlerDoc( @@ -5411,17 +2681,6 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { "a conditional definition's operaton:variableName narrows when the " + 'condition is (re-)evaluated, which this tool cannot represent', ], - [ - 'camunda:variableEvents on a conditional definition', - handlerDoc( - ` - \${amount > 100} - `, - { body: '', defs: camundaDefs }, - ), - "a conditional definition's operaton:variableEvents narrows when the " + - 'condition is (re-)evaluated, which this tool cannot represent', - ], ] as const)( '%s refuses with UnsupportedEventFeatureError naming the form', async (_title, xml, detail) => { @@ -5473,41 +2732,6 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { }); describe('root honesty', () => { - // Two signal roots of one name refuse instead (`parseSignals` fails the - // deployment); `parseMessages` has no such check, so two message roots - // deploy and merge here. - it('two bpmn:Message roots sharing one name, each referenced, collapse to one IR name with no warning', async () => { - const xml = bpmnDefs` - - - - - - - - - - - - - - - - - `; - - const { ir, warnings } = await xmlToIr(xml); - expect(warnings).toEqual([]); - const start = byId(subProcess(ir, 'Handler'), 'HStart'); - expect(start.kind === 'startEvent' && start.eventDefinition).toEqual( - messageDef('Ping'), - ); - const wait = byId(ir, 'Wait'); - expect( - wait.kind === 'intermediateCatchEvent' && wait.eventDefinition, - ).toEqual(messageDef('Ping')); - }); - it('itemRef on a referenced bpmn:Message root warns once and still imports', async () => { const xml = handlerDoc( '', @@ -5534,93 +2758,7 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { }); describe('xmlToIr: compensation import', () => { - const compensationXml = bpmnDoc` - - - - - - - - - - - - - - - - - - - - - - - - - `; - - const COMPENSATION: EventDefinition = { kind: 'compensation' }; - - const EXPECTED_COMPENSATION_IR: BpmnProcess = { - ...chained( - [ - { kind: 'startEvent', id: 'PStart' }, - chainedSub( - 'Booking', - [ - { kind: 'startEvent', id: 'BStart' }, - { kind: 'userTask', id: 'ReserveRoom' }, - { kind: 'endEvent', id: 'BEnd' }, - ], - { - prefix: 'Flow', - unwired: [ - triggeredSub( - 'UndoBooking', - [ - typedEvent('startEvent', 'UndoStart', COMPENSATION), - { kind: 'userTask', id: 'CancelRoom' }, - { kind: 'endEvent', id: 'UndoEnd' }, - ], - { prefix: 'Flow' }, - ), - ], - }, - ), - typedEvent('intermediateThrowEvent', 'EmitUndo', COMPENSATION), - typedEvent('endEvent', 'ThrowUndo', COMPENSATION), - ], - { prefix: 'Flow' }, - ), - id: 'p', - }; - - it('imports a compensation handler hosted by the plain sub-process it compensates, a compensation emit, and a compensation throw, into the exact expected IR (deep equality), warnings: []', async () => { - const { ir, warnings } = await xmlToIr(compensationXml); - expect(ir).toEqual(EXPECTED_COMPENSATION_IR); - expect(warnings).toEqual([]); - }); - - it('an explicit waitForCompletion="true" on both throw positions imports identically to the default (absent) form', async () => { - const xml = compensationXml - .replace( - '', - '', - ) - .replace( - '', - '', - ); - - const { ir, warnings } = await xmlToIr(xml); - expect(ir).toEqual(EXPECTED_COMPENSATION_IR); - expect(warnings).toEqual([]); - }); - describe('refusals', () => { - /** An `UndoBooking` compensation handler, `UndoStart -> UndoEnd`. */ const undoHandler = (startAttrs = '', definitionAttrs = '') => ` @@ -5630,7 +2768,6 @@ describe('xmlToIr: compensation import', () => { `; - /** `S -> Booking -> E`, where the plain sub-process hosts `body`. */ const bookingDoc = (body: string) => oneNodeDoc('subProcess', { id: 'Booking', @@ -5652,21 +2789,6 @@ describe('xmlToIr: compensation import', () => { '(activityRef="ReserveRoom"); this tool always addresses the ' + 'enclosing scope and cannot target a single activity', ], - [ - 'an activityRef on a compensation end-event definition', - rootedDoc( - '', - ` - - - - - `, - ), - 'a compensation definition targets one activity by reference ' + - '(activityRef="T"); this tool always addresses the enclosing ' + - 'scope and cannot target a single activity', - ], [ 'waitForCompletion="false" on an intermediate throw', oneNodeDoc('intermediateThrowEvent', { @@ -5679,19 +2801,6 @@ describe('xmlToIr: compensation import', () => { 'tool only imports the default (wait for the compensation to ' + 'complete) behavior', ], - [ - 'waitForCompletion="false" on an end event', - rootedDoc( - '', - ` - - - `, - ), - 'a compensation definition sets waitForCompletion="false"; this ' + - 'tool only imports the default (wait for the compensation to ' + - 'complete) behavior', - ], [ 'isInterrupting="false" on a compensation handler start', bookingDoc(undoHandler('isInterrupting="false"')), @@ -5742,39 +2851,6 @@ describe('xmlToIr: compensation import', () => { }, ); - it.each([ - [ - 'a service task', - oneNodeDoc('serviceTask', { - id: 'CancelReservation', - attrs: 'operaton:class="com.example.Cancel" isForCompensation="true"', - }), - 'CancelReservation', - ], - [ - 'a sub-process', - oneNodeDoc('subProcess', { - id: 'UndoBlock', - attrs: 'isForCompensation="true"', - doc: bpmnDoc, - children: ` - - `, - }), - 'UndoBlock', - ], - ] as const)( - 'isForCompensation="true" on %s refuses with UnsupportedEventFeatureError', - async (_title, xml, elementId) => { - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedEventFeatureError, - IS_FOR_COMPENSATION_DETAIL, - ); - expect(e.elementId).toBe(elementId); - }, - ); - it.each([ [ 'no bpmn:association leaves the boundary event at all', @@ -5820,7 +2896,6 @@ describe('xmlToIr: compensation import', () => { const RESERVE = ''; - /** `S -> ReserveRoom -> E`, `ReserveRoom` compensated by `CancelReservation`, the three written in the given order. */ const pairedDoc = (...nodes: readonly string[]): string => operatonDoc` ${nodes.join('\n ')} @@ -5839,38 +2914,12 @@ describe('xmlToIr: compensation import', () => { ]; it.each([ - [ - 'the boundary event comes first in document order', - pairedDoc(RESERVE, BOUNDARY, HANDLER), - 'CompensationBoundary', - REWRITE_PREVIEW, - ], [ 'the handler comes first in document order', pairedDoc(RESERVE, HANDLER, BOUNDARY), 'CancelReservation', REWRITE_PREVIEW, ], - [ - 'the compensated activity repeats, which keeps the for-each clause', - pairedDoc( - ` - - `, - BOUNDARY, - HANDLER, - ), - 'CompensationBoundary', - [ - 'var lines: any', - 'subprocess Compensated_ReserveRoom {', - ' service ReserveRoom for each line in lines(class: "com.example.Reserve")', - ' on compensation {', - ' user CancelReservation', - ' }', - '}', - ], - ], ] as const)( 'a compensated activity, its boundary event and its association-linked handler are all named in the refusal, and the printed rewrite re-parses through the compiler (%s)', async (_title, xml, elementId, preview) => { @@ -5887,8 +2936,7 @@ describe('xmlToIr: compensation import', () => { ); it('a compensated activity that independently carries content this tool cannot import at all still raises the compensation refusal, not that unrelated one', async () => { - // The boundary comes first, so the rewrite preview maps the host before - // the container walk does: only the preview's mapper sees the defect. + // Boundary first: only the rewrite preview's mapper meets the host before the container walk. const xml = operatonDoc` @@ -5908,27 +2956,6 @@ describe('xmlToIr: compensation import', () => { expect(e.elementId).toBe('CompensationBoundary'); }); }); - - it('a document that imports successfully has the same warnings whether or not its bpmn:association goes through the compensation-refusal lookup', async () => { - const xml = operatonDoc` - - - - - `; - - const { warnings } = await xmlToIr(xml); - expect(warnings).toEqual([ - { - elementId: 'p', - category: 'unmappedConstruct', - message: - "A bpmn:association 'Assoc1' on 'p' was not imported " + - '(this tool imports the executable flow and the engine settings ' + - 'on its steps, and nothing declared or drawn beside it).', - }, - ]); - }); }); describe('xmlToIr: boundary event import', () => { @@ -6029,7 +3056,6 @@ describe('xmlToIr: boundary event import', () => { PT1H `; - /** `PStart -> Review -> PEnd`, carrying the given boundary block. */ const reviewBoundaryDoc = ( boundary: string, extraFlows = '', @@ -6043,7 +3069,6 @@ ${extraFlows} `; describe('refusals', () => { - /** The whole refusal, since it enumerates every host noun. */ const unattachableDetail = (ref: string): string => `attachedToRef "${ref}" does not name a plain task, user task, ` + 'service task, send task, business rule task, receive task, script ' + @@ -6084,84 +3109,21 @@ ${extraFlows} - - - - - - - - - ${TIMER_1H} - - - - `, - unattachableDetail('Other'), - 'Boundary_Other_timer', - ], - [ - // The mirror of the case above: nesting runs its own host check over - // its own container, so an id one level out is not in scope. - 'a boundary event inside a sub-process attached to an id in the outer process', - bpmnDoc` - - - - - - ${TIMER_1H} - - - - - - - - - `, - unattachableDetail('Outer'), - 'Boundary_Outer_timer', - ], [ 'an attachedToRef naming a gateway in the same container', bpmnDoc` - - ${TIMER_1H} - - - - `, - unattachableDetail('Choose'), - 'Boundary_Choose_timer', - ], - [ - // An event sub-process is authored as a bare `on { ... }` and - // carries no id or name of its own, so nothing could name it as a host. - 'an attachedToRef naming an event sub-process', - reviewBoundaryDoc(` - - - - - - - - - + ${TIMER_1H} - `), - unattachableDetail('Handler'), - 'Boundary_Handler_timer', + + + + `, + unattachableDetail('Choose'), + 'Boundary_Choose_timer', ], [ - // `` is optional in BPMN and moddle fills `incoming` - // from those children alone, so the flow's own targetRef is what has - // to be checked; Operaton reads it either way. + // Moddle fills `incoming` from optional children alone; Operaton reads the flow's targetRef. 'a sequence flow targeting a boundary event with no bpmn:incoming child', reviewBoundaryDoc( ` @@ -6201,127 +3163,10 @@ ${extraFlows} - - - - - - - `; - - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedEventFeatureError, - ); - expect(e.elementId).toBe('Boundary_Ship_escalation'); - expect(e.detail).toBe( - 'an escalation boundary event attaches to "Ship", a service task; ' + - 'Operaton only allows an escalation boundary on a subprocess, a ' + - 'call activity, or a user task', - ); - }); - }); - - it('a boundary event written before its host imports cleanly', async () => { - // The whole reason host checking is a post-loop pass: moddle presents - // children in document order, and BPMN does not require the host first. - const xml = bpmnDoc` - - ${TIMER_1H} - - - - - `; - - const { node, warnings } = await importOnly(xml, 'boundaryEvent'); - expect(warnings).toEqual([]); - expect(node).toEqual( - boundaryEvent( - 'Boundary_Review_timer', - 'Review', - timerDef('duration', 'PT1H'), - ), - ); }); }); describe('xmlToIr: intermediate catch event import', () => { - it('imports message, timer (duration/date/cycle), signal, and conditional catches into the exact expected IR, incoming/outgoing preserved, warnings: []', async () => { - const xml = bpmnDefs` - - - - - - - - - PT1H - - - - - 2026-08-01T09:00:00 - - - - - R3/PT10M - - - - - - - - \${amount > 100} - - - - - - - - - - - `; - - const waits = ( - id: string, - eventDefinition: IntermediateCatchEvent['eventDefinition'], - ): FlowElement => ({ kind: 'intermediateCatchEvent', id, eventDefinition }); - - const chain: FlowElement[] = [ - { kind: 'startEvent', id: 'Start' }, - waits('WaitMsg', messageDef('PaymentReceived')), - waits('WaitDur', timerDef('duration', 'PT1H')), - waits('WaitDate', timerDef('date', '2026-08-01T09:00:00')), - waits('WaitCycle', timerDef('cycle', 'R3/PT10M')), - waits('WaitSig', signalDef('Ping')), - waits('WaitCond', conditionDef('${amount > 100}')), - { kind: 'endEvent', id: 'End' }, - ]; - - const expectedIr: BpmnProcess = minimalProcess( - chain, - chain.slice(1).map((el, i) => ({ - id: `F${i + 1}`, - sourceRef: chain[i]!.id, - targetRef: el.id, - })), - ); - - const { ir, warnings } = await xmlToIr(xml); - expect(ir).toEqual(expectedIr); - expect(warnings).toEqual([]); - }); - - /** `S -> Wait -> E`, where the catch carries the given definition. */ const unsupportedTriggerXml = (definitionXml: string, doc = bpmnDoc) => oneNodeDoc('intermediateCatchEvent', { id: 'Wait', @@ -6330,7 +3175,6 @@ describe('xmlToIr: intermediate catch event import', () => { }); describe('refuses an unsupported trigger', () => { - /** The whole refusal, since it names where each refused trigger does belong. */ const unawaitableDetail = (tag: string): string => `an await cannot carry a bpmn:${tag}: only message, timer, signal, ` + 'condition, or link triggers can be awaited inline; error and ' + @@ -6344,21 +3188,6 @@ describe('xmlToIr: intermediate catch event import', () => { '', unawaitableDetail('ErrorEventDefinition'), ], - [ - 'escalation', - '', - unawaitableDetail('EscalationEventDefinition'), - ], - [ - 'compensation', - '', - unawaitableDetail('CompensateEventDefinition'), - ], - [ - 'cancel', - '', - unawaitableDetail('CancelEventDefinition'), - ], ] as const)( 'a %s trigger refuses with UnsupportedEventFeatureError naming the form', async (_label, definitionXml, detail) => { @@ -6413,27 +3242,6 @@ ${definitions} 'an await with no event definition (a "none" intermediate catch) ' + 'waits for nothing this tool can represent', ], - [ - // A timer catch is a supported kind: only the shape is refused, so - // this is an UnsupportedEventFeatureError and not an - // UnsupportedElementError. - 'a bodyless timerEventDefinition on a catch', - unsupportedTriggerXml(''), - 'a timer definition must carry exactly one of timeDuration/timeDate/' + - 'timeCycle (found 0)', - ], - [ - // Narrowing inherited from the shared catch-definition read. - 'operaton:variableName on a conditional catch', - unsupportedTriggerXml( - ` - \${amount > 100} - `, - operatonDoc, - ), - "a conditional definition's operaton:variableName narrows when the " + - 'condition is (re-)evaluated, which this tool cannot represent', - ], ] as const)( '%s refuses with UnsupportedEventFeatureError naming the form', async (_title, xml, detail) => { @@ -6449,17 +3257,14 @@ describe('xmlToIr: link events', () => { interface PairOptions { throwAttrs?: string; - /** Extension content written before the throw's definition. */ throwChildren?: string; throwDef?: string; catchAttrs?: string; catchDef?: string; - /** Flows written on top of the three the pair gets. */ extraFlows?: string; defs?: XmlTag; } - /** `S -> A -> ToRetry` (the throw) and `AtRetry` (the catch) `-> E`. */ const pairDoc = ({ throwAttrs = 'name="Retry"', throwChildren = '', @@ -6484,40 +3289,6 @@ describe('xmlToIr: link events', () => { ${extraFlows} `; - it('a link pair imports as two disconnected events under one link name, and a name equal to the link name is nothing to report', async () => { - const { ir, warnings } = await xmlToIr(pairDoc()); - expect(warnings).toEqual([]); - expect([byId(ir, 'ToRetry'), byId(ir, 'AtRetry')]).toEqual([ - { kind: 'intermediateThrowEvent', id: 'ToRetry', eventDefinition: link }, - { kind: 'intermediateCatchEvent', id: 'AtRetry', eventDefinition: link }, - ]); - expect(ir.sequenceFlows.map((f) => [f.sourceRef, f.targetRef])).toEqual([ - ['S', 'A'], - ['A', 'ToRetry'], - ['AtRetry', 'E'], - ]); - }); - - it.each([ - ['the throw', 'ToRetry', 'an emit link', { throwAttrs: 'name="Go back"' }], - ['the catch', 'AtRetry', 'an await link', { catchAttrs: 'name="Go back"' }], - ] as const)( - 'a link event whose label is not its link name reports the label, on %s', - async (_end, id, surface, options) => { - const { ir, warnings } = await xmlToIr(pairDoc(options)); - expect(byId(ir, id)).not.toHaveProperty('name'); - expect(warnings).toEqual([ - { - elementId: id, - category: 'label', - message: - `The label 'Go back' on '${id}' was not imported: ${surface} ` + - "has no label in this tool's surface.", - }, - ]); - }, - ); - it('engine settings and listeners on a link throw are reported one each and reach the IR on the catch alone', async () => { const { ir, warnings } = await xmlToIr( pairDoc({ @@ -6565,12 +3336,6 @@ ${extraFlows} `; it.each([ ['the throw', 'ToRetry', { throwDef: '' }], - ['the catch', 'AtRetry', { catchDef: '' }], - [ - 'the throw, with an empty name', - 'ToRetry', - { throwDef: '' }, - ], ] as const)( 'a link definition with no name refuses, on %s', async (_end, id, options) => { @@ -6618,38 +3383,10 @@ ${extraFlows} `; ); }, ); - - it("a link on an event handler's start is still refused, like one on a process start, an end, or a boundary", async () => { - const e = await expectRefusal( - xmlToIr(handlerDoc(LINK_DEF)), - UnsupportedEventDefinitionError, - ); - expect([e.elementId, e.eventKind, e.definitionType]).toEqual([ - 'HStart', - 'start', - 'bpmn:LinkEventDefinition', - ]); - }); }); describe('xmlToIr: a label on an event the surface gives no label to', () => { - const TIMER = ` - PT1H - `; - it.each([ - [ - 'a throw', - 'Throw', - 'Custom Label', - rootedDoc( - ' \n', - ` - - - `, - ), - ], [ 'an emit', 'Emit', @@ -6661,43 +3398,6 @@ describe('xmlToIr: a label on an event the surface gives no label to', () => { doc: bpmnDoc, }), ], - [ - 'an event handler', - 'Handler', - 'Custom Handler Label', - bpmnDoc` - - - - - - - `, - ], - [ - 'a boundary event', - 'Boundary', - 'Timed out', - bpmnDoc` - - - ${TIMER} - - - - `, - ], - [ - 'an await', - 'Wait', - 'Awaiting payment', - oneNodeDoc('intermediateCatchEvent', { - id: 'Wait', - attrs: 'name="Awaiting payment"', - children: TIMER, - doc: bpmnDoc, - }), - ], ] as const)( 'on %s the label is dropped, and that is the only thing reported', async (surface, id, label, xml) => { @@ -6714,33 +3414,6 @@ describe('xmlToIr: a label on an event the surface gives no label to', () => { ]); }, ); - - it('an end named for a boundary escape keeps its label in the print, even when the boundary is written after it', async () => { - const { ir, warnings } = - await xmlToIr(bpmnDoc` - - - - ${TIMER} - - - - - `); - expect(warnings).toEqual([]); - const dsl = irToDsl(ir).source; - expect(dsl).toBe( - 'process p {\n' + - ' start S\n' + - ' user Review\n' + - ' end E\n' + - ' on Review: timer("PT1H") {\n' + - ' end EndEvent_Boundary(label: "Escaped")\n' + - ' }\n' + - '}\n', - ); - await expectParses(dsl); - }); }); describe('xmlToIr: flat engine settings on a user task', () => { @@ -6756,8 +3429,7 @@ describe('xmlToIr: flat engine settings on a user task', () => { 'operaton:dueDate="2026-08-01T09:00:00" ' + 'operaton:followUpDate="${followUp}" ' + 'operaton:priority="7"', - // The form data and the retry cycle share one extensionElements wrapper, - // so reading either has to leave the other for its own consumer. + // Form data and retry cycle share one extensionElements wrapper. extensionElements(` @@ -6780,45 +3452,10 @@ describe('xmlToIr: flat engine settings on a user task', () => { }); expect(warnings).toEqual([]); - // A job priority takes an expression as readily as an integer. const expression = await importUserTask('operaton:jobPriority="${high}"'); expect(expression.node.jobPriority).toBe('${high}'); expect(expression.warnings).toEqual([]); }); - - it('carries nothing, and warns nothing, for a flag written at its engine default', async () => { - const { node, warnings } = await importUserTask( - 'operaton:asyncBefore="false" operaton:asyncAfter="false" ' + - 'operaton:exclusive="true"', - ); - expect(node).toEqual({ kind: 'userTask', id: 'T' }); - expect(warnings).toEqual([]); - }); - - it.each([ - [ - 'operaton:async="true" imports as asyncBefore and names the respelling', - 'operaton:async="true"', - { asyncBefore: true }, - [ - { - elementId: 'T', - category: 'unmappedConstruct', - message: asyncRespellingMessage("'T'"), - }, - ], - ], - [ - 'operaton:async="false" carries nothing, as isAsyncBefore reads only "true"', - 'operaton:async="false"', - {}, - [], - ], - ])('%s', async (_title, attrs, settings, expected) => { - const { node, warnings } = await importUserTask(attrs); - expect(node).toEqual({ kind: 'userTask', id: 'T', ...settings }); - expect(warnings).toEqual(expected); - }); }); describe('xmlToIr: BPMN-native assignment and the quantity attributes', () => { const role = ( @@ -6844,53 +3481,6 @@ describe('xmlToIr: BPMN-native assignment and the quantity attributes', () => { const reported = (warnings: ImportWarning[]) => warnings.map((w) => [w.category, w.elementId, w.message]); - it('merges the roles before the operaton: attributes, in the order the engine builds its lists, and names each rewrite', async () => { - const { node, warnings } = await importReview( - 'operaton:candidateUsers="bob" operaton:candidateGroups="audit"', - role('humanPerformer', 'Lead', 'demo') + - role('potentialOwner', 'Team', 'user(mary), group(managers)') + - role('potentialOwner', 'Finance', 'finance'), - ); - expect(node).toEqual({ - kind: 'userTask', - id: 'Review', - assignee: 'demo', - candidateUsers: 'mary,bob', - candidateGroups: 'managers,finance,audit', - }); - expect(reported(warnings)).toEqual([ - [ - 'unmappedConstruct', - 'Review', - expect.stringMatching( - /^The bpmn:humanPerformer 'Lead' on 'Review' imports as assignee: "demo":.*parseHumanPerformerResourceAssignment.*operaton:assignee/, - ), - ], - [ - 'unmappedConstruct', - 'Review', - expect.stringMatching( - /^The bpmn:potentialOwner 'Team' on 'Review' imports as candidateUsers: "mary" and candidateGroups: "managers":.*parsePotentialOwnerResourceAssignment/, - ), - ], - [ - 'unmappedConstruct', - 'Review', - expect.stringMatching( - /^The bpmn:potentialOwner 'Finance' on 'Review' imports as candidateGroups: "finance":/, - ), - ], - ]); - }); - - it('an empty operaton:assignee is kept as written, since parseUserTaskCustomExtensions reads it as one', async () => { - const { node, warnings } = await importReview('operaton:assignee=""', ''); - expect([node, warnings]).toEqual([ - { kind: 'userTask', id: 'Review', assignee: '' }, - [], - ]); - }); - it.each([ [ 'a humanPerformer beside operaton:assignee', @@ -6898,12 +3488,6 @@ describe('xmlToIr: BPMN-native assignment and the quantity attributes', () => { role('humanPerformer', 'Lead', 'demo'), /'Lead'.*"demo".*operaton:assignee="bob".*parseUserTaskCustomExtensions/, ], - [ - 'a humanPerformer beside an empty operaton:assignee', - 'operaton:assignee=""', - role('humanPerformer', 'Lead', 'demo'), - /'Lead'.*"demo".*operaton:assignee="".*parseUserTaskCustomExtensions/, - ], [ 'two humanPerformers', '', @@ -7138,16 +3722,6 @@ describe('xmlToIr: flat engine settings on every carrying node kind', () => { }; it.each([ - { - title: 'an expression-bound service task', - tag: 'serviceTask', - attrs: - 'operaton:expression="${svc.run()}" operaton:resultVariableName="out"', - children: '', - kind: 'serviceTask' as const, - resultVariable: 'out', - warnings: [RESPELLED_RESULT_VARIABLE], - }, { title: 'a script task', tag: 'scriptTask', @@ -7157,15 +3731,6 @@ describe('xmlToIr: flat engine settings on every carrying node kind', () => { resultVariable: 'out', warnings: [RESPELLED_RESULT_VARIABLE], }, - { - title: 'a decision-bound business rule task', - tag: 'businessRuleTask', - attrs: 'operaton:decisionRef="d" operaton:resultVariableName="out"', - children: '', - kind: 'serviceTask' as const, - resultVariable: 'out', - warnings: [RESPELLED_RESULT_VARIABLE], - }, { title: 'both spellings written, which keeps resultVariable and reports the older one', @@ -7197,33 +3762,9 @@ describe('xmlToIr: flat engine settings on every carrying node kind', () => { expect(imported.warnings).toEqual(warnings); }, ); - - it('an event handler and its trigger start each carry their own settings', async () => { - const xml = operatonDefs` - - - - - - - - - - - - `; - - const { ir, warnings } = await xmlToIr(xml); - const handler = subProcess(ir, 'Handler'); - expect(handler.asyncBefore).toBe(true); - const start = byId(handler, 'HStart'); - expect(start.kind === 'startEvent' && start.asyncAfter).toBe(true); - expect(warnings).toEqual([]); - }); }); describe("xmlToIr: a timer job's lock is read where BpmnParse.parseTimer reads it", () => { - /** `S -> Host -> E` with a boundary timer on the host, its tag and definition carrying the given attributes. */ const boundaryTimerDoc = (tagAttrs: string, defAttrs: string): string => operatonDefs` @@ -7240,7 +3781,6 @@ describe("xmlToIr: a timer job's lock is read where BpmnParse.parseTimer reads i `; - /** `S -> E` with a timer-started event sub-process `Handler` alongside; `subAttrs` go on the sub-process tag. */ const handlerTimerDoc = ( tagAttrs: string, defAttrs: string, @@ -7259,35 +3799,23 @@ describe("xmlToIr: a timer job's lock is read where BpmnParse.parseTimer reads i `; - const BOTH_JOBS_WARNING = warning( - 'B', - /operaton:exclusive="true" on the event.*continuation job.*operaton:exclusive="false" on its timer definition.*timer job.*took the timer definition's/, - ); - const CONTINUATION_COPY_WARNING = warning( 'Handler', /'jobPriority' setting on 'Handler' was not imported: the 'on timer' head.*timer job.*continuation job/, ); - it.each< - [string, string, { tag?: string; def?: string; sub?: string }, unknown] - >([ - [ - 'the definition alone on a boundary', - 'B', - { def: 'operaton:exclusive="false"' }, - { exclusive: false, warnings: [] }, - ], - [ - 'the definition alone on an event sub-process start', - 'HStart', - { def: 'operaton:exclusive="false"' }, - { exclusive: false, warnings: [] }, - ], + const BOTH_JOBS_WARNING = warning( + 'B', + /operaton:exclusive="true" on the event.*continuation job.*operaton:exclusive="false" on its timer definition.*timer job.*took the timer definition's/, + ); + + it.each< + [string, string, { tag?: string; def?: string; sub?: string }, unknown] + >([ [ - 'the tag alone, which the definition falls back to', + 'the definition alone on a boundary', 'B', - { tag: 'operaton:exclusive="false"' }, + { def: 'operaton:exclusive="false"' }, { exclusive: false, warnings: [] }, ], [ @@ -7327,11 +3855,7 @@ const TIMER_CATCH = ` `; -/** - * `S -> G -> C -> E`, `C` a timer so the one shape also holds for a wait. - * The gateway is written first or last among the nodes, so a sweep keyed - * on document position meets it at either end. - */ +// The gateway is written first or last, so a sweep keyed on document position meets it at either end. const gatewayDoc = ( tag: string, attrs: string, @@ -7354,189 +3878,23 @@ const gatewayDoc = ( }; describe('xmlToIr: the job settings on a gateway', () => { - const settingAttrs = (prefix: 'operaton' | 'camunda', asyncAfter = true) => - `${prefix}:asyncBefore="true" ` + - (asyncAfter ? `${prefix}:asyncAfter="true" ` : '') + - `${prefix}:exclusive="false" ${prefix}:jobPriority="7"`; - const RETRY_CHILD = extensionElements( - ' R3/PT10M', - ); - const ALL_FIVE = { - asyncBefore: true, - asyncAfter: true, - exclusive: false, - jobPriority: '7', - retryCycle: 'R3/PT10M', - }; - it.each([ - { - title: 'a split written first carries all five', - tag: 'exclusiveGateway', - attrs: settingAttrs('operaton'), - children: RETRY_CHILD, - placement: 'first' as const, - settings: ALL_FIVE, - warnings: [], - }, - { - title: - 'a conditioned fork written last carries all five under the camunda prefix', - tag: 'inclusiveGateway', - attrs: settingAttrs('camunda'), - children: RETRY_CHILD, - placement: 'last' as const, - doc: dualDoc, - settings: ALL_FIVE, - warnings: [['p', 'unmappedConstruct', CAMUNDA_ALIAS_MESSAGE]], - }, - { - title: 'a fork written last carries all five', - tag: 'parallelGateway', - attrs: settingAttrs('operaton'), - children: RETRY_CHILD, - placement: 'last' as const, - settings: ALL_FIVE, - warnings: [], - }, - { - title: 'a wait carries the four the engine deploys on it', - tag: 'eventBasedGateway', - attrs: settingAttrs('operaton', false), - children: RETRY_CHILD, - placement: 'first' as const, - settings: { - asyncBefore: true, - exclusive: false, - jobPriority: '7', - retryCycle: 'R3/PT10M', - }, - warnings: [], - }, - { - title: - 'a split written with the older async spelling carries asyncBefore and names the respelling', - tag: 'exclusiveGateway', - attrs: 'operaton:async="true"', - placement: 'first' as const, - settings: { asyncBefore: true }, - warnings: [['G', 'unmappedConstruct', asyncRespellingMessage("'G'")]], - }, - { - title: - 'an execution listener on a split is dropped naming the parser that runs it', - tag: 'exclusiveGateway', - attrs: '', - children: extensionElements( - ' ', - ), - placement: 'last' as const, - settings: {}, - warnings: [ - [ - 'G', - 'extensionAttribute', - unread( - "operaton:executionListener 'start'", - 'G', - 'a ', - 'runs it (BpmnParse.parseExecutionListenersOnScope)', - ).message, - ], - ], - }, - ])('$title', async (row) => { - const { ir, warnings } = await xmlToIr(gatewayDoc(row.tag, row.attrs, row)); - expect(byId(ir, 'G')).toEqual({ kind: row.tag, id: 'G', ...row.settings }); - expect(warnings.map((w) => [w.elementId, w.category, w.message])).toEqual( - row.warnings, + it('a split written with the older async spelling carries asyncBefore and names the respelling', async () => { + const { ir, warnings } = await xmlToIr( + gatewayDoc('exclusiveGateway', 'operaton:async="true"', { + placement: 'first', + }), ); - }); - - it.each([ - ['a decimal', '1.5'], - ['a word', 'abc'], - ])( - '%s job priority refuses, quoting parsePriority', - async (_title, value) => { - const err = await expectRefusal( - xmlToIr( - gatewayDoc('exclusiveGateway', `operaton:jobPriority="${value}"`), - ), - UnsupportedExtensionFormError, - priorityRefusal('jobPriority', value), - ); - expect(err.elementId).toBe('G'); - }, - ); -}); - -describe('xmlToIr: content is consumed only on the owner kind that reads it', () => { - const serviceTaskXml = (attrs: string, children = ''): string => - oneNodeDoc('serviceTask', { - id: 'Svc', - attrs: `operaton:class="com.example.Svc" ${attrs}`, - children, - }); - - it.each([ - [ - 'an operaton:formData, which is read off a start event or a user task', - '', - extensionElements(` - - `), - /FormData/i, - ], - [ - 'an operaton:assignee, which is read off a user task', - 'operaton:assignee="alice"', - '', - 'assignee', - ], - ] as const)( - 'a service task carrying %s reports it as dropped', - async (_title, attrs, children, message) => { - const { warnings } = await xmlToIr(serviceTaskXml(attrs, children)); - expectOneWarning(extensionWarnings(warnings), { - elementId: 'Svc', - message, - }); - }, - ); - - it('an operaton:in on a user task warns, while the same element on a call activity does not', async () => { - const mapping = ` - - - - `; - const onUserTask = operatonDoc` - ${mapping} - - - `; - const onCallActivity = operatonDoc` - ${mapping} - - - `; - - const taskResult = await xmlToIr(onUserTask); - expectOneWarning(extensionWarnings(taskResult.warnings), { - elementId: 'T', - message: /In/i, + expect(byId(ir, 'G')).toEqual({ + kind: 'exclusiveGateway', + id: 'G', + asyncBefore: true, }); - - const callResult = await xmlToIr(onCallActivity); - expect(callResult.warnings).toEqual([]); - const call = byId(callResult.ir, 'C'); - expect(call.kind === 'callActivity' && call.inMappings).toEqual([ - { kind: 'variable', source: 'a', target: 'b' }, + expect(warnings.map((w) => [w.elementId, w.category, w.message])).toEqual([ + ['G', 'unmappedConstruct', asyncRespellingMessage("'G'")], ]); }); }); -/** A class-bound service task carrying the given `` body: an activity, so it takes both the io block and execution listeners. */ const serviceTaskWith = (children: string): string => oneNodeDoc('serviceTask', { id: 'Svc', @@ -7547,7 +3905,6 @@ const serviceTaskWith = (children: string): string => const importServiceTask = (children: string) => importById(serviceTaskWith(children), 'Svc', 'serviceTask'); -/** The user-task counterpart of {@link serviceTaskWith}, for what only it carries. */ const userTaskWith = (children: string): string => oneNodeDoc('userTask', { id: 'Review', @@ -7557,92 +3914,10 @@ const userTaskWith = (children: string): string => const importUserTaskWith = (children: string) => importById(userTaskWith(children), 'Review', 'userTask'); -/** One `` block wrapping the given parameter elements. */ const ioBlock = (params: string): string => ` \n${params}\n `; describe('xmlToIr: input/output parameters', () => { - it('each of the four value forms imports, in declaration order', async () => { - const { node: task, warnings } = await importServiceTask( - ioBlock(` bar - - - a - b - - - - - v - - - - 1 + 1 - - \${execution.out}`), - ); - - expect(task.inputParameters).toEqual([ - ioParam('plain', textValue('bar')), - ioParam('items', listValue([textValue('a'), textValue('b')])), - ioParam('lookup', mapValue([mapEntry('k', textValue('v'))])), - ioParam('computed', scriptValue('groovy', '1 + 1')), - ]); - expect(task.outputParameters).toEqual([ - ioParam('result', textValue('${execution.out}')), - ]); - expect(warnings).toEqual([]); - }); - - it('a list of maps and a map of lists both import, nested either way', async () => { - const { node: task, warnings } = await importServiceTask( - ioBlock(` - - - v - - - - - - - - z - - - - `), - ); - - expect(task.inputParameters).toEqual([ - ioParam( - 'listOfMaps', - listValue([mapValue([mapEntry('k', textValue('v'))])]), - ), - ioParam( - 'mapOfLists', - mapValue([mapEntry('k', listValue([textValue('z')]))]), - ), - ]); - expect(warnings).toEqual([]); - }); - - it('a parameter with an empty body imports as empty text', async () => { - const { node: task, warnings } = await importServiceTask( - ioBlock(' '), - ); - expect(task.inputParameters).toEqual([ioParam('nothing', textValue(''))]); - expect(warnings).toEqual([]); - }); - - /** One `` holding one io block, optionally with an output parameter beside the input. */ - const IO = ( - params = '1', - ): string => - extensionElements(` - ${params} - `); - it('every activity kind reads its own io block, and an event that runs one reports it naming parseActivityInputOutput', async () => { const xml = operatonDefs` @@ -7702,7 +3977,21 @@ describe('xmlToIr: input/output parameters', () => { ]); }); - /** The refusal an io mapping draws where `method` fails the deployment on the tag. */ + it('a parameter with an empty body imports as empty text', async () => { + const { node: task, warnings } = await importServiceTask( + ioBlock(' '), + ); + expect(task.inputParameters).toEqual([ioParam('nothing', textValue(''))]); + expect(warnings).toEqual([]); + }); + + const IO = ( + params = '1', + ): string => + extensionElements(` + ${params} + `); + const ioRefusal = (tag: string, method: string, suffix = ''): string => `an operaton:inputOutput mapping on a , which ` + `BpmnParse.${method} fails the deployment on ("operaton:inputOutput ` + @@ -7741,19 +4030,12 @@ describe('xmlToIr: input/output parameters', () => { 'B', ioRefusal('boundaryEvent', 'ensureNoIoMappingDefined'), ], - ...( - [ - 'exclusiveGateway', - 'inclusiveGateway', - 'parallelGateway', - 'eventBasedGateway', - ] as const - ).map((tag): [string, string, string, string] => [ - `a ${tag}`, - gatewayDoc(tag, '', { children: IO() }), + [ + 'a parallelGateway', + gatewayDoc('parallelGateway', '', { children: IO() }), 'G', - ioRefusal(tag, 'checkActivityInputOutputSupported'), - ]), + ioRefusal('parallelGateway', 'checkActivityInputOutputSupported'), + ], [ 'an event sub-process', handlerDoc('', { @@ -7788,8 +4070,6 @@ describe('xmlToIr: input/output parameters', () => { }, ); - // `BpmnParseUtil.parseNestedParamValueProvider` reads `element.elements()` - // alone, so body text beside the one nested child never reaches the engine. it('a parameter carrying body text beside one nested child keeps the child and warns on the text', async () => { const { node: task, warnings } = await importServiceTask( ioBlock( @@ -7815,76 +4095,6 @@ describe('xmlToIr: input/output parameters', () => { }); describe('xmlToIr: execution listeners', () => { - it('each of the four bindings imports, in emission order', async () => { - const { node: task, warnings } = await importServiceTask( - ` - `, - ); - expect(task.executionListeners).toEqual([ - { event: 'start', binding: classBinding('com.example.L') }, - { event: 'end', binding: exprBinding('${bean.done()}') }, - ]); - expect(warnings).toEqual([]); - - const delegated = await importServiceTask( - ` `, - ); - expect(delegated.node.executionListeners).toEqual([ - { event: 'start', binding: delegateBinding('${listenerBean}') }, - ]); - - const scripted = await importServiceTask( - ` - println 'done' - `, - ); - expect(scripted.node.executionListeners).toEqual([ - { event: 'end', binding: scriptValue('groovy', "println 'done'") }, - ]); - expect(scripted.warnings).toEqual([]); - }); - - it('every node kind that carries engine settings carries listeners too', async () => { - const listener = ` - - `; - const xml = operatonDoc` -${listener} - - -${listener} - - -${listener} - - - - - -${listener} - - PT1H - - - -${listener} - - - - - `; - - const { ir, warnings } = await xmlToIr(xml); - const carrying = ir.flowElements - .filter( - (fe) => - 'executionListeners' in fe && fe.executionListeners !== undefined, - ) - .map((fe) => fe.id); - expect(carrying).toEqual(['Start', 'Review', 'Booking', 'Timeout', 'End']); - expect(warnings).toEqual([]); - }); - it('a listener on the process, a flow or a gateway is dropped naming the parser that runs it', async () => { const listener = (event: string): string => extensionElements( @@ -7924,57 +4134,31 @@ ${listener} it.each([ { - case: 'two listeners on one event import in document order, since Operaton appends rather than replaces', - children: ` - `, - listeners: [ - { event: 'start', binding: classBinding('com.example.A') }, - { event: 'start', binding: classBinding('com.example.B') }, - ], - warnings: [], - }, - { - case: 'a second attribute binding is shadowed by the one BpmnParse.parseExecutionListener resolves first', - children: ` `, - listeners: [{ event: 'start', binding: classBinding('com.example.A') }], + case: 'an empty expression carries as written, since ExpressionExecutionListener evaluates it rather than refusing it', + children: ` `, + listeners: [{ event: 'start', binding: exprBinding('') }], warnings: [ { elementId: 'Svc', category: 'extensionAttribute', message: - "The 'expression' setting on an operaton:executionListener on " + - "'Svc' has no effect alongside class and was not imported.", + 'An operaton:executionListener on \'Svc\' has expression="": ' + + 'ExpressionExecutionListener evaluates the empty text rather ' + + 'than refusing it, and the document written back carries it.', }, ], }, { - case: 'a script child is shadowed the same way by an attribute binding', - children: ` - 1 - `, + case: 'a second attribute binding is shadowed by the one BpmnParse.parseExecutionListener resolves first', + children: ` `, listeners: [{ event: 'start', binding: classBinding('com.example.A') }], warnings: [ { elementId: 'Svc', category: 'extensionAttribute', message: - "The 'script' setting on an operaton:executionListener on 'Svc' " + - 'has no effect alongside class and was not imported.', - }, - ], - }, - { - case: 'an empty expression carries as written, since ExpressionExecutionListener evaluates it rather than refusing it', - children: ` `, - listeners: [{ event: 'start', binding: exprBinding('') }], - warnings: [ - { - elementId: 'Svc', - category: 'extensionAttribute', - message: - 'An operaton:executionListener on \'Svc\' has expression="": ' + - 'ExpressionExecutionListener evaluates the empty text rather ' + - 'than refusing it, and the document written back carries it.', + "The 'expression' setting on an operaton:executionListener on " + + "'Svc' has no effect alongside class and was not imported.", }, ], }, @@ -8023,79 +4207,9 @@ describe('xmlToIr: task listeners', () => { }, ]); expect(warnings).toEqual([]); - }); - - it('a task listener on a service task stays a reported drop', async () => { - const { warnings } = await importServiceTask( - ` `, - ); - expectOneWarning(warnings, { elementId: 'Svc', message: /TaskListener/i }); - }); - - // The export mints a timeout listener's id, the key - // `BpmnParse.parseTimeoutTaskListener` requires, so the one read is not - // carried; on any other event `parseTaskListener` never reads it. - it('a listener id is consumed without a warning, on a timeout and elsewhere', async () => { - const { node: task, warnings } = await importUserTaskWith( - ` - - - PT8H - - `, - ); - expect(task.taskListeners).toEqual([ - { event: 'create', binding: classBinding('com.example.L') }, - { - event: 'timeout', - binding: classBinding('com.example.T'), - timer: timerDef('duration', 'PT8H'), - }, - ]); - expect(warnings).toEqual([]); - }); - - it('an undeclared operaton attribute and a foreign one on a listener both report', async () => { - const { node: task, warnings } = await importUserTaskWith( - ` `, - ); - expect(task.taskListeners?.[0].binding).toEqual( - classBinding('com.example.L'), - ); - expect(warnings.map((w) => w.message)).toEqual([ - expect.stringMatching(/'operaton:mystery' on an operaton:taskListener/), - expect.stringMatching(/'foo:bar' on an operaton:taskListener/), - ]); - }); - - it.each([ - { - case: 'two timeout listeners import in document order, each carrying its own timer, since TaskDefinition.addTimeoutTaskListener keys by id rather than replacing', - children: ` - - PT1H - - - - - 2026-08-01T09:00:00 - - `, - listeners: [ - { - event: 'timeout', - binding: classBinding('com.example.T'), - timer: timerDef('duration', 'PT1H'), - }, - { - event: 'timeout', - binding: classBinding('com.example.D'), - timer: timerDef('date', '2026-08-01T09:00:00'), - }, - ], - warnings: [], - }, + }); + + it.each([ { case: 'an empty class carries as written, since parseTaskListener checks no attribute for emptiness', children: ` `, @@ -8219,20 +4333,6 @@ describe('xmlToIr: a consumed extension child reports its own unread attributes' expect(warnings[0].message).toMatch(/operaton:inputParameter 'x'/); }); - it('an undeclared operaton attribute and a foreign one on an io parameter both report', async () => { - const { node: task, warnings } = await importServiceTask( - ioBlock(` v`), - ); - expect(task.inputParameters).toEqual([ioParam('x', textValue('v'))]); - expect(warnings.map((w) => w.message)).toEqual([ - expect.stringMatching( - /'operaton:mystery' on an operaton:inputParameter 'x'/, - ), - expect.stringMatching(/'foo:bar' on an operaton:inputParameter 'x'/), - ]); - }); - it.each([ [ 'a foreign attribute on the operaton:inputOutput block itself reports', @@ -8243,45 +4343,6 @@ describe('xmlToIr: a consumed extension child reports its own unread attributes' ), [/'foo:bar' on an operaton:inputOutput/], ], - [ - 'two form fields are told apart by the id their warnings name', - oneNodeDoc('userTask', { - id: 'Review', - children: ` - - - - - - - `, - }), - [ - /'foo:bar' on an operaton:formField 'approve'/, - /'foo:bar' on an operaton:formField 'comment'/, - ], - ], - [ - 'in and out mappings are told apart by the end their warnings name', - oneNodeDoc('callActivity', { - id: 'Call', - attrs: 'calledElement="sub"', - children: ` - - - - - - - `, - }), - [ - /'foo:bar' on an operaton:in 'amount'/, - /'foo:bar' on an operaton:in 'customer'/, - /'foo:bar' on an operaton:in '\$\{now\(\)\}'/, - /'foo:bar' on an operaton:out 'verdict'/, - ], - ], ] as const)('%s', async (_title, xml, expected) => { const { warnings } = await xmlToIr(xml); expect(warnings.map((w) => w.message)).toEqual( @@ -8290,13 +4351,11 @@ describe('xmlToIr: a consumed extension child reports its own unread attributes' }); }); -/** One `operaton:field`, self-closing unless given a child element as its body. */ const field = (attrs: string, body = ''): string => body === '' ? ` ` : ` ${body}`; -/** The reason a field under a binding that receives no field list draws. */ const boundElsewhere = (binding: string): string => 'Operaton injects a field into a class, a delegate or a built-in mail or ' + `shell binding and into no other, and this one is bound by ${binding}`; @@ -8340,41 +4399,6 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => ServiceTaskBinding, ImportWarning[], ][] = [ - [ - 'a class binding carries a quoted value as the literal the engine injects', - 'serviceTask', - CLASS, - GREETING, - SVC_INJECTED, - [], - ], - [ - 'a delegate binding carries an operaton:expression child as the expression the engine evaluates', - 'sendTask', - 'operaton:delegateExpression="${svcBean}"', - field( - 'name="greeting"', - '${who}', - ), - { - kind: 'delegateExpression', - expression: '${svcBean}', - fields: [{ name: 'greeting', value: '${who}' }], - }, - [], - ], - [ - 'a code-bound business rule task keeps both of its fields in the order the document writes them', - 'businessRuleTask', - CLASS, - `${GREETING}\n${field('name="role" stringValue="clerk"')}`, - { - kind: 'class', - className: 'com.example.Svc', - fields: [...INJECTED, { name: 'role', value: 'clerk' }], - }, - [], - ], [ 'an operaton:string child carries, and warns that export writes it back as a stringValue attribute', 'serviceTask', @@ -8389,21 +4413,6 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => ), ], ], - [ - 'a delegate binding carries a #{...} operaton:expression child as written', - 'sendTask', - 'operaton:delegateExpression="${svcBean}"', - field( - 'name="greeting"', - '#{who}', - ), - { - kind: 'delegateExpression', - expression: '${svcBean}', - fields: [{ name: 'greeting', value: '#{who}' }], - }, - [], - ], [ 'a stringValue that reads as an expression drops, because export would write it back as an operaton:expression the engine evaluates', 'serviceTask', @@ -8418,20 +4427,6 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => ), ], ], - [ - 'a stringValue that reads as a #{...} expression drops the same way', - 'serviceTask', - CLASS, - field('name="greeting" stringValue="#{who}"'), - SVC, - [ - dropped( - "a stringValue attribute holding '#{who}' would be written back " + - 'as an operaton:expression child, and the engine would evaluate ' + - 'it rather than inject the text', - ), - ], - ], [ 'an operaton:expression child that does not read as an expression drops, because export would write it back as the literal stringValue, and its text is quoted on one line', 'serviceTask', @@ -8468,24 +4463,6 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => ), ], ], - [ - 'an operaton:string child keeps every space it carries, because the engine injects it verbatim', - 'serviceTask', - CLASS, - field('name="greeting"', ' hello '), - { - kind: 'class', - className: 'com.example.Svc', - fields: [{ name: 'greeting', value: ' hello ' }], - }, - [ - warned( - "The injected field 'greeting' on 'Svc' writes its value in an " + - 'operaton:string child, which this tool writes back as a ' + - 'stringValue attribute; the engine injects the same text either way.', - ), - ], - ], [ 'a stringValue beside an operaton:expression child carries the stringValue Operaton reads, and reports the child it passes over', 'serviceTask', @@ -8517,22 +4494,6 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => ), ], ], - [ - 'an expression binding receives no field list, so the field drops', - 'serviceTask', - 'operaton:expression="${svcBean.run()}"', - GREETING, - exprBinding('${svcBean.run()}'), - [dropped(boundElsewhere('operaton:expression'))], - ], - [ - 'an external topic receives no field list, so the field drops', - 'serviceTask', - 'operaton:type="external" operaton:topic="rate"', - GREETING, - externalBinding('rate'), - [dropped(boundElsewhere('operaton:type="external"'))], - ], [ 'a decision binding receives no field list, so the field drops', 'businessRuleTask', @@ -8565,20 +4526,6 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => 'shell behaviour does, and on no other position.', ]); }); - - it('a carried field still reports the attributes no reader reads off it', async () => { - const { node, warnings } = await importBound( - 'serviceTask', - CLASS, - field( - 'xmlns:foo="http://foo.example" name="greeting" stringValue="hello" foo:bar="1"', - ), - ); - expect(node.binding).toEqual(SVC_INJECTED); - expect(warnings.map((w) => w.message)).toEqual([ - expect.stringMatching(/'foo:bar' on an operaton:field 'greeting'/), - ]); - }); }); describe('xmlToIr: a mail or shell task imports with its fields on the three tags', () => { @@ -8610,31 +4557,7 @@ describe('xmlToIr: a mail or shell task imports with its fields on the three tag message: `The '${attr}' setting on 'T' has no effect alongside ${winner} and was not imported.`, }); - const TAGS = [ - ['serviceTask', {}], - ['sendTask', { element: 'send' }], - ['businessRuleTask', { element: 'businessRule' }], - ] as const; - it.each([ - ...TAGS.flatMap(([tag, element]) => [ - { - title: `a mail task on bpmn:${tag} carries a field in each value slot`, - tag, - attrs: 'operaton:type="mail"', - fields: MAIL_FIELDS, - node: { binding: MAIL, ...element }, - warnings: [], - }, - { - title: `a shell task on bpmn:${tag} carries its fields`, - tag, - attrs: 'operaton:type="shell"', - fields: SHELL_FIELDS, - node: { binding: SHELL, ...element }, - warnings: [], - }, - ]), { title: 'operaton:type="Shell" imports lower-case, and a resultVariable beside it stays on the node with the never-read warning', @@ -8712,34 +4635,7 @@ describe('xmlToIr: a service-like task the engine would refuse is refused with i children: fields.length === 0 ? '' : extensionElements(fields.join('\n')), }); - const refusedByEmail = (missing: string, rule: string): string => - `operaton:type="mail" without ${missing} field, which Operaton refuses ` + - `to deploy: "${rule}" (BpmnParse.validateFieldDeclarationsForEmail)`; - it.each([ - [ - 'a mail task with cc and text and no to, since cc satisfies nothing', - typed('mail', [field('name="cc" stringValue="lead@example.com"'), TEXT]), - 'Service task', - refusedByEmail("a 'to'", 'No recipient is defined on the mail activity'), - ], - [ - 'a mail task with to and no body', - typed('mail', [TO]), - 'Service task', - refusedByEmail( - "a 'text' or 'html'", - 'Text or html field should be provided', - ), - ], - [ - 'a shell task without a command', - typed('shell', [field('name="arg1" stringValue="x"')]), - 'Service task', - 'operaton:type="shell" without a \'command\' field, which Operaton ' + - 'refuses to deploy: "No shell command is defined on the shell ' + - 'activity" (BpmnParse.validateFieldDeclarationsForShell)', - ], [ 'a shell task with a field written as an expression', typed('shell', [ @@ -8752,18 +4648,6 @@ describe('xmlToIr: a service-like task the engine would refuse is refused with i 'BpmnParse.validateFieldDeclarationsForShell casts every shell field ' + 'to a FixedValue, and an expression is not one', ], - [ - 'a shell task with a field written as a #{...} expression', - typed('shell', [ - COMMAND, - field('name="arg1"', '#{x}'), - ]), - 'Service task', - 'operaton:type="shell" with the field \'arg1\' written as an ' + - 'operaton:expression, which Operaton fails to deploy: ' + - 'BpmnParse.validateFieldDeclarationsForShell casts every shell field ' + - 'to a FixedValue, and an expression is not one', - ], [ 'a shell wait flag outside true and false', typed('shell', [COMMAND, field('name="wait" stringValue="yes"')]), @@ -8802,29 +4686,6 @@ describe('xmlToIr: a service-like task the engine would refuse is refused with i 'operaton:type="mail", which this surface carries on a service, send ' + 'or business rule task alone', ], - [ - 'a result variable beside a class, which only an expression binding is built with', - oneNodeDoc('serviceTask', { - attrs: - 'operaton:class="com.example.Svc" operaton:resultVariable="outcome"', - }), - 'Service task', - 'operaton:class with operaton:resultVariable, which Operaton refuses ' + - "to deploy: \"'resultVariableName' not supported for serviceTask " + - "elements using 'class'\" (BpmnParse.parseServiceTaskLike)", - ], - [ - 'the older resultVariableName spelling beside a delegate on a business rule task, read by the same parse', - oneNodeDoc('businessRuleTask', { - attrs: - 'operaton:delegateExpression="${rater}" operaton:resultVariableName="rating"', - }), - 'Business rule task', - 'operaton:delegateExpression with operaton:resultVariableName, which ' + - "Operaton refuses to deploy: \"'resultVariableName' not supported " + - "for businessRuleTask elements using 'delegateExpression'\" " + - '(BpmnParse.parseServiceTaskLike)', - ], ])('%s', async (_title, xml, subject, construct) => { const e = await expectRefusal( xmlToIr(xml), @@ -8835,130 +4696,21 @@ describe('xmlToIr: a service-like task the engine would refuse is refused with i }); }); -describe('xmlToIr: a listener carries an injected field on the same two bindings', () => { - const GREETING = - ' '; - const SCRIPT = - ' x = 1\n'; - - /** The single listener's binding, off the node each listener kind hangs on. */ - const importListener = async ( - kind: 'execution' | 'task', - attrs: string, - body = '', - ): Promise<{ binding: ListenerBinding; warnings: ImportWarning[] }> => { - const child = (tag: string, event: string): string => - ` \n${body}${GREETING}\n `; - - if (kind === 'execution') { - const { node, warnings } = await importServiceTask( - child('executionListener', 'start'), - ); - return { binding: node.executionListeners![0].binding, warnings }; - } - const { node, warnings } = await importUserTaskWith( - child('taskListener', 'create'), - ); - return { binding: node.taskListeners![0].binding, warnings }; - }; - - const cases: readonly [ - string, - 'execution' | 'task', - string, - string, - ListenerBinding, - ImportWarning[], - ][] = [ - [ - 'an execution listener bound by class carries the field onto its own binding', - 'execution', - 'class="com.example.L"', - '', - { - kind: 'class', - className: 'com.example.L', - fields: [{ name: 'greeting', value: 'hello' }], - }, - [], - ], - [ - 'a task listener bound by a delegate expression carries the field onto its own binding', - 'task', - 'delegateExpression="${listenerBean}"', - '', - { - kind: 'delegateExpression', - expression: '${listenerBean}', - fields: [{ name: 'greeting', value: 'hello' }], - }, - [], - ], - [ - 'a listener bound by a fenced script receives no field list, so the field drops', - 'execution', - '', - SCRIPT, - scriptValue('groovy', 'x = 1'), - [ - { - elementId: 'Svc', - category: 'extensionAttribute', - message: - "The injected field 'greeting' on an operaton:executionListener " + - `on 'Svc' was not imported: ${boundElsewhere('an operaton:script child')}.`, - }, - ], - ], - ]; - - it.each(cases)('%s', async (_title, kind, attrs, body, binding, expected) => { - const { binding: imported, warnings } = await importListener( - kind, - attrs, - body, - ); - expect(imported).toEqual(binding); - expect(warnings).toEqual(expected); - }); -}); - describe('xmlToIr: a user task names a deployed form by reference', () => { const importFormRef = (attrs: string) => importOnly(oneNodeDoc('userTask', { attrs }), 'userTask'); - const bound: readonly [string, string, VersionBinding][] = [ - [ - 'the latest deployed form', - 'operaton:formRefBinding="latest"', - { kind: 'latest' }, - ], - [ - 'the form deployed alongside the process', - 'operaton:formRefBinding="deployment"', - { kind: 'deployment' }, - ], - [ - 'one pinned version of the form', - 'operaton:formRefBinding="version" operaton:formRefVersion="3"', - { kind: 'version', version: '3' }, - ], - ]; - - it.each(bound)( - 'a formRef resolving to %s imports the key and the binding together, reporting nothing', - async (_title, attrs, binding) => { - const { node, warnings } = await importFormRef( - `operaton:formRef="review-form" ${attrs}`, - ); - expect(node).toEqual({ - kind: 'userTask', - id: 'T', - formRef: { key: 'review-form', binding }, - }); - expect(warnings).toEqual([]); - }, - ); + it('a formRef resolving to the form deployed alongside the process imports the key and the binding together, reporting nothing', async () => { + const { node, warnings } = await importFormRef( + 'operaton:formRef="review-form" operaton:formRefBinding="deployment"', + ); + expect(node).toEqual({ + kind: 'userTask', + id: 'T', + formRef: { key: 'review-form', binding: { kind: 'deployment' } }, + }); + expect(warnings).toEqual([]); + }); it.each([ [ @@ -9005,140 +4757,38 @@ describe('xmlToIr: a user task names a deployed form by reference', () => { 'BpmnParse.parseFormDefinition resolves (deployment, latest, ' + 'version), so the engine refuses to deploy it', ], - [ - 'a formRef pinned to a version it never names', - 'operaton:formRef="review-form" operaton:formRefBinding="version"', - 'formRefBinding="version" is set without a formRefVersion, so the engine cannot resolve which version to use', - ], ])( '%s refuses, rather than importing a task Operaton would not deploy', async (_title, attrs, detail) => { - const error = await expectRefusal( - xmlToIr(oneNodeDoc('userTask', { attrs })), - UnsupportedFormReferenceError, - detail, - ); - expect(error.message).toContain("The form reference on 'T'"); - }, - ); -}); - -describe('xmlToIr: a second extension block of one kind refuses, since Element.elementNS throws on it', () => { - const twice = (block: string): string => `${block}\n${block}`; - const PROPERTIES = ` - - `; - - it.each([ - [ - 'operaton:failedJobRetryTimeCycle', - serviceTaskWith( - twice( - ' R3/PT10M', - ), - ), - 'Svc', - 'failedJobRetryTimeCycle', - 'DefaultFailedJobParseListener.setFailedJobRetryTimeCycleValue reads ' + - 'them on an async step, a timer-driven event or a typed throw', - ], - [ - 'operaton:inputOutput', - serviceTaskWith( - twice( - ioBlock( - ' 1', - ), - ), - ), - 'Svc', - 'inputOutput', - 'BpmnParseUtil.parseInputOutput reads them', - ], - [ - 'operaton:inputOutput on an intermediate catch', - oneNodeDoc('intermediateCatchEvent', { - id: 'Wait', - children: `${extensionElements( - twice( - ioBlock( - ' 1', - ), - ), - )} - PT1H`, - }), - 'Wait', - 'inputOutput', - 'BpmnParseUtil.parseInputOutput reads them', - ], - [ - 'operaton:inputOutput on an intermediate throw carrying a definition', - oneNodeDoc('intermediateThrowEvent', { - id: 'Throw', - children: `${extensionElements( - twice( - ioBlock( - ' 1', - ), - ), - )} - `, - }), - 'Throw', - 'inputOutput', - 'BpmnParseUtil.parseInputOutput reads them', - ], - [ - 'operaton:formData', - userTaskWith( - twice(` - - `), - ), - 'Review', - 'formData', - 'DefaultFormHandler.parseFormData reads them', - ], - [ - 'operaton:properties on an external task', - oneNodeDoc('serviceTask', { - id: 'Svc', - attrs: 'operaton:type="external" operaton:topic="t"', - children: extensionElements(twice(PROPERTIES)), - }), - 'Svc', - 'properties', - 'BpmnParseUtil.parseOperatonExtensionProperties reads them', - ], - ])( - 'a second %s refuses, naming the reader that meets both', - async (_title, xml, elementId, tag, reads) => { - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedExtensionFormError, - `2 blocks, which Element.elementNS throws on ` + - `when ${reads} ("Parsing exception: multiple elements with tag ` + - `name '${tag}' found"), and BpmnParse.execute lets that fail the ` + - 'deployment', + const error = await expectRefusal( + xmlToIr(oneNodeDoc('userTask', { attrs })), + UnsupportedFormReferenceError, + detail, ); - expect(e.elementId).toBe(elementId); + expect(error.message).toContain("The form reference on 'T'"); }, ); +}); - it('two operaton:properties blocks on a step no external worker runs are both dropped, since nothing reads them there', async () => { - const { node, warnings } = await importServiceTask(twice(PROPERTIES)); - expect('properties' in node).toBe(false); - const dropped = { - elementId: 'Svc', - category: 'extensionAttribute', - message: - "The operaton:properties block on 'Svc' was not imported: Operaton " + - 'reads it in parseExternalServiceTask alone, which only ' + - 'operaton:type="external" reaches, so the step runs as written ' + - 'without it.', - }; - expect(warnings).toEqual([dropped, dropped]); +describe('xmlToIr: a second extension block of one kind refuses, since Element.elementNS throws on it', () => { + const twice = (block: string): string => `${block}\n${block}`; + + it('a second operaton:formData refuses, naming the reader that meets both', async () => { + const e = await expectRefusal( + xmlToIr( + userTaskWith( + twice(` + + `), + ), + ), + UnsupportedExtensionFormError, + '2 blocks, which Element.elementNS throws on ' + + 'when DefaultFormHandler.parseFormData reads them ("Parsing ' + + "exception: multiple elements with tag name 'formData' found\"), " + + 'and BpmnParse.execute lets that fail the deployment', + ); + expect(e.elementId).toBe('Review'); }); }); @@ -9171,69 +4821,25 @@ describe('xmlToIr: an initiator and a form on a start that is not the process ow }, ]; - it.each([ - [ - 'a handler start', + it('a handler start drops both with one warning each, naming the reader that never runs there', async () => { + const { ir, warnings } = await xmlToIr( handlerDoc(`${START_FORM}\n `, { startAttrs: 'operaton:initiator="who"', defs: operatonDefs, }), - (ir: BpmnProcess) => byId(subProcess(ir, 'Handler'), 'HStart'), - 'HStart', - ], - [ - 'a start inside a subprocess', - operatonDoc` - - - ${START_FORM} - - - - - - - `, - (ir: BpmnProcess) => byId(subProcess(ir, 'Sub'), 'Inner'), - 'Inner', - ], - ])( - '%s drops both with one warning each, naming the reader that never runs there', - async (_title, xml, startOf, id) => { - const { ir, warnings } = await xmlToIr(xml); - const start = startOf(ir); - expect(start.kind === 'startEvent' && 'initiator' in start).toBe(false); - expect(start.kind === 'startEvent' && 'formFields' in start).toBe(false); - expect(warnings).toEqual(dropped(id)); - }, - ); - - it("the process's own start keeps both", async () => { - const { node, warnings } = await importOnly( - operatonDoc` - ${START_FORM} - - - `, - 'startEvent', ); - expect(node).toEqual({ - kind: 'startEvent', - id: 'S', - initiator: 'who', - formFields: [{ id: 'reason', type: 'string' }], - }); - expect(warnings).toEqual([]); + const start = byId(subProcess(ir, 'Handler'), 'HStart'); + expect(start.kind === 'startEvent' && 'initiator' in start).toBe(false); + expect(start.kind === 'startEvent' && 'formFields' in start).toBe(false); + expect(warnings).toEqual(dropped('HStart')); }); }); -/** One `` block on the `Review` user task, wrapping the given fields. */ const formOn = (fields: string): string => ` \n${fields}\n `; const importForm = (fields: string) => importUserTaskWith(formOn(fields)); -/** The `validation` block of one field. */ const validation = (constraints: string): string => ` \n${constraints}\n `; @@ -9436,38 +5042,6 @@ ${validation(' ')} ), ], }, - { - case: "a decimal 'min' is carried and the script draws an error", - field: ` -${validation(' ')} - `, - imported: { - id: 'amount', - type: 'number', - constraints: [{ name: 'min', config: '1.5' }], - }, - warnings: [ - reviewWarning( - /'min'.*imported as written.*MinValidator\.validate.*'1\.5'/, - ), - ], - }, - { - case: "a decimal 'maxlength' is carried and the script draws an error", - field: ` -${validation(' ')} - `, - imported: { - id: 'note', - type: 'string', - constraints: [{ name: 'maxlength', config: '2.5' }], - }, - warnings: [ - reviewWarning( - /'maxlength'.*imported as written.*MaxLengthValidator\.validate.*'2\.5'/, - ), - ], - }, { case: 'an enum default naming no value is carried and the script draws an error', field: ` @@ -9506,12 +5080,6 @@ ${validation(' `, - imported: { id: 'flag', type: 'boolean', defaultValue: 'TRUE' }, - warnings: [], - }, { case: 'an ISO default on a date field with no pattern is carried and the script draws an error', field: ` `, @@ -9522,31 +5090,12 @@ ${validation(' `, - imported: { id: 'amount', type: 'number', defaultValue: '${x}' }, - warnings: [], - }, { case: 'an empty label and an empty default are kept as written', field: ` `, imported: { id: 'note', type: 'string', label: '', defaultValue: '' }, warnings: [], }, - { - case: 'an enum default that is an expression is not checked against the values', - field: ` - - `, - imported: { - id: 'plan', - type: 'enum', - defaultValue: '${chosen}', - values: [{ id: 'a' }], - }, - warnings: [], - }, { case: 'a repeated enum value id keeps the first position and the last label', field: ` @@ -9622,8 +5171,7 @@ ${validation(' { - // The clean field first: a warning drawn against it, or against the - // offending field twice, is as wrong as one never drawn. + // The clean field comes first, so a misattributed warning fails. const { node: task, warnings } = await importForm( ` \n${field}`, ); @@ -9633,38 +5181,8 @@ ${validation(' { - const { node: task, warnings } = await importUserTaskWith( - ` - - - - -${validation(' ')} - - `, - ); - expect(task.formFields).toEqual([ - { - id: 'note', - type: 'string', - constraints: [{ name: 'required' }], - properties: [{ key: 'k', value: 'v' }], - }, - ]); - expect(warnings.map((w) => w.message)).toEqual([ - expect.stringMatching( - /'name' on an operaton:property 'k' in an operaton:properties in an operaton:formField 'note'/, - ), - expect.stringMatching( - /'foo:bar' on an operaton:constraint 'required' in an operaton:validation in an operaton:formField 'note'/, - ), - ]); - }); }); -/** Two coded error roots followed by a process body written verbatim. */ const codedErrorsDoc = (body: string): string => operatonDefs` @@ -9674,7 +5192,6 @@ ${body} `; -/** An external service task `Charge`, with the given attributes and extension children. */ const externalTask = (attrs: string, children: string): string => ` ${extensionElements(children)}`; @@ -9686,64 +5203,10 @@ const propertiesOf = (entries: string): string => ` \n${entries}\n `; describe('xmlToIr: external task extras', () => { - it('a service, send and business rule task each carry their priority, properties and error mappings on the external binding', async () => { - const { ir, warnings } = await xmlToIr( - codedErrorsDoc( - `${externalTask( - 'operaton:taskPriority="42"', - `${propertiesOf(` - `)} -${errorMapping( - `id="Map_1" errorRef="Err_Declined" expression="\${externalTask.errorMessage == 'declined'}"`, -)} -${errorMapping('id="Map_2" errorRef="Err_Timeout" expression="${externalTask.retries == 0}"')}`, - )} - -${extensionElements(propertiesOf(' '))} - -${extensionElements(errorMapping('errorRef="Err_Timeout" expression="${false}"'))}`, - ), - ); - const bindingOf = (id: string): ServiceTaskBinding => { - const node = byId(ir, id); - expect(node.kind).toBe('serviceTask'); - return (node as { binding: ServiceTaskBinding }).binding; - }; - expect(bindingOf('Charge')).toEqual({ - kind: 'external', - topic: 'charge-card', - taskPriority: '42', - properties: [ - { key: 'gateway', value: 'stripe' }, - { key: 'attempts', value: '3' }, - ], - errorMappings: [ - { - errorCode: 'DECLINED', - condition: "${externalTask.errorMessage == 'declined'}", - }, - { errorCode: 'TIMEOUT', condition: '${externalTask.retries == 0}' }, - ], - }); - expect(bindingOf('Notify')).toEqual({ - kind: 'external', - topic: 'notify', - taskPriority: '${p}', - properties: [{ key: 'channel', value: 'email' }], - }); - expect(bindingOf('Decide')).toEqual({ - kind: 'external', - topic: 'decide', - errorMappings: [{ errorCode: 'TIMEOUT', condition: '${false}' }], - }); - expect(warnings).toEqual([]); - }); - const CODED_ROOT = ' \n'; const BLANK_ROOT = ' \n'; - /** `Charge` beside the given error roots, carrying the given mapping. */ const mappingDoc = (mapping: string, roots = CODED_ROOT): string => operatonDefs`${roots} @@ -9752,11 +5215,6 @@ ${externalTask('', mapping)} `; it.each([ - [ - 'a mapping with no expression', - errorMapping('errorRef="Err_Declined"'), - /no expression/, - ], [ 'a mapping whose errorRef names no root and carries no expression, which the engine checks before the root', errorMapping('errorRef="Err_Missing"'), @@ -9793,31 +5251,6 @@ ${externalTask('', mapping)} undefined, [SKIPPED_MAPPING_WARNING], ], - [ - 'a mapping with neither attribute is skipped the same way', - errorMapping(''), - undefined, - [SKIPPED_MAPPING_WARNING], - ], - [ - "a mapping whose errorRef names no root imports the reference text as the code, the engine's reading", - errorMapping('errorRef="Err_Missing" expression="${true}"'), - [{ errorCode: 'Err_Missing', condition: '${true}' }], - [ - { - elementId: 'Charge', - category: 'unmappedConstruct', - message: - "The errorRef 'Err_Missing' on 'Charge' names no bpmn:error " + - "root and imports as the code 'Err_Missing': Operaton takes a " + - "dangling reference's text as the code " + - '(BpmnParse.parseBoundaryErrorEventDefinition, ' + - 'parseErrorStartEventDefinition, parseEndEvents, ' + - 'parseOperatonErrorEventDefinitions), and the document written ' + - 'back declares an error root carrying it.', - }, - ], - ], ] as const)('%s', async (_title, mapping, errorMappings, expected) => { const { node, warnings } = await importById( mappingDoc(mapping), @@ -9832,6 +5265,19 @@ ${externalTask('', mapping)} }); it.each([ + { + case: 'a property with no name is skipped, naming which', + body: externalTask( + '', + propertiesOf(` + `), + ), + binding: { + ...externalBinding('charge-card'), + properties: [{ key: 'k', value: 'v' }], + }, + warnings: [warning('Charge', /operaton:property #2.*no name/)], + }, { case: 'the three extras on a class-bound service task are dropped, each naming the one reader', body: ` @@ -9863,118 +5309,10 @@ ${errorMapping('errorRef="Err_Declined" expression="${true}"')}`)} - Declined by the issuer. - `, - ), - binding: { - ...externalBinding('charge-card'), - errorMappings: [{ errorCode: 'DECLINED', condition: '${true}' }], - }, - warnings: [ - warning( - 'Charge', - /documentation on 'Charge'.*an error mapping has no documentation/, - 'documentation', - ), - ], - }, - { - case: 'a property with no name is skipped, naming which', - body: externalTask( - '', - propertiesOf(` - `), - ), - binding: { - ...externalBinding('charge-card'), - properties: [{ key: 'k', value: 'v' }], - }, - warnings: [warning('Charge', /operaton:property #2.*no name/)], - }, - { - case: 'a repeated property name keeps the first position and the last value, as the engine map does', - body: externalTask( - '', - propertiesOf(` - - `), - ), - binding: { - ...externalBinding('charge-card'), - properties: [ - { key: 'k', value: 'last' }, - { key: 'other', value: 'o' }, - ], - }, - warnings: [ - warning( - 'Charge', - /property 'k'.*written twice.*once with its last value, as BpmnParseUtil.parseOperatonExtensionProperties keeps it \(HashMap.put\), at its first position/, - ), - ], - }, - { - case: "a property's id beside its name is reported by the sweep as unread", - body: externalTask( - '', - propertiesOf( - ' ', - ), - ), - binding: { - ...externalBinding('charge-card'), - properties: [{ key: 'k', value: 'v' }], - }, - warnings: [warning('Charge', /'id' on an operaton:property 'k'/)], - }, - { - case: 'a result variable beside the external binding is carried with the never-read warning', - body: externalTask('operaton:resultVariable="out"', ''), - binding: externalBinding('charge-card'), - warnings: [ - warning( - 'Charge', - /^The resultVariable 'out' on 'Charge' was imported as written.*parseServiceTaskLike/, - ), - ], - }, - { - case: 'a task priority opening with #{ is carried as written', - body: externalTask('operaton:taskPriority="#{x}"', ''), - binding: { ...externalBinding('charge-card'), taskPriority: '#{x}' }, - warnings: [], - }, - { - case: 'a task priority on an external message throw is dropped naming the reader, beside the clean task', - roots: ' \n', - body: ` - - `, - binding: undefined, - warnings: [ - warning( - 'Ping', - /^The 'taskPriority' setting on 'Ping' was not imported: BpmnParse\.parseExternalServiceTask reads it off the message definition/, - ), - ], - }, - { - case: 'properties on a user task stay extra configuration', - body: ` -${extensionElements(propertiesOf(' '))}`, - binding: undefined, - warnings: [unread('operaton:properties', 'Review', 'a ')], - }, - ])('$case', async ({ roots = '', body, binding, warnings: expected }) => { - // The clean external task first, the offending element last: a warning - // drawn against the clean one is as wrong as one never drawn. + ])('$case', async ({ body, binding, warnings: expected }) => { + // The clean task comes first, so a misattributed warning fails. const xml = operatonDefs` -${roots} + ${extensionElements(`${propertiesOf(' ')} @@ -9992,26 +5330,18 @@ ${body} expect(warnings).toEqual(expected); }); - it.each([ - ['a decimal task priority', '1.5'], - ['a task priority opening with a digit before its expression', '1 ${x}'], - ])('%s refuses, quoting parsePriority', async (_title, value) => { + it('a decimal task priority refuses, quoting parsePriority', async () => { const err = await expectRefusal( - xmlToIr( - codedErrorsDoc(externalTask(`operaton:taskPriority="${value}"`, '')), - ), + xmlToIr(codedErrorsDoc(externalTask('operaton:taskPriority="1.5"', ''))), UnsupportedExtensionFormError, - priorityRefusal('taskPriority', value), + priorityRefusal('taskPriority', '1.5'), ); expect(err.elementId).toBe('Charge'); }); }); -// The two-nested-values and stray-entry rows are only detectable because the -// moddle descriptor declares a parameter's nested value as a repeating -// property; single-valued, both would parse to something plausible. +// Moddle declares a parameter's nested value as repeating, which is what makes two values detectable. describe('xmlToIr: the extension-form refusal matrix', () => { - /** The service task carries the io block and execution listeners, the user task the task listeners. */ const HOSTS = { Svc: serviceTaskWith, Review: userTaskWith }; const refuse = ( @@ -10037,16 +5367,6 @@ ${body} 'would change what runs'; it.each([ - { - case: 'a parameter carrying two nested values', - on: 'Svc', - children: ioBlock( - ` `, - ), - detail: - "operaton:inputParameter 'x' carries 2 nested values (operaton:List, " + - 'operaton:Map), and a value is one', - }, { case: 'a map entry carrying two nested values', on: 'Svc', @@ -10065,16 +5385,6 @@ ${body} 'an operaton:inputParameter has no name, so there is nothing to bind ' + 'its value to', }, - { - case: 'an output parameter with no name', - on: 'Svc', - children: ioBlock( - ` text`, - ), - detail: - 'an operaton:outputParameter has no name, so there is nothing to ' + - 'bind its value to', - }, { case: 'a map entry with no key', on: 'Svc', @@ -10099,17 +5409,6 @@ ${body} "the operaton:script in operaton:inputParameter 'x' has no " + 'scriptFormat, so there is no language to evaluate its body in', }, - { - case: 'a script value whose body holds three backticks', - on: 'Svc', - children: nested( - ' x = "```"', - ), - detail: - "the operaton:script in operaton:inputParameter 'x' contains three " + - 'consecutive backticks, which no script fence this language has ' + - 'can enclose', - }, { case: 'a listener script whose body holds three backticks', on: 'Svc', @@ -10151,17 +5450,7 @@ ${body} detail: /two operaton:inputParameter children share name="x"; Operaton runs both, the last write winning \(IoMapping\.executeInputParameters\)/, }, - { - case: 'two output parameters sharing a name', - on: 'Svc', - children: - ioBlock(` 1 - 2`), - detail: - /two operaton:outputParameter children share name="x"; Operaton runs both, the last write winning \(IoMapping\.executeOutputParameters\)/, - }, - // A variable the engine sets under a name the script cannot spell: - // renaming it would change what runs, so the document is refused. + // The engine sets this variable under a name the script cannot spell. { case: 'an input parameter whose name the script cannot spell', on: 'Svc', @@ -10170,14 +5459,6 @@ ${body} ), detail: unspellable("operaton:inputParameter 'my.param'"), }, - { - case: 'a form field whose id the script cannot spell', - on: 'Review', - children: ` - - `, - detail: unspellable("operaton:formField 'my.field'"), - }, { case: 'an injected field naming no value slot', on: 'Svc', @@ -10233,20 +5514,6 @@ ${body} children: ` `, detail: /event="take".*start, end/, }, - { - case: 'a task listener whose event is not one of the six task events', - on: 'Review', - children: ` `, - detail: - /event="start".*create, assignment, complete, update, delete, timeout/, - }, - { - case: "a task listener spelling the assignment event 'assign', which BpmnParse.parseTaskListeners refuses", - on: 'Review', - children: ` `, - detail: - /event="assign".*create, assignment, complete, update, delete, timeout/, - }, { case: 'a timeout task listener with no timer', on: 'Review', @@ -10262,22 +5529,32 @@ ${body} expect(err.elementId).toBe(row.on); }, ); +}); - it('the same name in each direction is an ordinary mapping, not a repeat', async () => { - const { node: task } = await importServiceTask( - ioBlock(` 1 - 2`), +describe('xmlToIr: task kinds', () => { + it('a business rule task imports the whole decision binding', async () => { + const { node, warnings } = await importOnly( + oneNodeDoc('businessRuleTask', { + attrs: + 'operaton:decisionRef="riskRating" ' + + 'operaton:decisionRefBinding="version" ' + + 'operaton:decisionRefVersion="3" ' + + 'operaton:mapDecisionResult="singleEntry" ' + + 'operaton:resultVariable="risk"', + }), + 'serviceTask', ); - expect(task.inputParameters).toEqual([ioParam('x', textValue('1'))]); - expect(task.outputParameters).toEqual([ioParam('x', textValue('2'))]); + expect(warnings).toEqual([]); + expect(node.element).toBe('businessRule'); + expect(node.binding).toEqual({ + kind: 'decision', + decisionRef: 'riskRating', + binding: { kind: 'version', version: '3' }, + mapDecisionResult: 'singleEntry', + }); + expect(node.resultVariable).toBe('risk'); }); -}); -describe('xmlToIr: task kinds', () => { - /** - * Each tag, the IR kind it imports as, the least it needs to bind, and the - * noun a boundary-host refusal names it with. - */ const KINDS = [ ['task', 'task', '', 'plain task'], [ @@ -10298,7 +5575,6 @@ describe('xmlToIr: task kinds', () => { const ORDER_PAID_ROOT = ' \n'; - /** `S -> receiveTask -> E`, beside the roots the task may reference. */ const receiveDoc = (attrs: string, roots = ''): string => operatonDefs`${roots} @@ -10308,7 +5584,6 @@ describe('xmlToIr: task kinds', () => { `; - /** `S -> node -> E` with one boundary event attached to the node. */ const boundaryDoc = (tag: string, attrs: string, definition: string) => operatonDoc` @@ -10319,29 +5594,6 @@ describe('xmlToIr: task kinds', () => { `; - const TIMER_1H = ` - PT1H - `; - - it.each(KINDS)( - 'bpmn:%s imports as a %s carrying its name and engine settings', - async (tag, kind, binding) => { - const { node, warnings } = await importOnly( - oneNodeDoc(tag, { - attrs: - `name="Handle It" ${binding} ` + - 'operaton:asyncBefore="true" operaton:jobPriority="5"', - }), - kind, - ); - expect(warnings).toEqual([]); - expect(node.id).toBe('T'); - expect(node.name).toBe('Handle It'); - expect(node.asyncBefore).toBe(true); - expect(node.jobPriority).toBe('5'); - }, - ); - it('a send task with no binding refuses as a send task, not as a service task', async () => { const e = await expectRefusal( xmlToIr(oneNodeDoc('sendTask')), @@ -10353,89 +5605,6 @@ describe('xmlToIr: task kinds', () => { ); }); - it('a business rule task imports the whole decision binding', async () => { - const { node, warnings } = await importOnly( - oneNodeDoc('businessRuleTask', { - attrs: - 'operaton:decisionRef="riskRating" ' + - 'operaton:decisionRefBinding="version" ' + - 'operaton:decisionRefVersion="3" ' + - 'operaton:mapDecisionResult="singleEntry" ' + - 'operaton:resultVariable="risk"', - }), - 'serviceTask', - ); - expect(warnings).toEqual([]); - expect(node.element).toBe('businessRule'); - expect(node.binding).toEqual({ - kind: 'decision', - decisionRef: 'riskRating', - binding: { kind: 'version', version: '3' }, - mapDecisionResult: 'singleEntry', - }); - expect(node.resultVariable).toBe('risk'); - }); - - it('a code-bound business rule task warns about the decision settings it drops', async () => { - const { warnings } = await importOnly( - oneNodeDoc('businessRuleTask', { - attrs: - 'operaton:class="com.example.Rate" ' + - 'operaton:decisionRefBinding="latest" ' + - 'operaton:decisionRefVersion="3" ' + - 'operaton:decisionRefTenantId="acme" ' + - 'operaton:mapDecisionResult="singleEntry"', - }), - 'serviceTask', - ); - expect(warnings.map((w) => w.message)).toEqual([ - "The 'decisionRefBinding' setting on 'T' has no effect without an operaton:decisionRef and was not imported.", - "The 'decisionRefVersion' setting on 'T' has no effect without an operaton:decisionRef and was not imported.", - "The 'decisionRefTenantId' setting on 'T' has no effect without an operaton:decisionRef and was not imported.", - "The 'mapDecisionResult' setting on 'T' has no effect without an operaton:decisionRef and was not imported.", - ]); - }); - - it('a decision-bound business rule task naming a tenant refuses, as the call activity does', async () => { - const err = await expectRefusal( - xmlToIr( - oneNodeDoc('businessRuleTask', { - attrs: - 'operaton:decisionRef="riskRating" operaton:decisionRefTenantId="acme"', - }), - ), - UnsupportedServiceTaskFormError, - ); - expect([err.serviceTaskId, err.construct]).toEqual([ - 'T', - 'it names operaton:decisionRefTenantId="acme", which pins the tenant ' + - 'BpmnParse.parseTenantId resolves the decision against; dropping it ' + - 'would change which decision runs, and this surface has no tenant ' + - 'setting', - ]); - }); - - it('a decision-bound business rule task warns about the implementation it drops', async () => { - const { node, warnings } = await importOnly( - oneNodeDoc('businessRuleTask', { - attrs: - 'operaton:decisionRef="riskRating" ' + - 'operaton:class="com.example.Rate" ' + - 'operaton:type="external" operaton:topic="rate"', - }), - 'serviceTask', - ); - expect(node.binding).toEqual({ - kind: 'decision', - decisionRef: 'riskRating', - }); - expect(warnings.map((w) => w.message)).toEqual([ - "The 'class' setting on 'T' has no effect alongside an operaton:decisionRef and was not imported.", - "The 'type' setting on 'T' has no effect alongside an operaton:decisionRef and was not imported.", - "The 'topic' setting on 'T' has no effect alongside an operaton:decisionRef and was not imported.", - ]); - }); - it.each([ [ 'a mapDecisionResult outside the four Operaton accepts', @@ -10444,16 +5613,12 @@ describe('xmlToIr: task kinds', () => { 'filling the result variable this tool can represent', ], [ - 'decisionRefBinding="version" without a decisionRefVersion', - 'operaton:decisionRefBinding="version"', - 'decisionRefBinding="version" is set without a decisionRefVersion, so ' + - 'the engine cannot resolve which version to use', - ], - [ - 'decisionRefBinding="versionTag"', - 'operaton:decisionRefBinding="versionTag"', - 'decisionRefBinding="versionTag" pins a version tag, which this ' + - 'surface has no setting for', + 'a tenant, as the call activity does', + 'operaton:decisionRefTenantId="acme"', + 'it names operaton:decisionRefTenantId="acme", which pins the tenant ' + + 'BpmnParse.parseTenantId resolves the decision against; dropping it ' + + 'would change which decision runs, and this surface has no tenant ' + + 'setting', ], ])( 'a business rule task with %s refuses rather than importing without it', @@ -10466,7 +5631,7 @@ describe('xmlToIr: task kinds', () => { ), UnsupportedServiceTaskFormError, ); - expect(e.construct).toBe(construct); + expect([e.serviceTaskId, e.construct]).toEqual(['T', construct]); }, ); @@ -10479,12 +5644,6 @@ describe('xmlToIr: task kinds', () => { expect(warnings).toEqual([]); }); - it('a receive task with no messageRef imports as a wait state, with no message name and no warning', async () => { - const { node, warnings } = await importOnly(receiveDoc(''), 'receiveTask'); - expect(node).not.toHaveProperty('messageName'); - expect(warnings).toEqual([]); - }); - it.each(KINDS)( 'isForCompensation="true" on a bpmn:%s refuses rather than importing into normal flow', async (tag, _kind, binding) => { @@ -10499,17 +5658,6 @@ describe('xmlToIr: task kinds', () => { }, ); - it.each(KINDS)( - 'a timer boundary event attaches to a bpmn:%s', - async (tag, _kind, binding) => { - const { ir, warnings } = await xmlToIr( - boundaryDoc(tag, binding, TIMER_1H), - ); - expect(only(ir, 'boundaryEvent').attachedToRef).toBe('T'); - expect(warnings).toEqual([]); - }, - ); - it.each(KINDS)( 'an escalation boundary event on a bpmn:%s still refuses', async (tag, _kind, binding, noun) => { @@ -10548,24 +5696,6 @@ describe('xmlToIr: task kinds', () => { expect(warnings).toEqual([MANUAL_TASK_WARNING]); }); - it('an engine setting on a manual task is read rather than reported as a drop', async () => { - const { node, warnings } = await importOnly( - oneNodeDoc('manualTask', { - id: 'M1', - attrs: 'operaton:asyncBefore="true"', - children: extensionElements( - ' \n' + - ' ok\n' + - ' ', - ), - }), - 'task', - ); - expect(node.asyncBefore).toBe(true); - expect(node.inputParameters).toEqual([ioParam('note', textValue('ok'))]); - expect(warnings).toEqual([MANUAL_TASK_WARNING]); - }); - it('an intermediate throw with no definition (the Modeler milestone) imports as a plain step carrying its io mapping, prints and re-parses, warning that history will report it as a task', async () => { const { ir, warnings } = await xmlToIr( oneNodeDoc('intermediateThrowEvent', { @@ -10606,54 +5736,9 @@ describe('xmlToIr: task kinds', () => { ); await expectParses(source); }); - - it('a document holding all four kinds imports with no warnings at all', async () => { - const ioBlock = (name: string, value: string) => - ` - - - ${value} - - - `; - const xml = operatonDefs`${ORDER_PAID_ROOT} - - ${ioBlock('note', 'recorded')} - ${ioBlock('to', 'ops@example.com')} - ${ioBlock('reference', 'INV-1')} - ${ioBlock('applicant', 'acme')} - - - - - - - `; - - const { ir, warnings } = await xmlToIr(xml); - expect(warnings).toEqual([]); - expect(ir.flowElements.map((fe) => fe.kind)).toEqual([ - 'startEvent', - 'task', - 'serviceTask', - 'receiveTask', - 'serviceTask', - 'endEvent', - ]); - expect(only(ir, 'task').inputParameters).toEqual([ - ioParam('note', textValue('recorded')), - ]); - }); }); describe('xmlToIr: a block that can be given up', () => { - /** The closing sentence {@link UnsupportedEventFeatureError} appends by default. */ - const EVENT_SURFACE_NOTE = - 'Event handlers catch one error, escalation, message, signal, timer, ' + - 'condition, or compensation trigger on their single start event; ' + - 'throws and emits carry the code or name their kind requires, and ' + - 'compensation carries neither.'; - const BLOCK_BODY = ` @@ -10679,7 +5764,6 @@ describe('xmlToIr: a block that can be given up', () => { doc?: XmlTag; } - /** `S -> Book -> E`, where `Book` is the block, `body` follows its start, and `beside` sits alongside it. */ const blockDoc = ({ tag = 'transaction', attrs = '', @@ -10695,7 +5779,6 @@ ${beside} `; - /** The pair: a cancel end inside the block and a cancel boundary on it. */ const pairedDoc = (options: BlockOptions = {}): string => blockDoc({ body: BLOCK_BODY + CANCEL_END, @@ -10703,309 +5786,94 @@ ${beside} ...options, }); - it('imports as a subprocess naming the tag it serializes to, with the children a plain one gets', async () => { - const { ir: given } = await xmlToIr(blockDoc()); - const { ir: plain } = await xmlToIr(blockDoc({ tag: 'subProcess' })); - - expect(subProcess(given, 'Book')).toEqual({ - ...subProcess(plain, 'Book'), - element: 'transaction', - }); - expect(subProcess(plain, 'Book')).not.toHaveProperty('element'); - }); - - it('imports the cancel pair: the end keeps its label, the boundary keeps its host', async () => { - const { ir, warnings } = await xmlToIr(pairedDoc()); - - expect(byId(subProcess(ir, 'Book'), 'GiveUp')).toEqual({ - kind: 'endEvent', - id: 'GiveUp', - name: 'Give up the booking', - eventDefinition: { kind: 'cancel' }, - }); - expect(byId(ir, 'Boundary_Book_cancel')).toEqual({ - kind: 'boundaryEvent', - id: 'Boundary_Book_cancel', - attachedToRef: 'Book', - eventDefinition: { kind: 'cancel' }, - }); - expect(warnings).toEqual([]); - }); - it.each([ [ - 'a plain subprocess', + 'a cancel end directly inside a plain subprocess', blockDoc({ tag: 'subProcess', body: BLOCK_BODY + CANCEL_END }), - ], - [ - 'an event handler', - handlerDoc('', { - body: ` - - - -`, - }), - ], - [ - 'the process itself', - bpmnDoc` - - - - `, - ], - ])('a cancel end directly inside %s refuses', async (_where, xml) => { - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedEventFeatureError, - ); - expect(e.elementId).toBe('GiveUp'); - expect(e.message).toBe( - "The event construct at 'GiveUp' cannot be imported: an end event " + - 'carries a cancel definition outside a block that can be given up; ' + + 'GiveUp', + 'an end event carries a cancel definition outside a block that can be given up; ' + 'Operaton only accepts one directly inside a , and ' + 'refuses to deploy the file otherwise. Move the end inside a ' + ', or take the cancel definition off it.', - ); - }); - - it.each([ - [ - 'a user task', - 'user task', - operatonDoc` - -${cancelBoundary()} - - `, ], [ - 'a plain subprocess', - 'subprocess', + 'a cancel boundary on a plain subprocess', blockDoc({ tag: 'subProcess', beside: cancelBoundary() }), + 'Boundary_Book_cancel', + 'a cancel boundary event attaches to "Book", a subprocess; Operaton ' + + 'only allows a cancel boundary on a , and refuses ' + + 'to deploy the file otherwise. Attach it to a , ' + + 'or take the cancel definition off it.', ], - ])( - 'a cancel boundary on %s refuses, naming the host', - async (_title, noun, xml) => { - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedEventFeatureError, - ); - expect(e.elementId).toBe('Boundary_Book_cancel'); - expect(e.message).toBe( - "The event construct at 'Boundary_Book_cancel' cannot be imported: a " + - `cancel boundary event attaches to "Book", a ${noun}; Operaton ` + - 'only allows a cancel boundary on a , and refuses ' + - 'to deploy the file otherwise. Attach it to a , ' + - 'or take the cancel definition off it.', - ); - }, - ); - - it('a second cancel boundary on the same block refuses', async () => { - const e = await expectRefusal( - xmlToIr( - pairedDoc({ - beside: cancelBoundary() + cancelBoundary('Boundary_Book_cancel2'), - }), - ), - UnsupportedEventFeatureError, - ); - expect(e.elementId).toBe('Boundary_Book_cancel2'); - expect(e.message).toBe( - "The event construct at 'Boundary_Book_cancel2' cannot be imported: a " + - 'second cancel boundary event attaches to "Book"; Operaton allows ' + + [ + 'a second cancel boundary on the same block', + pairedDoc({ + beside: cancelBoundary() + cancelBoundary('Boundary_Book_cancel2'), + }), + 'Boundary_Book_cancel2', + 'a second cancel boundary event attaches to "Book"; Operaton allows ' + 'one cancel boundary per block and refuses to deploy a file with two. ' + 'Leave one cancel boundary event on the block.', - ); - }); - - it('a cancel definition on an event handler start refuses as a kind that position does not take', async () => { - const e = await expectRefusal( - xmlToIr(handlerDoc('')), - UnsupportedEventDefinitionError, - ); - expect(e.eventKind).toBe('start'); - expect(e.definitionType).toBe('bpmn:CancelEventDefinition'); - }); - - it('a non-interrupting cancel boundary refuses', async () => { - const e = await expectRefusal( - xmlToIr( - pairedDoc({ - beside: cancelBoundary( - 'Boundary_Book_cancel', - 'cancelActivity="false"', - ), - }), - ), - UnsupportedEventFeatureError, - ); - expect(e.message).toBe( - "The event construct at 'Boundary_Book_cancel' cannot be imported: a " + - 'cancel boundary event cannot be non-interrupting ' + - '(cancelActivity="false"); Operaton deploys it and lets what ' + - 'follows the boundary run beside the block instead of taking over ' + - `from it, which this surface cannot write back. ${EVENT_SURFACE_NOTE}`, - ); - }); - - it('a cancel end with no cancel boundary on its block warns, naming the runtime failure', async () => { - const { warnings } = await xmlToIr( - blockDoc({ body: BLOCK_BODY + CANCEL_END }), - ); - expectOneWarning(warnings, { - elementId: 'Book', - category: 'unmappedConstruct', - message: - "The block 'Book' holds an end event that gives it up, with no " + - 'cancel boundary event attached to it: Operaton deploys the file and ' + - 'then stops with an error the first time that end is reached. Write ' + - "'on Book: cancel { ... }' beside the block to catch it.", - }); - }); - - it('a cancel boundary on a block that never gives itself up warns, naming the unreachable path', async () => { - const { warnings } = await xmlToIr(blockDoc({ beside: cancelBoundary() })); - expectOneWarning(warnings, { - elementId: 'Boundary_Book_cancel', - category: 'unmappedConstruct', - message: - "The cancel boundary event on 'Book' was imported, but nothing " + - 'inside the block gives it up, so what follows the boundary can ' + - 'never run.', - }); - }); - - it.each([ - [ - 'method', - 'method="##Store"', - 'Operaton reads it on a not at all', - ], - [ - 'protocol', - 'protocol="two-phase"', - 'Operaton reads it on a not at all', - ], - [ - 'triggeredByEvent', - 'triggeredByEvent="true"', - 'Operaton ignores it on a and runs the block as an ' + - 'ordinary step of the surrounding flow', ], - ])( - '%s on the block warns exactly once and is dropped', - async (name, attrs, reason) => { - const { ir, warnings } = await xmlToIr(blockDoc({ attrs })); - expect(subProcess(ir, 'Book')).not.toHaveProperty(name); - expectOneWarning(warnings, { - elementId: 'Book', - category: 'unmappedConstruct', - message: - `The '${name}' attribute on 'Book' was not imported: ${reason}, so ` + - 'the imported block runs exactly as the source document does.', - }); - }, - ); - - it('an undo handler directly inside the block imports, while one at process level still refuses', async () => { - const undoHandler = ` - - - - - - -`; - const { ir, warnings } = await xmlToIr( - blockDoc({ body: BLOCK_BODY + undoHandler }), - ); - expect( - subProcess(subProcess(ir, 'Book'), 'UndoCharge').triggeredByEvent, - ).toBe(true); - expect(warnings).toEqual([]); - + ])('%s refuses', async (_title, xml, elementId, detail) => { const e = await expectRefusal( - xmlToIr(blockDoc({ beside: undoHandler })), + xmlToIr(xml), UnsupportedEventFeatureError, ); - expect(e.detail).toBe( - 'a compensation handler must be hosted directly by the block whose ' + - 'completed work it undoes, not by the process; move it inside that ' + - 'block', - ); - }); - - it('engine settings, an input/output mapping and a repetition on the block import with no warnings', async () => { - const { ir, warnings } = await xmlToIr( - blockDoc({ - attrs: 'operaton:asyncBefore="true"', - body: ` - - - 1A - - -${BLOCK_BODY}`, - }), + expect(e.elementId).toBe(elementId); + expect(e.message).toBe( + `The event construct at '${elementId}' cannot be imported: ${detail}`, ); - const block = subProcess(ir, 'Book'); - expect(block.asyncBefore).toBe(true); - expect(block.inputParameters).toEqual([ioParam('seat', textValue('1A'))]); - expect(block.loop).toEqual({ collection: 'seats' }); - expect(warnings).toEqual([]); }); -}); - -describe('xmlToIr: an either-branch split', () => { - /** `S -> Fork -> {A, B} -> Join -> Sub -> E`, `Sub` holding a split of its own. */ - const splitXml = bpmnDefs` - - - - - - - - - - - - - - - - \${amount > 10000} - - - - - - - `; - - it('imports with its label and its default, keeps no defaultFlowId key when none is written, and reaches a nested block', async () => { - const { ir, warnings } = await xmlToIr(splitXml); - expect(byId(ir, 'Fork')).toEqual({ - kind: 'inclusiveGateway', - id: 'Fork', - name: 'Which reviews?', - defaultFlowId: 'F_Fork_B', - }); - - const join = byId(ir, 'Join'); - expect(join).toEqual({ kind: 'inclusiveGateway', id: 'Join' }); - expect(join).not.toHaveProperty('defaultFlowId'); - expect(join).not.toHaveProperty('name'); - - expect(only(subProcess(ir, 'Sub'), 'inclusiveGateway').id).toBe('SubFork'); - expect(warnings).toEqual([]); + it.each([ + [ + 'a cancel end with no cancel boundary on its block warns, naming the runtime failure', + blockDoc({ body: BLOCK_BODY + CANCEL_END }), + 'Book', + "The block 'Book' holds an end event that gives it up, with no " + + 'cancel boundary event attached to it: Operaton deploys the file and ' + + 'then stops with an error the first time that end is reached. Write ' + + "'on Book: cancel { ... }' beside the block to catch it.", + ], + [ + 'a cancel boundary on a block that never gives itself up warns, naming the unreachable path', + blockDoc({ beside: cancelBoundary() }), + 'Boundary_Book_cancel', + "The cancel boundary event on 'Book' was imported, but nothing " + + 'inside the block gives it up, so what follows the boundary can ' + + 'never run.', + ], + [ + 'a method on the block is dropped, since Operaton never reads it there', + blockDoc({ attrs: 'method="##Store"' }), + 'Book', + "The 'method' attribute on 'Book' was not imported: Operaton reads it " + + 'on a not at all, so the imported block runs ' + + 'exactly as the source document does.', + ], + [ + 'triggeredByEvent on the block is dropped, since Operaton runs it as an ordinary step', + blockDoc({ attrs: 'triggeredByEvent="true"' }), + 'Book', + "The 'triggeredByEvent' attribute on 'Book' was not imported: " + + 'Operaton ignores it on a and runs the block as ' + + 'an ordinary step of the surrounding flow, so the imported block ' + + 'runs exactly as the source document does.', + ], + ])('%s', async (_title, xml, elementId, message) => { + const { ir, warnings } = await xmlToIr(xml); + const book = subProcess(ir, 'Book'); + expect(book.element).toBe('transaction'); + expect(book).not.toHaveProperty('method'); + expect(book).not.toHaveProperty('triggeredByEvent'); + expect(warnings).toEqual([ + { elementId, category: 'unmappedConstruct', message }, + ]); }); +}); - /** `S -> G -> {A, B, C} -> E`, `G` and its three routes written by the row. */ +describe('xmlToIr: an either-branch split', () => { const xorDoc = (gateway: string, routes: string): string => bpmnDefs` @@ -11048,27 +5916,6 @@ ${routes} "Exclusive Gateway 'G' has outgoing sequence flow 'fA' which is the " + 'default flow but has a condition too.', ], - [ - 'a default beside an unconditioned route that is not it', - '', - [route('fA', 'A', '${a}'), route('fB', 'B'), route('fC', 'C')].join('\n'), - "Exclusive Gateway 'G' has outgoing sequence flow 'fC' without " + - 'condition which is not the default flow.', - ], - [ - 'a default naming a flow that leaves another node', - '', - [route('fA', 'A', '${a}'), route('fB', 'B')].join('\n'), - "Exclusive Gateway 'G' has outgoing sequence flow 'fB' without " + - 'condition which is not the default flow.', - ], - [ - 'two unconditioned routes and no default', - '', - [route('fA', 'A'), route('fB', 'B')].join('\n'), - "Exclusive Gateway 'G' has outgoing sequence flow 'fA' without " + - 'condition which is not the default flow.', - ], [ 'an empty condition body beside an unconditioned route, which the engine counts as conditioned', '', @@ -11088,37 +5935,12 @@ ${routes} expect(e.message).toContain('BpmnParse.validateExclusiveGateway'); }, ); - - it.each([ - ['no default', ''], - [ - 'default="", which validateExclusiveGateway reads as none', - '', - ], - ])( - 'an exclusive gateway with one conditioned and one plain route and %s imports, as the engine assumes the plain one is the default', - async (_title, gateway) => { - const { ir, warnings } = await xmlToIr( - xorDoc( - gateway, - [ - route('fA', 'A', '${a}'), - route('fB', 'B'), - route('fC', 'C', '${c}'), - ].join('\n'), - ), - ); - expect(byId(ir, 'G')).toEqual({ kind: 'exclusiveGateway', id: 'G' }); - expect(warnings).toEqual([]); - }, - ); }); describe('xmlToIr: a fallback route named on a step', () => { const condition = (body: string): string => `${body}`; - /** `S -> Triage -> {E1, E2}`, both routes out of the step weighed. */ const stepDoc = (element: string): string => bpmnDefs` @@ -11134,123 +5956,73 @@ ${element} `; - it.each([ - [ - 'a task', - ' ', - { kind: 'userTask', id: 'Triage', defaultFlowId: 'F2' }, - ], - [ - 'a call activity', - ' ', + it('a default naming a flow that enters the step is dropped with the warning handleNoTransitions earns it', async () => { + const { node, warnings } = await importOnly( + stepDoc(' '), + 'userTask', + ); + expect(node).toEqual({ kind: 'userTask', id: 'Triage' }); + expect(warnings).toEqual([ { - kind: 'callActivity', - id: 'Triage', - calledElement: 'other', - defaultFlowId: 'F2', + elementId: 'Triage', + category: 'unmappedConstruct', + message: + "The 'default' attribute on 'Triage' was not imported: it names " + + "'F0', which is not a route out of the step, so " + + 'BpmnActivityBehavior.handleNoTransitions finds no flow to take ' + + 'and fails the step whenever no other route holds; the imported ' + + 'step names no fallback.', }, - ], - [ - 'an inclusive gateway', - ' ', - { kind: 'inclusiveGateway', id: 'Triage', defaultFlowId: 'F2' }, - ], - ] as const)( - 'the default named on %s carries as its defaultFlowId when it is one of its own routes', - async (_title, element, carried) => { - const { node, warnings } = await importOnly( - stepDoc(element), - carried.kind, - ); - expect(node).toEqual(carried); - expect(warnings).toEqual([]); - }, - ); + ]); + }); +}); - it('carries it on a block and on a step nested in one', async () => { - const { ir, warnings } = await xmlToIr( - stepDoc( - ` - - - - - - - ${condition('${a}')} - - - ${condition('${b}')} - - `, +describe('xmlToIr: a wait with several branches', () => { + it('refuses a branch that does not begin with something to wait for, and one reached by more than one path', async () => { + const notAWait = await expectRefusal( + xmlToIr( + waitDoc({ + branches: [ + messageBranch, + { id: 'Ship', element: '' }, + ], + }), ), + UnsupportedEventFeatureError, + ); + expect(notAWait.elementId).toBe('Ship'); + expect(notAWait.detail).toContain( + "a branch of the wait 'Wait' leads to 'Ship', a user task", + ); + expect(notAWait.message).toContain( + 'every branch of a wait with several branches has to begin with ' + + 'something to wait for', ); - expect(byId(ir, 'Triage')).toEqual({ - kind: 'subProcess', - id: 'Triage', - defaultFlowId: 'F2', - flowElements: [ - { kind: 'startEvent', id: 'SubS' }, - { kind: 'userTask', id: 'Inner', defaultFlowId: 'SF2' }, - { kind: 'endEvent', id: 'SubE1' }, - { kind: 'endEvent', id: 'SubE2' }, - ], - sequenceFlows: [ - { id: 'SF0', sourceRef: 'SubS', targetRef: 'Inner' }, - { - id: 'SF1', - sourceRef: 'Inner', - targetRef: 'SubE1', - conditionExpression: '${a}', - }, - { - id: 'SF2', - sourceRef: 'Inner', - targetRef: 'SubE2', - conditionExpression: '${b}', - }, - ], - }); - expect(warnings).toEqual([]); + const reachedTwice = await expectRefusal( + xmlToIr( + waitDoc({ + beside: ' \n', + extraFlows: + ' \n', + }), + ), + UnsupportedEventFeatureError, + ); + expect(reachedTwice.elementId).toBe('OnPaid'); + expect(reachedTwice.detail).toContain( + "'OnPaid' is reached by more than one path; a step inside a wait " + + 'with several branches can only be reached through the wait that ' + + 'opens it', + ); }); - it.each([ - ['a flow that enters the step', 'F0'], - ['a flow the document does not hold', 'F9'], - ])( - 'a default naming %s is dropped with the warning handleNoTransitions earns it', - async (_title, named) => { - const { node, warnings } = await importOnly( - stepDoc(` `), - 'userTask', - ); - expect(node).toEqual({ kind: 'userTask', id: 'Triage' }); - expect(warnings).toEqual([ - { - elementId: 'Triage', - category: 'unmappedConstruct', - message: - `The 'default' attribute on 'Triage' was not imported: it names '${named}', ` + - 'which is not a route out of the step, so ' + - 'BpmnActivityBehavior.handleNoTransitions finds no flow to take ' + - 'and fails the step whenever no other route holds; the imported ' + - 'step names no fallback.', - }, - ]); - }, - ); -}); - -describe('xmlToIr: a wait with several branches', () => { interface WaitOptions { attrs?: string; children?: string; - /** The elements the branches begin with, each with the id a flow names. */ branches?: readonly { id: string; element: string }[]; roots?: string; beside?: string; - /** Flows written on top of the ones every branch gets. */ extraFlows?: string; defs?: XmlTag; } @@ -11274,7 +6046,6 @@ describe('xmlToIr: a wait with several branches', () => { const MESSAGE_ROOT = ' \n'; - /** `S -> Wait`, every branch merging into `Merge -> E`. */ const waitDoc = ({ attrs = '', children = '', @@ -11305,50 +6076,6 @@ ${branches )}${extraFlows} `; - it('imports the wait, every branch trigger it admits, and every flow, with no warnings', async () => { - const signalBranch = { - id: 'OnStock', - element: ` - - `, - }; - const conditionBranch = { - id: 'OnCancelled', - element: ` - - \${cancelled} - - `, - }; - - const { ir, warnings } = await xmlToIr( - waitDoc({ - attrs: 'gatewayDirection="Diverging"', - branches: [messageBranch, timerBranch, signalBranch, conditionBranch], - roots: - MESSAGE_ROOT + - ' \n', - }), - ); - - expect(only(ir, 'eventBasedGateway')).toEqual({ - kind: 'eventBasedGateway', - id: 'Wait', - }); - expect( - ir.flowElements - .filter((fe) => fe.kind === 'intermediateCatchEvent') - .map((fe) => [fe.id, fe.eventDefinition.kind]), - ).toEqual([ - ['OnPaid', 'message'], - ['OnLate', 'timer'], - ['OnStock', 'signal'], - ['OnCancelled', 'conditional'], - ]); - expect(ir.sequenceFlows).toHaveLength(10); - expect(warnings).toEqual([]); - }); - it('reports instantiate and eventGatewayType once each, and says nothing about gatewayDirection', async () => { const { warnings: instantiate } = await xmlToIr( waitDoc({ attrs: 'instantiate="true"' }), @@ -11380,143 +6107,25 @@ ${branches expect(quiet).toEqual([]); }); - it('reads the name and the job settings off a wait, and lets the generic sweeps report the rest on either gateway kind', async () => { - const ATTRS = - 'operaton:asyncBefore="true" operaton:jobPriority="7" sortOrder="3"'; - const DOC = 'Pick one.'; - - const { ir, warnings } = await xmlToIr( - waitDoc({ - attrs: `name="Whichever comes first" ${ATTRS}`, - children: DOC, - defs: operatonDefs, - }), - ); - - expect(only(ir, 'eventBasedGateway')).toEqual({ - kind: 'eventBasedGateway', - id: 'Wait', - name: 'Whichever comes first', - documentation: 'Pick one.', - asyncBefore: true, - jobPriority: '7', - }); - const reported = (warning: ImportWarning) => [ - warning.category, - warning.elementId, - warning.message, - ]; - const sweepsOn = (id: string) => [ - [ - 'unmappedConstruct', - id, - expect.stringContaining("'sortOrder' attribute"), - ], - ]; - expect(warnings.map(reported)).toEqual(sweepsOn('Wait')); - - const fork = await xmlToIr( - oneNodeDoc('inclusiveGateway', { - id: 'Fork', - attrs: ATTRS, - children: DOC, - }), - ); - expect(only(fork.ir, 'inclusiveGateway')).toEqual({ - kind: 'inclusiveGateway', - id: 'Fork', - documentation: 'Pick one.', - asyncBefore: true, - jobPriority: '7', - }); - expect(fork.warnings.map(reported)).toEqual(sweepsOn('Fork')); - }); - - it.each([ - ['operaton:asyncAfter="true"', operatonDefs], - ['camunda:asyncAfter="true"', camundaDefs], - ])( - 'a wait marked %s is refused, naming the engine rule', - async (attrs, defs) => { - const err = await expectRefusal( - xmlToIr(waitDoc({ attrs, defs })), - UnsupportedEventFeatureError, - "'Wait' is marked asyncAfter, which BpmnParse.parseEventBasedGateway " + - 'refuses to deploy on a wait with several branches', - ); - expect(err.elementId).toBe('Wait'); - expect(err.message).toBe( - "The event construct at 'Wait' cannot be imported: 'Wait' is marked " + - 'asyncAfter, which BpmnParse.parseEventBasedGateway refuses to ' + - 'deploy on a wait with several branches. Drop the asyncAfter ' + - "setting from 'Wait'; its other job settings are read as written.", - ); - }, - ); - - it('refuses a branch that does not begin with something to wait for, and one reached by more than one path', async () => { - const notAWait = await expectRefusal( - xmlToIr( - waitDoc({ - branches: [ - messageBranch, - { id: 'Ship', element: '' }, - ], - }), - ), - UnsupportedEventFeatureError, - ); - expect(notAWait.elementId).toBe('Ship'); - expect(notAWait.detail).toContain( - "a branch of the wait 'Wait' leads to 'Ship', a user task", - ); - expect(notAWait.message).toContain( - 'every branch of a wait with several branches has to begin with ' + - 'something to wait for', - ); - - const reachedTwice = await expectRefusal( + it('a wait marked operaton:asyncAfter is refused, naming the engine rule', async () => { + const err = await expectRefusal( xmlToIr( - waitDoc({ - beside: ' \n', - extraFlows: - ' \n', - }), + waitDoc({ attrs: 'operaton:asyncAfter="true"', defs: operatonDefs }), ), UnsupportedEventFeatureError, + "'Wait' is marked asyncAfter, which BpmnParse.parseEventBasedGateway " + + 'refuses to deploy on a wait with several branches', ); - expect(reachedTwice.elementId).toBe('OnPaid'); - expect(reachedTwice.detail).toContain( - "'OnPaid' is reached by more than one path; a step inside a wait " + - 'with several branches can only be reached through the wait that ' + - 'opens it', - ); - }); - - it('refuses a link catch on a branch by the flow rule: the branch is a flow into the catch', async () => { - const e = await expectRefusal( - xmlToIr( - waitDoc({ - branches: [ - messageBranch, - { - id: 'OnLink', - element: ` - - `, - }, - ], - }), - ), - UnsupportedEventFeatureError, - "the flow 'F_OnLink' enters the link catch 'OnLink'; a link catch is " + - 'entered by the throw of the same name rather than along a flow', + expect(err.elementId).toBe('Wait'); + expect(err.message).toBe( + "The event construct at 'Wait' cannot be imported: 'Wait' is marked " + + 'asyncAfter, which BpmnParse.parseEventBasedGateway refuses to ' + + 'deploy on a wait with several branches. Drop the asyncAfter ' + + "setting from 'Wait'; its other job settings are read as written.", ); - expect(e.elementId).toBe('OnLink'); }); }); -/** One row per refusal or warning arm whose exact wording no table above pins. */ describe('xmlToIr: refusal and warning wording', () => { const potentialOwnerXml = ( id: string, @@ -11609,27 +6218,6 @@ describe('xmlToIr: refusal and warning wording', () => { ]); }, ], - [ - 'operaton:in variables="all" local="true" imports the local flag beside all, and prints "in local *"', - async () => { - const xml = oneNodeDoc('callActivity', { - id: 'CallSub', - attrs: 'calledElement="sub-process"', - children: extensionElements( - '', - ), - }); - const { ir, warnings } = await xmlToIr(xml); - expect(only(ir, 'callActivity').inMappings).toEqual([ - { kind: 'all', local: true }, - ]); - expect(warnings).toEqual([]); - - const printed = irToDsl(ir).source; - expect(printed).toContain('in local *'); - await expectParses(printed); - }, - ], [ 'operaton:type="mail" with no fields at all refuses the same as one missing "to"', async () => { @@ -11661,8 +6249,7 @@ describe('xmlToIr: refusal and warning wording', () => { `, ), }); - // No binding on the listener: the timer is read before a binding is - // resolved, so this pins the timer refusal alone. + // No listener binding: the timer is read before one is resolved. await expectRefusal( xmlToIr(xml), UnsupportedExtensionFormError, @@ -11807,23 +6394,6 @@ describe('xmlToIr: refusal and warning wording', () => { ); }, ], - [ - 'a potential owner naming users only imports candidateUsers alone', - async () => { - const { node, warnings } = await importOnly( - potentialOwnerXml('PO_Users', 'user(alice), user(bob)'), - 'userTask', - ); - expect(node).toEqual({ - kind: 'userTask', - id: 'Review', - candidateUsers: 'alice,bob', - }); - expect(warnings).toEqual([ - candidateUsersWarning('PO_Users', 'alice,bob'), - ]); - }, - ], [ 'a potential owner expression with a trailing comma draws no empty candidate', async () => { diff --git a/tests/boundary-events.round-trip.test.ts b/tests/boundary-events.round-trip.test.ts index 35fb9d3..d4c52ef 100644 --- a/tests/boundary-events.round-trip.test.ts +++ b/tests/boundary-events.round-trip.test.ts @@ -1,12 +1,8 @@ -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; import type { FlowContainer, FlowElement } from '@bpmn-script/transform'; -import { - boundsOf, - describeSingleDiagram, - parseShapeBounds, -} from './helpers/di-bounds.js'; +import { boundsOf, parseShapeBounds } from './helpers/di-bounds.js'; import type { Bounds } from './helpers/di-bounds.js'; import { describeImportFirst } from './helpers/import-first.js'; import { kindOf, subProcess } from './helpers/ir-query.js'; @@ -71,8 +67,7 @@ const EXPECTED_ATTACHMENTS = [ ].sort(); // bpmn-auto-layout spreads `n` attachers along the host's bottom edge at -// `x + width * i/(n+1)`; measuring against the host's own bounds keeps a shape -// elsewhere on the canvas from passing. +// `x + width * i/(n+1)`. function assertAttachedToHost( bounds: Map, hostId: string, @@ -98,8 +93,7 @@ function assertAttachedToHost( }); } -// MIWG-style ``/`` children and boundary ids the -// id template would never produce. +// Boundary ids the id template would never produce. const IMPORT_FIRST_BPMN = ` @@ -166,100 +160,49 @@ const IMPORT_FIRST_BPMN = ` `; -describe("idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3", () => { - it('the authored ids survive verbatim at their correct container depth', () => { - expect(kindOf(rt.ir3, 'CheckAddress')).toBe('userTask'); - expect(kindOf(rt.ir3, 'PackGoods')).toBe('subProcess'); - expect(kindOf(rt.ir3, 'ComputeShipping')).toBe('scriptTask'); - expect(kindOf(rt.ir3, 'ChargePostage')).toBe('serviceTask'); - expect(kindOf(rt.ir3, 'PrintLabel')).toBe('serviceTask'); - expect(kindOf(rt.ir3, 'BookCarrier')).toBe('callActivity'); - expect(kindOf(rt.ir3, 'HandOverParcel')).toBe('userTask'); - // The escalation is emitted one container down, inside the sub-process the - // escalation boundary is attached to. - expect(kindOf(subProcess(rt.ir3, 'PackGoods'), 'Oversized')).toBe( - 'intermediateThrowEvent', - ); - }); - - it("each boundary's host, trigger, payload, and cancelActivity survive at every hop", () => { - for (const [label, ir] of rt.hops) { - expect( - attachmentSignatures(ir), - `attachments differ in ${label}`, - ).toEqual(EXPECTED_ATTACHMENTS); - } - }); - - it('the escape chain that rejoins the main flow keeps a real edge into its target', () => { - // The handler body and the main flow share one container, which is what - // makes `goto PackGoods` expressible as a real sequence flow. - const rejoin = rt.ir3.sequenceFlows.find( - (sf) => sf.sourceRef === 'MarkAddressVerified', - ); - expect(rejoin?.targetRef).toBe('PackGoods'); - }); - - it('the if inside an escape chain comes back as an if, not as gotos', () => { - // The boundary event is wired to the CFG's virtual entry, so the split in - // its escape chain has an immediate dominator; without one the - // restructurer could only degrade the branch into jumps. - expect(rt.dslPrime).toContain('if (parcelValue > 500) {'); - }); +it("keeps each boundary's host, trigger, payload and cancelActivity at every hop, and the escape chain's rejoin and if", () => { + for (const [label, ir] of rt.hops) { + expect(attachmentSignatures(ir), label).toEqual(EXPECTED_ATTACHMENTS); + } + // The escalation is thrown inside the sub-process its boundary watches. + expect(kindOf(subProcess(rt.ir3, 'PackGoods'), 'Oversized')).toBe( + 'intermediateThrowEvent', + ); + // Handler body and main flow share a container, so the goto is a real edge. + expect( + rt.ir3.sequenceFlows.find((sf) => sf.sourceRef === 'MarkAddressVerified') + ?.targetRef, + ).toBe('PackGoods'); + // The boundary is wired to the CFG's virtual entry, so the split in its + // escape chain has an immediate dominator and restructures as an if. + expect(rt.dslPrime).toContain('if (parcelValue > 500) {'); }); -describeSingleDiagram(rt); - -describe('DI attachment on the generated .bpmn', () => { - it('every boundary shape sits centered and half-overlapping on its host edge', () => { - const bounds = parseShapeBounds(rt.generatedXml); - - assertAttachedToHost(bounds, 'CheckAddress', [ +it('lays every boundary centered on and spread along its host lower edge, the sub-process host at its expanded size', () => { + const bounds = parseShapeBounds(rt.generatedXml); + const hosts: Record = { + CheckAddress: [ 'Boundary_CheckAddress_message', 'Boundary_CheckAddress_timer', - ]); - assertAttachedToHost(bounds, 'PackGoods', [ - 'Boundary_PackGoods_error', - 'Boundary_PackGoods_escalation', - ]); - assertAttachedToHost(bounds, 'BookCarrier', [ - 'Boundary_BookCarrier_signal', - ]); - assertAttachedToHost(bounds, 'HandOverParcel', [ - 'Boundary_HandOverParcel_condition', - ]); - assertAttachedToHost(bounds, 'ComputeShipping', [ - 'Boundary_ComputeShipping_timer', - ]); - assertAttachedToHost(bounds, 'ChargePostage', [ - 'Boundary_ChargePostage_error', - ]); - assertAttachedToHost(bounds, 'PrintLabel', ['Boundary_PrintLabel_message']); - }); - - it('the sub-process host carries its boundaries on the expanded box, not a task-sized one', () => { - // The host's bounds are computed from its children, so its lower edge only - // exists once the sub-process body has been laid out. - const bounds = parseShapeBounds(rt.generatedXml); - const host = boundsOf(bounds, 'PackGoods'); - const child = boundsOf(bounds, 'PickItems'); - - expect(host.width).toBeGreaterThan(child.width); - expect(host.height).toBeGreaterThan(child.height); - expect(rt.generatedXml).toContain( - 'bpmnElement="PackGoods" isExpanded="true"', - ); - for (const id of [ - 'Boundary_PackGoods_error', - 'Boundary_PackGoods_escalation', - ]) { - expect(boundsOf(bounds, id).y + 18).toBeCloseTo(host.y + host.height, 3); - } - }); + ], + PackGoods: ['Boundary_PackGoods_error', 'Boundary_PackGoods_escalation'], + BookCarrier: ['Boundary_BookCarrier_signal'], + HandOverParcel: ['Boundary_HandOverParcel_condition'], + ComputeShipping: ['Boundary_ComputeShipping_timer'], + ChargePostage: ['Boundary_ChargePostage_error'], + PrintLabel: ['Boundary_PrintLabel_message'], + }; + for (const [host, ids] of Object.entries(hosts)) { + assertAttachedToHost(bounds, host, ids); + } + // The host's bounds come from its laid-out children. + const host = boundsOf(bounds, 'PackGoods'); + const child = boundsOf(bounds, 'PickItems'); + expect(host.width).toBeGreaterThan(child.width); + expect(host.height).toBeGreaterThan(child.height); }); -// `[title, the ids of the one root the frozen .bpmn may carry, the definition -// kind, every element that has to reference it]`. +// [title, the one root's ids, definition kind, every element referencing it] const SHARED_ROOTS: readonly [ string, (xml: string) => string[], @@ -301,29 +244,23 @@ const SHARED_ROOTS: readonly [ ], ]; -describe('root sharing on the frozen .bpmn', () => { - it.each(SHARED_ROOTS)('%s', (_title, rootsOf, definition, referrers) => { - const roots = rootsOf(rt.frozenXml); - expect(roots).toHaveLength(1); - for (const id of referrers) { - expect(definitionRefOf(rt.frozenXml, id, definition), id).toBe(roots[0]); - } - }); +it.each(SHARED_ROOTS)('%s', (_title, rootsOf, definition, referrers) => { + const roots = rootsOf(rt.frozenXml); + expect(roots).toHaveLength(1); + for (const id of referrers) { + expect(definitionRefOf(rt.frozenXml, id, definition), id).toBe(roots[0]); + } }); describeImportFirst( 'a handwritten .bpmn with hand-named boundary ids round-trips', IMPORT_FIRST_BPMN, (first) => { - it('prints each boundary as an attached handler naming its host', () => { + it('prints each boundary as a handler on its host, re-synthesizing host-derived ids with the pairing intact', () => { expect(first.dsl).toContain('on InspectCrate: error(TORN_BOX) {'); expect(first.dsl).toContain('on InspectCrate: error(MISSING_ITEM) {'); expect(first.dsl).toContain('on StoreCrate: timer("PT1H", alongside) {'); - }); - - it('re-synthesizes the host-derived ids, suffixing the second of the colliding pair', () => { - // Which of the two colliding boundaries owns the `_2` suffix follows from - // print order, not from anything either boundary carries. + // The `_2` suffix follows print order. expect(boundaryEvents(first.ir).map((b) => b.id)).toEqual([ 'BoxTorn', 'ItemMissing', @@ -334,9 +271,6 @@ describeImportFirst( 'Boundary_InspectCrate_error_2', 'Boundary_StoreCrate_timer', ]); - }); - - it('keeps every host, trigger payload, and cancelActivity paired', () => { const expected = [ 'InspectCrate error MISSING_ITEM interrupting', 'InspectCrate error TORN_BOX interrupting', diff --git a/tests/branch-and-race.round-trip.test.ts b/tests/branch-and-race.round-trip.test.ts index 5537c4e..ebae44d 100644 --- a/tests/branch-and-race.round-trip.test.ts +++ b/tests/branch-and-race.round-trip.test.ts @@ -1,6 +1,4 @@ -import { describe, it, expect, beforeAll } from 'vitest'; - -import { Diagnostic } from 'vscode-languageserver-types'; +import { it, expect } from 'vitest'; import { irToDsl, @@ -14,9 +12,9 @@ import type { FlowElement, } from '@bpmn-script/transform'; -import { describeNoOverlappingShapes } from './helpers/di-bounds.js'; import { idsOfTag } from './helpers/xml-query.js'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; +import { validationErrors } from './helpers/pipeline.js'; const rt = roundTripFixture('branch-and-race', { dslPrimeFrom: 'frozen', @@ -31,8 +29,7 @@ interface Flow { conditioned: boolean; } -// A flow closes on itself unless it carries a condition, the only child a flow -// of this artifact has. +// A condition is the only child a flow of this artifact has. function sequenceFlows(xml: string): Flow[] { const flow = /]*\bsourceRef="([^"]+)" targetRef="([^"]+)"\s*(?:\/>|>([\s\S]*?)<\/bpmn:sequenceFlow>)/g; @@ -57,8 +54,6 @@ function countOfKind( return container.flowElements.filter((fe) => fe.kind === kind).length; } -// Every line opening a branch or a wait, so a lost `else`, a bare branch that -// gained a head, or an `await` missing a trigger all show up in order. function blockHeads(source: string): string[] { return source .split('\n') @@ -73,147 +68,115 @@ const RACE_GATEWAYS = [ 'Gateway_order-handling_6_race', ]; -describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { - it('every hop keeps each fork kind and the default each inclusive fork carries', () => { - for (const [label, ir] of rt.hops) { - expect( - countOfKind(ir, 'inclusiveGateway'), - `inclusive pairs differ in ${label}`, - ).toBe(4); - expect( - countOfKind(ir, 'parallelGateway'), - `parallel pair differs in ${label}`, - ).toBe(2); - expect( - countOfKind(ir, 'eventBasedGateway'), - `race gateways differ in ${label}`, - ).toBe(2); - - // Only the first fork names a default: a join has one incoming path per - // branch and nothing to fall back to, and the second fork's unheaded - // branch always runs, so a fallback there could never be taken. - const carried = ir.flowElements.filter( - (fe) => - fe.kind === 'inclusiveGateway' && fe.defaultFlowId !== undefined, - ); - expect( - carried.map((fe) => fe.id), - `defaults differ in ${label}`, - ).toEqual(['Gateway_order-handling_2_fork']); - } - }); - - it("the decompiled DSL' writes every branch head and both wait forms back", () => { - expect(blockHeads(rt.dslPrime)).toEqual([ - 'if (orderValue > 10000) {', - 'if (!stockShort) {', - 'else {', - 'if (overseas) {', - '{', - '{', - '{', - 'await {', - 'message("PaymentReceived") {', - 'timer("P3D", asyncBefore: true) {', - 'await {', - 'signal("StockArrived") {', - 'condition(stockShort) {', - 'await message("CarrierBooked")', - ]); - }); - - it("every edge folds into a block, so DSL' jumps nowhere", () => { - expect(rt.dslPrime).not.toContain('goto'); - expect(rt.dslPrime).not.toContain(UNSTRUCTURED_MARKER); - }); - - it('printing the imported IR reports nothing at all', () => { - // Every gateway of the artifact is nameless, and every fork either names - // its default or has an unheaded branch that always runs, so neither the - // elided-label nor the invented-fallback warning has anything to report. - expect(irToDsl(rt.ir2).warnings).toEqual([]); - }); -}); - -describe('gateway shape pins on the frozen .bpmn', () => { - it('freezes two inclusive pairs, one parallel pair, and an exclusive merge per race', () => { - expect(idsOfTag(rt.frozenXml, 'inclusiveGateway')).toEqual([ - 'Gateway_order-handling_2_fork', - 'Gateway_order-handling_2_join', - 'Gateway_order-handling_3_fork', - 'Gateway_order-handling_3_join', - ]); - expect(idsOfTag(rt.frozenXml, 'parallelGateway')).toEqual([ - 'Gateway_order-handling_4_fork', - 'Gateway_order-handling_4_join', - ]); - // A race merges on an exclusive join, since exactly one branch ever runs. - expect(idsOfTag(rt.frozenXml, 'exclusiveGateway')).toEqual([ - 'Gateway_order-handling_5_join', - 'Gateway_order-handling_6_join', - ]); - }); - - it('the one fallback points at its else branch, and the fork beside an unheaded branch names none', () => { - expect(declaredDefaults(rt.frozenXml)).toEqual([ - [ - 'Gateway_order-handling_2_fork', - 'Flow_Gateway_order-handling_2_fork_default', - ], - ]); - - const flows = sequenceFlows(rt.frozenXml); +it('keeps each fork kind and the one default at every hop, printing every branch head and wait with no jump and no warning', () => { + for (const [label, ir] of rt.hops) { + expect( + countOfKind(ir, 'inclusiveGateway'), + `inclusive pairs differ in ${label}`, + ).toBe(4); expect( - flows.find((f) => f.id === 'Flow_Gateway_order-handling_2_fork_default') - ?.targetRef, - ).toBe('TriageOrder'); + countOfKind(ir, 'parallelGateway'), + `parallel pair differs in ${label}`, + ).toBe(2); expect( - flows - .filter((f) => f.sourceRef === 'Gateway_order-handling_3_fork') - .map((f) => [f.targetRef, f.conditioned]), - ).toEqual([ - ['PrepareCustoms', true], - ['PrintLabel', false], - ]); - }); + countOfKind(ir, 'eventBasedGateway'), + `race gateways differ in ${label}`, + ).toBe(2); - it('each race gateway waits on one catch event per branch and weighs none of them', () => { - expect(idsOfTag(rt.frozenXml, 'eventBasedGateway')).toEqual(RACE_GATEWAYS); + // The second fork's unheaded branch always runs, so it needs no fallback. + const carried = ir.flowElements.filter( + (fe) => fe.kind === 'inclusiveGateway' && fe.defaultFlowId !== undefined, + ); + expect( + carried.map((fe) => fe.id), + `defaults differ in ${label}`, + ).toEqual(['Gateway_order-handling_2_fork']); + } + expect(blockHeads(rt.dslPrime)).toEqual([ + 'if (orderValue > 10000) {', + 'if (!stockShort) {', + 'else {', + 'if (overseas) {', + '{', + '{', + '{', + 'await {', + 'message("PaymentReceived") {', + 'timer("P3D", asyncBefore: true) {', + 'await {', + 'signal("StockArrived") {', + 'condition(stockShort) {', + 'await message("CarrierBooked")', + ]); + expect(rt.dslPrime).not.toContain('goto'); + expect(rt.dslPrime).not.toContain(UNSTRUCTURED_MARKER); + // Nameless gateways, and every fork names its default or has an unheaded + // branch, so neither the elided-label nor the fallback warning applies. + expect(irToDsl(rt.ir2).warnings).toEqual([]); +}); - const catchIds = idsOfTag(rt.frozenXml, 'intermediateCatchEvent'); - // A race branch's catch is named for its gateway and its position, and the - // plain `await` for the statement alone, so a drift has a name here. - expect(catchIds).toEqual([ - 'Catch_order-handling_5_b0', - 'Catch_order-handling_5_b1', - 'Catch_order-handling_6_b0', - 'Catch_order-handling_6_b1', - 'Catch_order-handling_7', - ]); - const catchEvents = new Set(catchIds); - const flows = sequenceFlows(rt.frozenXml); - for (const gateway of RACE_GATEWAYS) { - const outs = flows.filter((f) => f.sourceRef === gateway); - expect(outs, `${gateway} does not fork`).toHaveLength(2); - for (const out of outs) { - // Operaton builds no transition for a flow out of an event-based - // gateway and routes through the event scope instead, so a condition - // there would be content nothing reads. - expect(out.conditioned, `${out.id} carries a condition`).toBe(false); - expect(catchEvents.has(out.targetRef), `${out.id} misses a catch`).toBe( - true, - ); - } +it('writes the gateway pairs, the one fallback onto its else branch, and each race waiting unweighed on one catch per branch', () => { + expect(idsOfTag(rt.frozenXml, 'inclusiveGateway')).toEqual([ + 'Gateway_order-handling_2_fork', + 'Gateway_order-handling_2_join', + 'Gateway_order-handling_3_fork', + 'Gateway_order-handling_3_join', + ]); + expect(idsOfTag(rt.frozenXml, 'parallelGateway')).toEqual([ + 'Gateway_order-handling_4_fork', + 'Gateway_order-handling_4_join', + ]); + expect(idsOfTag(rt.frozenXml, 'exclusiveGateway')).toEqual([ + 'Gateway_order-handling_5_join', + 'Gateway_order-handling_6_join', + ]); + expect(declaredDefaults(rt.frozenXml)).toEqual([ + [ + 'Gateway_order-handling_2_fork', + 'Flow_Gateway_order-handling_2_fork_default', + ], + ]); + + const flows = sequenceFlows(rt.frozenXml); + expect( + flows.find((f) => f.id === 'Flow_Gateway_order-handling_2_fork_default') + ?.targetRef, + ).toBe('TriageOrder'); + expect( + flows + .filter((f) => f.sourceRef === 'Gateway_order-handling_3_fork') + .map((f) => [f.targetRef, f.conditioned]), + ).toEqual([ + ['PrepareCustoms', true], + ['PrintLabel', false], + ]); + expect(idsOfTag(rt.frozenXml, 'eventBasedGateway')).toEqual(RACE_GATEWAYS); + + const catchIds = idsOfTag(rt.frozenXml, 'intermediateCatchEvent'); + expect(catchIds).toEqual([ + 'Catch_order-handling_5_b0', + 'Catch_order-handling_5_b1', + 'Catch_order-handling_6_b0', + 'Catch_order-handling_6_b1', + 'Catch_order-handling_7', + ]); + const catchEvents = new Set(catchIds); + for (const gateway of RACE_GATEWAYS) { + const outs = flows.filter((f) => f.sourceRef === gateway); + expect(outs, `${gateway} does not fork`).toHaveLength(2); + for (const out of outs) { + // Operaton routes an event-based gateway through the event scope, so a + // condition on its flow is never read. + expect(out.conditioned, `${out.id} carries a condition`).toBe(false); + expect(catchEvents.has(out.targetRef), `${out.id} misses a catch`).toBe( + true, + ); } - }); + } }); -describeNoOverlappingShapes(rt); - -// A fork the golden pair cannot carry: a fallback beside a branch with no -// condition, so nothing is ever left for the fallback. No script authors it -// (the validator refuses the `else` it prints), so the fixture is built as IR -// and taken in through XML, the way a modeled diagram arrives. +// A fallback beside a branch with no condition, so nothing is ever left for +// it. No script authors this, so it is built as IR and taken in through XML. const DEAD_FALLBACK_FLOW = 'Flow_Gateway_1_fork_default'; function inclusiveForkIr(fallbackTarget: string): BpmnProcess { @@ -265,49 +228,24 @@ function inclusiveForkIr(fallbackTarget: string): BpmnProcess { }; } -describe('a fork whose fallback can never fire', () => { - let source: string; - let printWarnings: ReturnType['warnings']; - let errors: string[]; - - beforeAll(async () => { - const { ir } = await xmlToIr(await irToXml(inclusiveForkIr('TriageOrder'))); - ({ source, warnings: printWarnings } = irToDsl(ir)); - const { diagnostics } = await rt.validate(source); - errors = diagnostics - .filter((d) => d.severity === 1) - .map((d) => Diagnostic.getMessageString(d)); - }); - - it('writes the fallback out rather than dropping the step behind it', () => { - // Leaving the `else` out would print source compiling to a different - // model, with the triage step off the run instead of on the fallback. - expect(source).toContain('else {'); - expect(source).toContain('user TriageOrder'); - }); - - it('reports the fallback, naming the fork it belongs to', () => { - expect(printWarnings.map((w) => [w.category, w.elementId])).toEqual([ - ['defaultFlow', 'Gateway_1_fork'], - ]); - expect(printWarnings[0]?.message).toContain('nothing is ever left over'); - }); - - it('reports it because the printed source is what the validator refuses', () => { - expect(errors).toEqual([expect.stringContaining('could never run')]); - }); - - it('says nothing when the fallback runs straight into the merge', async () => { - // Same dead fallback, going nowhere the merge does not, so it is left out - // of the print and there is no `else` to report or to refuse. - const { ir } = await xmlToIr( - await irToXml(inclusiveForkIr('Gateway_1_join')), - ); - const printed = irToDsl(ir); - const { diagnostics } = await rt.validate(printed.source); - - expect(printed.source).not.toContain('else'); - expect(printed.warnings).toEqual([]); - expect(diagnostics.filter((d) => d.severity === 1)).toEqual([]); - }); +it('prints a fork fallback that can never fire as an else the validator refuses, and reports it; one running into the merge prints nothing', async () => { + const { ir } = await xmlToIr(await irToXml(inclusiveForkIr('TriageOrder'))); + const { source, warnings } = irToDsl(ir); + // Leaving the `else` out would compile to a different model. + expect(source).toContain('else {'); + expect(source).toContain('user TriageOrder'); + expect(warnings.map((w) => [w.category, w.elementId])).toEqual([ + ['defaultFlow', 'Gateway_1_fork'], + ]); + expect(warnings[0]?.message).toContain('nothing is ever left over'); + expect(await validationErrors(source)).toEqual([ + expect.stringContaining('could never run'), + ]); + + const merged = irToDsl( + (await xmlToIr(await irToXml(inclusiveForkIr('Gateway_1_join')))).ir, + ); + expect(merged.source).not.toContain('else'); + expect(merged.warnings).toEqual([]); + expect(await validationErrors(merged.source)).toEqual([]); }); diff --git a/tests/call-activity.round-trip.test.ts b/tests/call-activity.round-trip.test.ts index 2832771..2703cb9 100644 --- a/tests/call-activity.round-trip.test.ts +++ b/tests/call-activity.round-trip.test.ts @@ -1,16 +1,13 @@ // Single-stage tests cannot catch a field-name or ordering disagreement between // stages, such as the generator writing operaton:in/out in one order and the -// importer reconstructing another. This runs the whole pipeline instead. +// importer reconstructing another. -import { describe, it, expect, beforeAll } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { describe, it, expect } from 'vitest'; import type { + BpmnProcess, FlowContainer, CallActivity, - CallVariableMapper, } from '@bpmn-script/transform'; import { @@ -18,15 +15,8 @@ import { describeImportFirst, } from './helpers/import-first.js'; import { normalizeIr } from './helpers/normalize-ir.js'; -import { idsOf, subProcess as findSubProcess } from './helpers/ir-query.js'; -import { roundTrip, roundTripOf, validate } from './helpers/pipeline.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const PURCHASING_EXAMPLE_PATH = resolve( - __dirname, - '../examples/spring-boot/processes/purchasing.bpmnscript', -); +import { subProcess } from './helpers/ir-query.js'; +import { roundTrip, validate } from './helpers/pipeline.js'; function findCallActivity(container: FlowContainer, id: string): CallActivity { const el = container.flowElements.find( @@ -40,298 +30,262 @@ function findCallActivity(container: FlowContainer, id: string): CallActivity { return el; } -// `[what the call carries, the authored and printed settings after `process`, -// the IR binding, the Operaton attributes the XML carries, the ones it must not]`. -const BINDING_ROWS: readonly [ - string, - string, - CallActivity['binding'], - readonly string[], - readonly string[], -][] = [ +const inProcess = (name: string, body: string[], vars: string[] = []) => [ - 'no binding', - '', - undefined, - ['calledElement="invoice-approval"'], - ['calledElementBinding', 'calledElementVersion', 'extensionElements'], - ], - [ - 'a deployment binding', - ', binding: deployment', - { kind: 'deployment' }, - ['operaton:calledElementBinding="deployment"'], - ['calledElementVersion'], - ], - [ - 'a pinned version', - ', version: 3', - { kind: 'version', version: '3' }, - [ - 'operaton:calledElementBinding="version"', - 'operaton:calledElementVersion="3"', - ], - [], - ], -]; - -describe.each(BINDING_ROWS)( - 'round-trip: a call with %s', - (_label, settings, binding, written, notWritten) => { - const CALL = `call InvokeSub(process: "invoice-approval"${settings})`; - - const run = roundTripOf( - [ - 'process call-binding {', - ' start Start', - ` ${CALL}`, - ' end End', - '}', - '', - ].join('\n'), - ); - - it('desugars to the binding alone, writes exactly its attributes, and prints the call back', () => { - expect(findCallActivity(run.ir1, 'InvokeSub')).toEqual({ - kind: 'callActivity', - id: 'InvokeSub', - calledElement: 'invoice-approval', - binding, - }); - for (const attribute of written) expect(run.xml).toContain(attribute); - for (const attribute of notWritten) { - expect(run.xml).not.toContain(attribute); - } - expect(run.warnings).toEqual([]); - expect(findCallActivity(run.ir2, 'InvokeSub')).toEqual( - findCallActivity(run.ir1, 'InvokeSub'), - ); - expect(run.dsl).toContain(CALL); - }); - }, -); - -describe('round-trip: call activity with businessKey and every mapping shape', () => { - // The validator checks `in` sources against caller scope, so they are declared - // below. `out` sources are evaluated in the called process and are not. - const FULL_FEATURED_SRC = [ - 'process call-full-featured {', - ' var a: number', - ' var b: number', - ' var w: string', - '', + `process ${name} {`, + ...vars.map((v) => ` var ${v}`), ' start Start', - ' call InvokeSub(', - ' process: "invoice-approval",', - ' binding: latest,', - ' businessKey: "${orderId}"', - ' ) {', - ' in *', - ' in x', - ' in t = a + b', - ' in t2 = "${a.b}"', - ' in local v = w', - ' out y', - ' out z = calleeVar', - ' }', + ...body, ' end End', '}', '', ].join('\n'); - const EXPECTED_CALL: CallActivity = { - kind: 'callActivity', - id: 'InvokeSub', - calledElement: 'invoice-approval', - binding: { kind: 'latest' }, - businessKey: '${orderId}', - inMappings: [ - { kind: 'all' }, - { kind: 'variable', source: 'x', target: 'x' }, - { kind: 'expression', sourceExpression: '${a + b}', target: 't' }, - { kind: 'expression', sourceExpression: '${a.b}', target: 't2' }, - { kind: 'variable', source: 'w', target: 'v', local: true }, +const CALL = { + kind: 'callActivity', + id: 'InvokeSub', + calledElement: 'invoice-approval', +} as const; + +describe('a call activity keeps its settings, mappings and position through every hop', () => { + it.each< + [ + title: string, + source: string, + path: string[], + expected: CallActivity, + written: string[], + notWritten: string[], + printed: string, + ] + >([ + [ + 'no binding writes no binding attributes', + inProcess('call-binding', [ + ' call InvokeSub(process: "invoice-approval")', + ]), + [], + { ...CALL, binding: undefined }, + ['calledElement="invoice-approval"'], + ['calledElementBinding', 'calledElementVersion', 'extensionElements'], + ' call InvokeSub(process: "invoice-approval")\n', ], - outMappings: [ - { kind: 'variable', source: 'y', target: 'y' }, - { kind: 'variable', source: 'calleeVar', target: 'z' }, + [ + 'a deployment binding writes the binding and no version', + inProcess('call-binding', [ + ' call InvokeSub(process: "invoice-approval", binding: deployment)', + ]), + [], + { ...CALL, binding: { kind: 'deployment' } }, + ['operaton:calledElementBinding="deployment"'], + ['calledElementVersion'], + ' call InvokeSub(process: "invoice-approval", binding: deployment)\n', ], - }; - - const run = roundTripOf(FULL_FEATURED_SRC); - - it('desugars to the expected call node (businessKey + every mapping shape)', () => { - expect(findCallActivity(run.ir1, 'InvokeSub')).toEqual(EXPECTED_CALL); - }); - - it('imports with zero warnings, and the call node survives verbatim', () => { - expect(run.warnings).toEqual([]); - expect(findCallActivity(run.ir2, 'InvokeSub')).toEqual( - findCallActivity(run.ir1, 'InvokeSub'), - ); - }); - - it("a second round-trip (DSL' -> IR3) is normalized-equal to the first", () => { - expect(normalizeIr(run.ir3)).toEqual(normalizeIr(run.ir1)); - }); - - it('the decompiled DSL recompiles without validation errors', async () => { - const { diagnostics } = await validate(run.dsl); - expect(diagnostics.filter((d) => d.severity === 1)).toEqual([]); - }); -}); - -// `[what the call carries, the authored and printed setting, the IR mapper, the -// Operaton attribute]`. The setting column serves both the source and the -// re-emitted DSL, which is what makes a printing change visible here. -const MAPPER_ROWS: readonly [string, string, CallVariableMapper, string][] = [ - [ - 'a mapper class', - 'mapper: "com.acme.CallMapper"', - { kind: 'class', className: 'com.acme.CallMapper' }, - 'operaton:variableMappingClass="com.acme.CallMapper"', - ], - [ - 'a mapper delegate', - 'mapperDelegate: "${callMapperBean}"', - { kind: 'delegateExpression', expression: '${callMapperBean}' }, - 'operaton:variableMappingDelegateExpression="${callMapperBean}"', - ], -]; - -describe.each(MAPPER_ROWS)( - 'round-trip: call activity with %s beside its declared mappings', - (_label, mapperSetting, mapper, mapperAttr) => { - // `asyncBefore` is here so the printed position is pinned on both sides: a - // mapper that printed after the engine settings would still satisfy an - // assertion that only pinned what precedes it. - const CALL_HEAD = `call InvokeSub(process: "invoice-approval", ${mapperSetting}, asyncBefore: true) {`; - - const MAPPER_SRC = [ - 'process call-variable-mapper {', - ' var amount: number', - '', - ' start Start', - ` ${CALL_HEAD}`, - ' in invoiceAmount = amount', - ' out approved', - ' }', - ' end End', - '}', - '', - ].join('\n'); - - const EXPECTED_CALL: CallActivity = { - kind: 'callActivity', - id: 'InvokeSub', - calledElement: 'invoice-approval', - mapper, - asyncBefore: true, - inMappings: [ - { kind: 'variable', source: 'amount', target: 'invoiceAmount' }, - ], - outMappings: [ - { kind: 'variable', source: 'approved', target: 'approved' }, + [ + 'a pinned version writes the version binding and its number', + inProcess('call-binding', [ + ' call InvokeSub(process: "invoice-approval", version: 3)', + ]), + [], + { ...CALL, binding: { kind: 'version', version: '3' } }, + [ + 'operaton:calledElementBinding="version"', + 'operaton:calledElementVersion="3"', ], - }; - - const run = roundTripOf(MAPPER_SRC); - - it('the mapper survives all four hops beside the declared mappings', () => { - expect(findCallActivity(run.ir1, 'InvokeSub')).toEqual(EXPECTED_CALL); - expect(run.xml).toContain(mapperAttr); + [], + ' call InvokeSub(process: "invoice-approval", version: 3)\n', + ], + [ + // `in` sources are checked against caller scope, `out` sources are not. + 'a businessKey and every mapping shape', + inProcess( + 'call-full-featured', + [ + ' call InvokeSub(', + ' process: "invoice-approval",', + ' binding: latest,', + ' businessKey: "${orderId}"', + ' ) {', + ' in *', + ' in x', + ' in t = a + b', + ' in t2 = "${a.b}"', + ' in local v = w', + ' out y', + ' out z = calleeVar', + ' }', + ], + ['a: number', 'b: number', 'w: string'], + ), + [], + { + ...CALL, + binding: { kind: 'latest' }, + businessKey: '${orderId}', + inMappings: [ + { kind: 'all' }, + { kind: 'variable', source: 'x', target: 'x' }, + { kind: 'expression', sourceExpression: '${a + b}', target: 't' }, + { kind: 'expression', sourceExpression: '${a.b}', target: 't2' }, + { kind: 'variable', source: 'w', target: 'v', local: true }, + ], + outMappings: [ + { kind: 'variable', source: 'y', target: 'y' }, + { kind: 'variable', source: 'calleeVar', target: 'z' }, + ], + }, + [], + [], + [ + ' call InvokeSub(process: "invoice-approval", binding: latest, businessKey: "${orderId}") {', + ' in *', + ' in x', + ' in t = "${a + b}"', + ' in t2 = "${a.b}"', + ' in local v = w', + ' out y', + ' out z = calleeVar', + ' }\n', + ].join('\n'), + ], + [ + // `asyncBefore` pins the mapper's printed position on both sides. + 'a mapper class beside declared mappings', + inProcess( + 'call-variable-mapper', + [ + ' call InvokeSub(process: "invoice-approval", mapper: "com.acme.CallMapper", asyncBefore: true) {', + ' in invoiceAmount = amount', + ' out approved', + ' }', + ], + ['amount: number'], + ), + [], + { + ...CALL, + mapper: { kind: 'class', className: 'com.acme.CallMapper' }, + asyncBefore: true, + inMappings: [ + { kind: 'variable', source: 'amount', target: 'invoiceAmount' }, + ], + outMappings: [ + { kind: 'variable', source: 'approved', target: 'approved' }, + ], + }, + ['operaton:variableMappingClass="com.acme.CallMapper"'], + [], + ' call InvokeSub(process: "invoice-approval", mapper: "com.acme.CallMapper", asyncBefore: true) {\n', + ], + [ + 'a mapper delegate beside declared mappings', + inProcess( + 'call-variable-mapper', + [ + ' call InvokeSub(process: "invoice-approval", mapperDelegate: "${callMapperBean}", asyncBefore: true) {', + ' in invoiceAmount = amount', + ' out approved', + ' }', + ], + ['amount: number'], + ), + [], + { + ...CALL, + mapper: { + kind: 'delegateExpression', + expression: '${callMapperBean}', + }, + asyncBefore: true, + inMappings: [ + { kind: 'variable', source: 'amount', target: 'invoiceAmount' }, + ], + outMappings: [ + { kind: 'variable', source: 'approved', target: 'approved' }, + ], + }, + ['operaton:variableMappingDelegateExpression="${callMapperBean}"'], + [], + ' call InvokeSub(process: "invoice-approval", mapperDelegate: "${callMapperBean}", asyncBefore: true) {\n', + ], + [ + 'a call nested in a subprocess stays in it, never in the parent', + inProcess('call-in-subprocess', [ + ' subprocess Payment(label: "Handle payment") {', + ' call InvokeSub(process: "invoice-approval") {', + ' in *', + ' }', + ' }', + ]), + ['Payment'], + { ...CALL, inMappings: [{ kind: 'all' }] }, + [], + [], + [ + ' subprocess Payment(label: "Handle payment") {', + ' call InvokeSub(process: "invoice-approval") {', + ' in *', + ' }', + ' }\n', + ].join('\n'), + ], + [ + 'a goto and a fall-through converging on the call', + inProcess( + 'call-goto-demo', + [ + ' if (flag) {', + ' goto InvokeSub', + ' }', + ' user Prep(label: "Prepare", assignee: "demo")', + ' call InvokeSub(process: "invoice-approval")', + ], + ['flag: boolean'], + ), + [], + CALL, + [], + [], + [ + ' if (flag) {', + ' goto InvokeSub', + ' }', + ' user Prep(label: "Prepare", assignee: "demo")', + ' call InvokeSub(process: "invoice-approval")\n', + ].join('\n'), + ], + ])( + '%s', + async (_title, source, path, expected, written, notWritten, printed) => { + const { diagnostics } = await validate(source); + expect(diagnostics).toEqual([]); + + const run = await roundTrip(source); + const callIn = (ir: BpmnProcess) => { + expect(ir.flowElements.some((fe) => fe.id === 'InvokeSub')).toBe( + path.length === 0, + ); + return findCallActivity( + path.reduce(subProcess, ir), + 'InvokeSub', + ); + }; + expect(callIn(run.ir1)).toEqual(expected); + for (const attribute of written) expect(run.xml).toContain(attribute); + for (const attribute of notWritten) { + expect(run.xml).not.toContain(attribute); + } expect(run.warnings).toEqual([]); - expect(findCallActivity(run.ir2, 'InvokeSub')).toEqual( - findCallActivity(run.ir1, 'InvokeSub'), - ); - expect(run.dsl).toContain(CALL_HEAD); + expect(callIn(run.ir2)).toEqual(callIn(run.ir1)); + expect(run.dsl).toContain(printed); expect(normalizeIr(run.ir3)).toEqual(normalizeIr(run.ir1)); - }); - - it('the decompiled DSL recompiles without validation errors', async () => { - const { diagnostics } = await validate(run.dsl); - expect(diagnostics.filter((d) => d.severity === 1)).toEqual([]); - }); - }, -); - -describe('round-trip: call activity nested inside a subprocess', () => { - const NESTED_CALL_SRC = [ - 'process call-in-subprocess {', - ' start Start', - ' subprocess Payment(label: "Handle payment") {', - ' call ChargeCustomer(process: "invoice-approval") {', - ' in *', - ' }', - ' }', - ' end End', - '}', - '', - ].join('\n'); - - const run = roundTripOf(NESTED_CALL_SRC); - - it('the call sits in the nested Payment container, never in the parent, at every hop', () => { - for (const ir of [run.ir1, run.ir2, run.ir3]) { - expect(idsOf(ir).has('ChargeCustomer')).toBe(false); - const payment = findSubProcess(ir, 'Payment'); - const call = findCallActivity(payment, 'ChargeCustomer'); - expect(call.calledElement).toBe('invoice-approval'); - } - }); - - it('the re-emitted DSL reconstructs the nested `subprocess { call ... }` shape', () => { - expect(run.dsl).toContain('subprocess Payment(label: "Handle payment") {'); - expect(run.dsl).toContain('call ChargeCustomer'); - }); -}); - -describe('round-trip: goto targeting a call activity', () => { - const GOTO_CALL_SRC = [ - 'process call-goto-demo {', - ' var flag: boolean', - '', - ' start Start', - ' if (flag) {', - ' goto Invoke', - ' }', - ' user Prep(label: "Prepare", assignee: "demo")', - ' call Invoke(process: "invoice-approval")', - ' end End', - '}', - '', - ].join('\n'); - - const run = roundTripOf(GOTO_CALL_SRC); - - it('the fixture opens validator-clean (no diagnostics)', async () => { - const { diagnostics } = await validate(GOTO_CALL_SRC); - expect(diagnostics).toEqual([]); - }); - - it('both the goto branch and the fallthrough converge on the call node', () => { - for (const ir of [run.ir1, run.ir2]) { - expect(findCallActivity(ir, 'Invoke').calledElement).toBe( - 'invoice-approval', - ); - const incoming = ir.sequenceFlows.filter((f) => f.targetRef === 'Invoke'); - expect(incoming.length).toBeGreaterThanOrEqual(2); - } - }); - - it("a second round-trip (DSL' -> IR3) is normalized-equal to the first", () => { - // irToDsl reconstructs the goto/fallthrough convergence as `if`/`else` - // rather than replaying the literal `goto`, and re-desugaring that grows a - // pass-through join, so compare through normalizeIr. - expect(findCallActivity(run.ir3, 'Invoke').calledElement).toBe( - 'invoice-approval', - ); - expect(normalizeIr(run.ir3)).toEqual(normalizeIr(run.ir1)); - }); + const { diagnostics: printedDiagnostics } = await validate(run.dsl); + expect(printedDiagnostics).toEqual([]); + }, + ); }); -// The `name` differs from the name humanized from the id, so it survives as a -// real label instead of being dropped as derivable. +// The `name` differs from the one humanized from the id, so it survives as a label. const HANDWRITTEN_BPMN = ` { - it('imports the alias binding and the interleaved mappings', () => { + it('imports the alias binding and the interleaved mappings, and prints all `in`s before all `out`s', () => { const call = findCallActivity(first.ir, 'ReviewApprovalCall'); expect(call.binding).toEqual({ kind: 'deployment' }); expect(call.inMappings).toEqual([ @@ -399,9 +353,6 @@ describeImportFirst( target: 'approvedFlag', }, ]); - }); - - it('the emitted call canonically reorders (all `in`s, then all `out`s) and keeps the alias-normalized binding', () => { expect(first.dsl).toContain( 'call ReviewApprovalCall(label: "Get invoice sign-off", ' + 'process: "invoice-approval", binding: deployment, ' + @@ -416,25 +367,3 @@ describeImportFirst( }, [camundaAliasWarning('call-import-demo')], ); - -describe('example: purchasing.bpmnscript calls the invoice-approval example by id', () => { - let source: string; - - beforeAll(() => { - source = readFileSync(PURCHASING_EXAMPLE_PATH, 'utf-8'); - }); - - it('opens validator-clean (no diagnostics)', async () => { - const { diagnostics } = await validate(source); - expect(diagnostics).toEqual([]); - }); - - it('round-trips end to end and resolves the real invoice-approval example by id', async () => { - const run = await roundTrip(source); - expect(findCallActivity(run.ir1, 'ReviewInvoice').calledElement).toBe( - 'invoice-approval', - ); - expect(run.xml).toContain('calledElement="invoice-approval"'); - expect(run.warnings).toEqual([]); - }); -}); diff --git a/tests/compensation.round-trip.test.ts b/tests/compensation.round-trip.test.ts index 8faed68..5cf5aed 100644 --- a/tests/compensation.round-trip.test.ts +++ b/tests/compensation.round-trip.test.ts @@ -1,15 +1,7 @@ -// The guards read start-form fields, and the `var compensation` declaration -// that does not survive the round trip is referenced only inside an opaque -// service expression, so DSL' validates clean. - -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; import type { EventDefinition } from '@bpmn-script/transform'; -import { - describeDiContainment, - describeSingleDiagram, -} from './helpers/di-bounds.js'; import { describeImportFirst } from './helpers/import-first.js'; import { definitionOf, @@ -20,7 +12,6 @@ import { import { roundTripFixture } from './helpers/round-trip-fixture.js'; const rt = roundTripFixture('compensation', { - example: 'compensating-saga', importPath: true, recompile: 'clean', }); @@ -32,9 +23,7 @@ function startEventOpenTag(xml: string, id: string): string | undefined { return new RegExp(`]*>`).exec(xml)?.[0]; } -// The compensation end carries an explicit `waitForCompletion="true"`, the -// moddle default, accepted on import and dropped as unmodeled. Every task label -// differs from the name humanized from its id, so the importer keeps it. +// `waitForCompletion="true"` is the moddle default, so import drops it. const IMPORT_FIRST_BPMN = ` @@ -87,80 +76,32 @@ const IMPORT_FIRST_BPMN = ` `; -describe("idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3", () => { - it('the authored emit and throw ids survive verbatim through their handlers', () => { - // `emit compensation` continues the path, `throw compensation` ends it. - expect(elementById(rt.ir3, 'Undo').kind).toBe('intermediateThrowEvent'); - expect(elementById(rt.ir3, 'CancelAll').kind).toBe('endEvent'); - expect(definitionOf(rt.ir3, 'Undo')).toEqual({ kind: 'compensation' }); - expect(definitionOf(rt.ir3, 'CancelAll')).toEqual({ kind: 'compensation' }); - }); - - it('both undo-block handler starts carry a compensation trigger at every hop', () => { - for (const ir of [rt.ir1, rt.ir2, rt.ir3]) { - for (const host of ['BookFlight', 'BookHotel']) { - const def = handlerTriggerDef(subProcess(ir, host), isCompensation); - expect(def, `undo block missing in ${host} at a hop`).toEqual({ - kind: 'compensation', - }); - } - } - }); -}); - -describeSingleDiagram(rt); - -describeDiContainment( - rt, - () => - ['BookFlight', 'BookHotel'].flatMap((host) => { - const handlerIds = subProcess(rt.ir1, host) - .flowElements.filter( - (fe) => fe.kind === 'subProcess' && fe.triggeredByEvent === true, - ) - .map((fe) => fe.id); - expect(handlerIds.length).toBeGreaterThan(0); - return handlerIds; - }), - 'generated', -); - -describe('compensation shape pins on the frozen .bpmn', () => { - it('emits no compensation root element (only the error and escalation roots exist)', () => { - // Compensation is payload-less, so it contributes no document-level root. - expect(rt.frozenXml).not.toMatch(/ { - expect( - rt.frozenXml.match(/]*>/g), - ).toEqual(Array(4).fill('')); - }); - - it('each undo block is a triggeredByEvent sub-process whose start is interrupting', () => { - // Compensation always interrupts, so the serializer drops the default and - // the trigger start carries no interrupting flag. +it('keeps the emit, the throw and both undo blocks as payload-less compensation at every hop', () => { + expect(elementById(rt.ir3, 'Undo').kind).toBe('intermediateThrowEvent'); + expect(elementById(rt.ir3, 'CancelAll').kind).toBe('endEvent'); + expect(definitionOf(rt.ir3, 'Undo')).toEqual({ kind: 'compensation' }); + expect(definitionOf(rt.ir3, 'CancelAll')).toEqual({ kind: 'compensation' }); + for (const [label, ir] of rt.hops) { for (const host of ['BookFlight', 'BookHotel']) { - const handler = subProcess(rt.ir1, host).flowElements.find( - (fe) => fe.kind === 'subProcess' && fe.triggeredByEvent === true, - ); - expect(handler?.kind).toBe('subProcess'); - if (handler?.kind === 'subProcess') { - const start = handler.flowElements.find((e) => e.kind === 'startEvent'); - expect( - start?.kind === 'startEvent' && start.isInterrupting, - ).toBeUndefined(); - } + const def = handlerTriggerDef(subProcess(ir, host), isCompensation); + expect(def, `${host} in ${label}`).toEqual({ kind: 'compensation' }); } + } +}); - for (const startId of ['CancelFlightStart', 'CancelHotelStart']) { - const tag = startEventOpenTag(rt.frozenXml, startId); - expect(tag, `start ${startId} not found`).toBeDefined(); - expect(tag).not.toContain('isInterrupting'); - } - }); +it('writes compensation without a root, an attribute or an interrupting flag', () => { + expect(rt.frozenXml).not.toMatch(/]*>/g), + ).toEqual(Array(4).fill('')); + // Compensation always interrupts, so the default is not written. + for (const startId of ['CancelFlightStart', 'CancelHotelStart']) { + expect(startEventOpenTag(rt.frozenXml, startId)).toBe( + ``, + ); + } }); describeImportFirst( @@ -174,9 +115,6 @@ describeImportFirst( expect(first.dsl).toContain('on compensation {'); expect(first.dsl).toContain('emit compensation Raise'); expect(first.dsl).toContain('throw compensation GiveUp'); - }); - - it('the emit and throw resolve to the payload-less compensation kind', () => { expect(definitionOf(first.ir, 'Raise')).toEqual({ kind: 'compensation' }); expect(definitionOf(first.ir, 'GiveUp')).toEqual({ kind: 'compensation', diff --git a/tests/cyclic-loop-performance.test.ts b/tests/cyclic-loop-performance.test.ts index d1a4849..9cad617 100644 --- a/tests/cyclic-loop-performance.test.ts +++ b/tests/cyclic-loop-performance.test.ts @@ -1,7 +1,6 @@ -// One dominator tree per test keeps `loopTestRank` near O(T*N) per pass, -// but `loopEntries` still re-runs it once per nested SCC, so a model whose -// gotos tie every loop into one cycle prints in roughly cubic time in the -// flow node count. The budget below guards that known limit from growing. +// `loopEntries` re-runs `loopTestRank` once per nested SCC, so gotos tying +// every loop into one cycle print in roughly cubic time; the budget keeps that +// known limit from growing. import { describe, expect, it } from 'vitest'; import { astToIr, irToDsl } from '@bpmn-script/transform'; import { parseToAst } from './helpers/pipeline.js'; @@ -9,8 +8,7 @@ import { parseToAst } from './helpers/pipeline.js'; const WHILE_COUNT = 150; const BUDGET_MS = 60_000; -// Each `while` closes its own cycle; each `goto` after one ties it into the -// previous while's body, so gotos chain every loop into a single SCC. +// Each `goto` ties a while into the previous one's body: one SCC overall. function cyclicWhiles(n: number): string { const lines = ['process p {', ' var x: any', ' var c: any']; for (let i = 0; i < n; i++) { diff --git a/tests/documentation.round-trip.test.ts b/tests/documentation.round-trip.test.ts index 56cd8bb..6f3a511 100644 --- a/tests/documentation.round-trip.test.ts +++ b/tests/documentation.round-trip.test.ts @@ -1,12 +1,8 @@ -// A documentation string is inert, so a process whose text stopped travelling -// looks unchanged; only reading the value back at every hop catches that. - -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; import type { FlowContainer } from '@bpmn-script/transform'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; -import { describeDiContainment } from './helpers/di-bounds.js'; import { allElements } from './helpers/ir-query.js'; const rt = roundTripFixture('documentation', { @@ -18,8 +14,6 @@ const rt = roundTripFixture('documentation', { const PROCESS_TEXT = 'Runs once for each supplier the buying team wants to trade with.'; -// The complete set, so a carrier that stops carrying goes missing and an -// element that gains one it has no surface for shows up. const CARRIED: Record = { ApplicationReceived: 'The supplier fills this in on the portal before anything else happens.', @@ -42,9 +36,6 @@ const CARRIED: Record = { 'The supplier may be named on a purchase order from here on.', }; -// The printed spelling of every setting, frozen: which of the two prose -// settings comes first, and what the escapes look like once a newline, a -// quote, a backslash and a leading `${` have been through the printer. const PRINTED_SETTINGS = [ 'process supplier-onboarding(label: "Supplier onboarding", documentation: "Runs once for each supplier the buying team wants to trade with.") {', ' start ApplicationReceived(label: "Application received", documentation: "The supplier fills this in on the portal before anything else happens.") {', @@ -72,25 +63,12 @@ function carriedDocumentation( ); } -function settingLines(source: string): string[] { - return source.split('\n').filter((line) => line.includes('documentation:')); -} - -describe('the frozen documentation contract', () => { - it('every carrier keeps its text at every hop, the process header included', () => { - for (const [label, ir] of rt.hops) { - expect(carriedDocumentation(ir), `carriers differ in ${label}`).toEqual( - CARRIED, - ); - expect(ir.documentation, `the header differs in ${label}`).toBe( - PROCESS_TEXT, - ); - } - }); - - it("the printed DSL' writes a label ahead of the documentation beside it, escaped", () => { - expect(settingLines(rt.dslPrime)).toEqual(PRINTED_SETTINGS); - }); +it("keeps every carrier's text at every hop and prints each label ahead of its escaped documentation", () => { + for (const [label, ir] of rt.hops) { + expect(carriedDocumentation(ir), label).toEqual(CARRIED); + expect(ir.documentation, label).toBe(PROCESS_TEXT); + } + expect( + rt.dslPrime.split('\n').filter((line) => line.includes('documentation:')), + ).toEqual(PRINTED_SETTINGS); }); - -describeDiContainment(rt, ['SetUpAccount']); diff --git a/tests/engine-attributes.round-trip.test.ts b/tests/engine-attributes.round-trip.test.ts index 9928bd1..97182b7 100644 --- a/tests/engine-attributes.round-trip.test.ts +++ b/tests/engine-attributes.round-trip.test.ts @@ -1,15 +1,10 @@ -// None of these settings changes control flow, so what this suite catches is a -// value that stops traveling in one of the four directions while the rest of -// the process looks unchanged. - -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; import { ENGINE_KEYS, type EngineKey } from '@bpmn-script/language'; import { isGateway } from '@bpmn-script/transform'; import type { EngineAttributes, FlowElement } from '@bpmn-script/transform'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; -import { describeDiContainment } from './helpers/di-bounds.js'; import { allElements, elementById, theOnly } from './helpers/ir-query.js'; const rt = roundTripFixture('engine-attributes', { @@ -18,8 +13,7 @@ const rt = roundTripFixture('engine-attributes', { recompile: 'clean', }); -// `historyTimeToLive` is deliberately not the exporter's default: at `P30D` -// the importer reads it as unwritten and the hop proves nothing. +// Not the exporter's `P30D` default, which the importer reads as unwritten. const PROCESS_HEADER = { versionTag: '3.1.0', historyTimeToLive: 'P90D', @@ -27,10 +21,6 @@ const PROCESS_HEADER = { candidateStarterGroups: 'adjusters', }; -// The tripwire for a regenerated golden: layout, flow ids, and synthesized -// handler and gateway ids may move, and no row here may. The three carriers -// with synthesized ids are pinned in the blocks below instead, located by what -// they are. const ENGINE_ATTRIBUTE_CONTRACT: readonly (readonly [ id: string, attribute: string, @@ -56,8 +46,6 @@ const ENGINE_ATTRIBUTE_CONTRACT: readonly (readonly [ ['OrderRepair', 'retryCycle', 'R5/PT10M'], ['PayoutReviewNeeded', 'asyncAfter', true], ['PayoutReviewNeeded', 'jobPriority', '40'], - // The key and the binding are one IR value, so one row: a version that - // stopped travelling fails as loudly as a key. [ 'ApprovePayout', 'formRef', @@ -66,8 +54,7 @@ const ENGINE_ATTRIBUTE_CONTRACT: readonly (readonly [ ['ClaimSettled', 'asyncBefore', true], ]; -// The `satisfies` clause keeps the list complete: a field added to -// `EngineAttributes` beyond the job settings and not here stops compiling. +// `satisfies` fails to compile when `EngineAttributes` gains a field not listed. const ENGINE_ATTRIBUTE_KEYS = [ ...ENGINE_KEYS, ...Object.keys({ executionListeners: 0 } satisfies Record< @@ -76,123 +63,84 @@ const ENGINE_ATTRIBUTE_KEYS = [ >), ]; -// Indexed rather than a field access: the IR types keep these fields off the -// kinds that cannot carry them, and the gateway block below reads exactly those. +// Indexed: the IR types keep these fields off the kinds that cannot carry them. function setting(el: FlowElement, attribute: string): unknown { return (el as unknown as Record)[attribute]; } -describe('the frozen engine-attribute contract', () => { - it('every authored node keeps its settings and their values at every hop', () => { - for (const [label, ir] of rt.hops) { - for (const [id, attribute, value] of ENGINE_ATTRIBUTE_CONTRACT) { - expect( - setting(elementById(ir, id), attribute), - `${id}.${attribute} differs in ${label}`, - ).toStrictEqual(value); - } +it('keeps every authored setting, the header settings and the async await at every hop, and invents none on a gateway', () => { + for (const [label, ir] of rt.hops) { + for (const [id, attribute, value] of ENGINE_ATTRIBUTE_CONTRACT) { + expect( + setting(elementById(ir, id), attribute), + `${id}.${attribute} in ${label}`, + ).toStrictEqual(value); } - }); - - it('the four engine settings on the header keep their values at every hop', () => { - for (const [label, ir] of rt.hops) { - const { + const { + versionTag, + historyTimeToLive, + candidateStarterUsers, + candidateStarterGroups, + } = ir; + expect( + { versionTag, historyTimeToLive, candidateStarterUsers, candidateStarterGroups, - } = ir; - expect( - { - versionTag, - historyTimeToLive, - candidateStarterUsers, - candidateStarterGroups, - }, - `the process header differs in ${label}`, - ).toEqual(PROCESS_HEADER); - } - }); - - // Asserted as the whole opening tag: Operaton refuses to deploy a form - // reference whose binding attribute is missing, so an emission that dropped - // one of the three would still look plausible read on its own. - it('the user task naming a deployed form writes all three reference attributes', () => { - expect(rt.frozenXml).toContain( - '', + }, + label, + ).toEqual(PROCESS_HEADER); + expect(theOnly(ir, 'intermediateCatchEvent').asyncBefore, label).toBe(true); + const gateways = allElements(ir).filter(isGateway); + expect(new Set(gateways.map((gw) => gw.kind)), label).toEqual( + new Set(['exclusiveGateway', 'parallelGateway']), ); - }); - - it('the awaited catch keeps its async continuation at every hop', () => { - for (const [label, ir] of rt.hops) { - const awaited = theOnly(ir, 'intermediateCatchEvent'); - expect(awaited.asyncBefore, `await differs in ${label}`).toBe(true); - } - }); -}); - -// The `gateway-settings` pair pins what a gateway written with settings -// carries; this artifact writes none, so it pins that no direction invents one. -describe('a gateway written without settings gains none at any hop', () => { - it('neither gateway kind holds one, at any container depth or hop', () => { - for (const [label, ir] of rt.hops) { - const gateways = allElements(ir).filter(isGateway); - expect( - new Set(gateways.map((gw) => gw.kind)), - `both gateway kinds must be present in ${label}`, - ).toEqual(new Set(['exclusiveGateway', 'parallelGateway'])); - - for (const gateway of gateways) { - for (const key of ENGINE_ATTRIBUTE_KEYS) { - expect( - setting(gateway, key), - `${gateway.id}.${key} in ${label}`, - ).toBeUndefined(); - } + for (const gateway of gateways) { + for (const key of ENGINE_ATTRIBUTE_KEYS) { + expect( + setting(gateway, key), + `${gateway.id}.${key} in ${label}`, + ).toBeUndefined(); } } - }); + } }); -describe('where a handler puts the settings written on it', () => { - // The lock is written twice on purpose: `BpmnParse.parseTimer` reads it - // off the definition for the timer job, `parseAsynchronousContinuation` off - // the tag for the asyncAfter job. - it("a hosted handler's settings land on the boundary event, its lock on the timer definition too", () => { - const boundary = theOnly(rt.ir2, 'boundaryEvent'); - expect(boundary.attachedToRef).toBe('ApprovePayout'); - expect(boundary.asyncAfter).toBe(true); - expect(boundary.exclusive).toBe(false); - expect(rt.frozenXml).toContain( - '\n' + - ' Flow_Boundary_ApprovePayout_timer_NudgeApprover\n' + - ' ', - ); - }); - - it("a host-less handler's settings land on the event sub-process, not on its trigger", () => { - const handler = theOnly( - rt.ir2, - 'subProcess', - (sp) => sp.triggeredByEvent === true, - ); - expect(handler.asyncBefore).toBe(true); - expect(handler.jobPriority).toBe('60'); - expect(handler.retryCycle).toBe('R2/PT30S'); - - const trigger = theOnly(handler, 'startEvent'); - expect(trigger.id).toBe('ReviewRequested'); - for (const key of ENGINE_ATTRIBUTE_KEYS) { - expect(setting(trigger, key), `${trigger.id}.${key}`).toBeUndefined(); - } - }); +it('writes all three form reference attributes, and puts handler settings on the boundary or the event sub-process, never the trigger', () => { + // Operaton refuses to deploy a form reference missing its binding. + expect(rt.frozenXml).toContain( + '', + ); + // The lock is written twice: `BpmnParse.parseTimer` reads it off the + // definition, `parseAsynchronousContinuation` off the tag. + expect(rt.frozenXml).toContain( + '\n' + + ' Flow_Boundary_ApprovePayout_timer_NudgeApprover\n' + + ' ', + ); + const boundary = theOnly(rt.ir2, 'boundaryEvent'); + expect([ + boundary.attachedToRef, + boundary.asyncAfter, + boundary.exclusive, + ]).toEqual(['ApprovePayout', true, false]); + const handler = theOnly( + rt.ir2, + 'subProcess', + (sp) => sp.triggeredByEvent === true, + ); + expect([ + handler.asyncBefore, + handler.jobPriority, + handler.retryCycle, + ]).toEqual([true, '60', 'R2/PT30S']); + const trigger = theOnly(handler, 'startEvent'); + expect(trigger.id).toBe('ReviewRequested'); + for (const key of ENGINE_ATTRIBUTE_KEYS) { + expect(setting(trigger, key), `${trigger.id}.${key}`).toBeUndefined(); + } }); - -// Both nesting parents are named so the walk cannot pass on an empty tree. -describeDiContainment(rt, () => [ - 'InspectVehicle', - theOnly(rt.ir1, 'subProcess', (sp) => sp.triggeredByEvent === true).id, -]); diff --git a/tests/event-handlers.round-trip.test.ts b/tests/event-handlers.round-trip.test.ts index 4585f49..4ba62f5 100644 --- a/tests/event-handlers.round-trip.test.ts +++ b/tests/event-handlers.round-trip.test.ts @@ -1,16 +1,7 @@ -// The guard clause the fixture exercises, a `throw` branch inside an `if` whose -// enclosing flow continues past it, is recovered structurally: the terminal -// prints inside the `if` and the continuation resumes after it, so no jump ever -// targets the `if`'s synthesized join. - -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; import type { EventDefinition } from '@bpmn-script/transform'; -import { - describeDiContainment, - describeSingleDiagram, -} from './helpers/di-bounds.js'; import { camundaAliasWarning, describeImportFirst, @@ -18,21 +9,17 @@ import { import { definitionOf, handlerTriggerDef, - kindOf, subProcess, } from './helpers/ir-query.js'; import { definitionRefOf, errorRoots } from './helpers/xml-query.js'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; const rt = roundTripFixture('event-handlers', { - example: 'order-recovery', importPath: true, recompile: 'errors', }); -// `camunda:` aliases on the error root message and the catch bindings, and -// labels that differ from the name humanized from each id, so the importer -// keeps them. +// `camunda:` aliases on the error root message and the catch bindings. const IMPORT_FIRST_BPMN = ` @@ -67,98 +54,64 @@ const IMPORT_FIRST_BPMN = ` `; -describe("idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3", () => { - it('the authored throw and emit ids survive verbatim at their correct depth', () => { - const payment = subProcess(rt.ir3, 'ProcessPayment'); - expect(kindOf(payment, 'PaymentFailed')).toBe('endEvent'); - expect(kindOf(rt.ir3, 'FlagForReview')).toBe('intermediateThrowEvent'); - }); - - it('the error and escalation declarations survive the round-trip', () => { - expect(rt.ir3.errorDecls).toEqual([ - { - name: 'PAYMENT_DECLINED', - code: 'PAYMENT_DECLINED', - message: 'The payment was declined by the bank', - }, - // A name apart from its code, and a declaration nothing raises or - // catches: both come back only if the root carries them. - { - name: 'GatewayTimeout', - code: 'gateway.timeout', - message: 'The payment gateway did not answer', - }, - { - name: 'STOCK_UNAVAILABLE', - code: 'STOCK_UNAVAILABLE', - message: 'The warehouse cannot fulfil the order', - }, - ]); - expect(rt.ir3.escalationDecls).toEqual([ - { name: 'MANUAL_REVIEW', code: 'MANUAL_REVIEW' }, - { name: 'ORDER_ABANDONED', code: 'ORDER_ABANDONED' }, - ]); - }); - - it('the handler trigger start carries its event definition at every hop', () => { - const isPaymentError = (def: EventDefinition | undefined): boolean => - def?.kind === 'error' && def.errorCode === 'PAYMENT_DECLINED'; - for (const [label, ir] of rt.hops) { - expect( - handlerTriggerDef(subProcess(ir, 'ProcessPayment'), isPaymentError), - `handler error definition differs in ${label}`, - ).toEqual({ - kind: 'error', - errorCode: 'PAYMENT_DECLINED', - codeVariable: 'c', - messageVariable: 'm', - }); - } - }); - - it('the terminal escalation end and the escalation emit keep their definitions', () => { - expect(definitionOf(rt.ir3, 'FlagForReview')).toEqual({ - kind: 'escalation', - escalationCode: 'MANUAL_REVIEW', +it('keeps the declarations, the handler trigger at every hop and the escalation throws', () => { + expect(rt.ir3.errorDecls).toEqual([ + { + name: 'PAYMENT_DECLINED', + code: 'PAYMENT_DECLINED', + message: 'The payment was declined by the bank', + }, + // A name apart from its code, and a declaration nothing raises or + // catches: both come back only if the root carries them. + { + name: 'GatewayTimeout', + code: 'gateway.timeout', + message: 'The payment gateway did not answer', + }, + { + name: 'STOCK_UNAVAILABLE', + code: 'STOCK_UNAVAILABLE', + message: 'The warehouse cannot fulfil the order', + }, + ]); + expect(rt.ir3.escalationDecls).toEqual([ + { name: 'MANUAL_REVIEW', code: 'MANUAL_REVIEW' }, + { name: 'ORDER_ABANDONED', code: 'ORDER_ABANDONED' }, + ]); + const isPaymentError = (def: EventDefinition | undefined): boolean => + def?.kind === 'error' && def.errorCode === 'PAYMENT_DECLINED'; + for (const [label, ir] of rt.hops) { + expect( + handlerTriggerDef(subProcess(ir, 'ProcessPayment'), isPaymentError), + label, + ).toEqual({ + kind: 'error', + errorCode: 'PAYMENT_DECLINED', + codeVariable: 'c', + messageVariable: 'm', }); - const terminal = rt.ir3.flowElements.find( + } + expect(definitionOf(rt.ir3, 'FlagForReview')).toEqual({ + kind: 'escalation', + escalationCode: 'MANUAL_REVIEW', + }); + expect( + rt.ir3.flowElements.filter( (fe) => fe.kind === 'endEvent' && fe.eventDefinition?.kind === 'escalation' && fe.eventDefinition.escalationCode === 'ORDER_ABANDONED', - ); - expect(terminal, 'terminal throw escalation missing').toBeDefined(); - }); + ), + ).toHaveLength(1); }); -describeSingleDiagram(rt); - -describeDiContainment( - rt, - () => { - const handlerIds = subProcess(rt.ir1, 'ProcessPayment') - .flowElements.filter((fe) => fe.kind === 'subProcess') - .map((fe) => fe.id); - expect(handlerIds.length).toBeGreaterThan(0); - return [...handlerIds, 'CaughtPayment', 'NotifyCustomer']; - }, - 'generated', -); - -describe('root sharing on the frozen .bpmn', () => { - it('the throw error end event and the on error handler share one bpmn:Error carrying the message', () => { - const roots = errorRoots(rt.frozenXml, 'PAYMENT_DECLINED'); - expect(roots).toHaveLength(1); - const { id: rootId, message } = roots[0]!; - expect(message).toBe('The payment was declined by the bank'); - - expect(definitionRefOf(rt.frozenXml, 'PaymentFailed', 'error')).toBe( - rootId, - ); - expect(definitionRefOf(rt.frozenXml, 'CaughtPayment', 'error')).toBe( - rootId, - ); - }); +it('the throw error end and the on error handler share one bpmn:Error carrying the message', () => { + expect(errorRoots(rt.frozenXml, 'PAYMENT_DECLINED')).toEqual([ + { id: expect.any(String), message: 'The payment was declined by the bank' }, + ]); + const [{ id }] = errorRoots(rt.frozenXml, 'PAYMENT_DECLINED'); + expect(definitionRefOf(rt.frozenXml, 'PaymentFailed', 'error')).toBe(id); + expect(definitionRefOf(rt.frozenXml, 'CaughtPayment', 'error')).toBe(id); }); describeImportFirst( @@ -166,7 +119,6 @@ describeImportFirst( IMPORT_FIRST_BPMN, (first) => { it('normalizes the camunda: error message and binding aliases into the DSL', () => { - // `code` doubles as an ordinary variable name in the catch parameter. expect(first.dsl).toContain('error BOOM(message: "It went boom")'); expect(first.dsl).toContain( 'on error(BOOM, code: code, message: text) {', diff --git a/tests/event-positions.round-trip.test.ts b/tests/event-positions.round-trip.test.ts index ee38991..b6ac61d 100644 --- a/tests/event-positions.round-trip.test.ts +++ b/tests/event-positions.round-trip.test.ts @@ -1,11 +1,5 @@ -// The terminate end ends its branch, so DSL' prints that branch as a `goto` -// onto the named service task rather than as the authored `if`/`else`. The IR -// is the same either way, and every node is named, so DSL' still validates -// clean. +import { it, expect } from 'vitest'; -import { describe, it, expect } from 'vitest'; - -import { describeNoOverlappingShapes } from './helpers/di-bounds.js'; import { endEvent, theOnly } from './helpers/ir-query.js'; import { definitionRefOf, messageRoots } from './helpers/xml-query.js'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; @@ -16,67 +10,41 @@ const rt = roundTripFixture('event-positions', { recompile: 'clean', }); -describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { - it('the message start keeps its correlation name at every hop', () => { - for (const [label, ir] of rt.hops) { - expect( - theOnly(ir, 'startEvent').eventDefinition, - `start trigger differs in ${label}`, - ).toEqual({ kind: 'message', messageName: 'OrderReceived' }); - } - }); - - it('the terminate end keeps its trigger and its label at every hop', () => { - for (const [label, ir] of rt.hops) { - const abandon = endEvent(ir, 'OrderAbandoned'); - expect(abandon.eventDefinition, `trigger differs in ${label}`).toEqual({ - kind: 'terminate', - }); - expect(abandon.name, `label differs in ${label}`).toBe( - 'Abandon every path', - ); - } - }); - - it("the decompiled DSL' writes each position back on its own statement", () => { - expect(rt.dslPrime).toContain( - 'start OrderReceived message("OrderReceived", label: "An order arrives")', - ); - expect(rt.dslPrime).toContain( - 'emit message NotifyWarehouse("WarehouseNotified")', - ); - expect(rt.dslPrime).toContain( - 'end OrderAbandoned terminate(label: "Abandon every path")', - ); - expect(rt.dslPrime).toContain( - 'throw message OrderAcknowledged("OrderAcknowledged")', - ); - }); -}); - -describe('root derivation on the frozen .bpmn', () => { - it('carries one bpmn:Message per distinct name, in first-appearance order', () => { - expect(messageRoots(rt.frozenXml).map((root) => root.name)).toEqual([ - 'OrderReceived', - 'WarehouseNotified', - 'OrderAcknowledged', +it('keeps the message start and the labelled terminate end at every hop, and writes each position back on its own statement', () => { + for (const [label, ir] of rt.hops) { + expect(theOnly(ir, 'startEvent').eventDefinition, label).toEqual({ + kind: 'message', + messageName: 'OrderReceived', + }); + const abandon = endEvent(ir, 'OrderAbandoned'); + expect([abandon.eventDefinition, abandon.name], label).toEqual([ + { kind: 'terminate' }, + 'Abandon every path', ]); - }); - - it('the start event carries a messageRef beside its form data', () => { - const start = - //.exec( - rt.frozenXml, - )?.[0]; - expect( - start, - 'no OrderReceived start event in the frozen artifact', - ).toBeDefined(); - expect(start).toContain(''); - expect(definitionRefOf(rt.frozenXml, 'OrderReceived', 'message')).toBe( - messageRoots(rt.frozenXml)[0]!.id, - ); - }); + } + for (const line of [ + 'start OrderReceived message("OrderReceived", label: "An order arrives")', + 'emit message NotifyWarehouse("WarehouseNotified")', + 'end OrderAbandoned terminate(label: "Abandon every path")', + 'throw message OrderAcknowledged("OrderAcknowledged")', + ]) { + expect(rt.dslPrime).toContain(line); + } }); -describeNoOverlappingShapes(rt); +it('carries one bpmn:Message per name in first-appearance order, the start referencing its root beside its form data', () => { + const roots = messageRoots(rt.frozenXml); + expect(roots.map((root) => root.name)).toEqual([ + 'OrderReceived', + 'WarehouseNotified', + 'OrderAcknowledged', + ]); + expect( + //.exec( + rt.frozenXml, + )?.[0], + ).toContain(''); + expect(definitionRefOf(rt.frozenXml, 'OrderReceived', 'message')).toBe( + roots[0]!.id, + ); +}); diff --git a/tests/event-triggers.round-trip.test.ts b/tests/event-triggers.round-trip.test.ts index 70952c3..2cc26d5 100644 --- a/tests/event-triggers.round-trip.test.ts +++ b/tests/event-triggers.round-trip.test.ts @@ -1,14 +1,7 @@ -// The fixture avoids the early-exit-inside-`if` shape, which degrades into a -// goto onto an unnamed synthesized join, so DSL' validates clean. - -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; import type { EventDefinition, FlowContainer } from '@bpmn-script/transform'; -import { - describeDiContainment, - describeSingleDiagram, -} from './helpers/di-bounds.js'; import { describeImportFirst } from './helpers/import-first.js'; import { definitionOf, @@ -21,7 +14,6 @@ import { definitionRefOf, messageRoots } from './helpers/xml-query.js'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; const rt = roundTripFixture('event-triggers', { - example: 'order-reminder', importPath: true, recompile: 'clean', }); @@ -32,9 +24,7 @@ function timerExpressions(container: FlowContainer): string[] { .sort(); } -// One `bpmn:Signal` root is referenced twice, by the intermediate throw and by -// the end event. Every task label differs from the name humanized from its id, -// so the importer keeps it. +// One `bpmn:Signal` root referenced by the intermediate throw and the end event. const IMPORT_FIRST_BPMN = ` @@ -95,93 +85,55 @@ const IMPORT_FIRST_BPMN = ` `; -describe("idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3", () => { - it('the authored throw and emit ids survive verbatim at process level', () => { - // `emit signal` continues the path, `throw signal` ends it. - expect(kindOf(rt.ir3, 'Notify')).toBe('intermediateThrowEvent'); - expect(kindOf(rt.ir3, 'Announce')).toBe('endEvent'); - expect(definitionOf(rt.ir3, 'Notify')).toEqual({ - kind: 'signal', - signalName: 'OrderFulfilled', - }); - expect(definitionOf(rt.ir3, 'Announce')).toEqual({ - kind: 'signal', - signalName: 'OrderFulfilled', - }); - }); - - it('the conditional handler condition survives as the same expression at every hop', () => { - const isConditional = (def: EventDefinition | undefined): boolean => - def?.kind === 'conditional'; - for (const [label, ir] of rt.hops) { - expect( - handlerTriggerDef(subProcess(ir, 'FulfilOrder'), isConditional), - `conditional handler differs in ${label}`, - ).toEqual({ kind: 'conditional', condition: '${stockLevel < 5}' }); - } - }); - - it('the timer expressions survive verbatim at every hop', () => { - for (const ir of [rt.ir1, rt.ir2, rt.ir3]) { - expect(timerExpressions(ir)).toEqual(['2026-08-01T09:00:00', 'PT2H']); - } - }); - - it('the message handler keeps its correlation name', () => { - const def = handlerTriggerDef(rt.ir3, (d) => d?.kind === 'message'); - expect(def).toEqual({ kind: 'message', messageName: 'OrderCancelled' }); +it('keeps the signal throws, and every handler trigger at every hop', () => { + // `emit signal` continues the path, `throw signal` ends it. + const signal = { kind: 'signal', signalName: 'OrderFulfilled' }; + expect(kindOf(rt.ir3, 'Notify')).toBe('intermediateThrowEvent'); + expect(kindOf(rt.ir3, 'Announce')).toBe('endEvent'); + expect(definitionOf(rt.ir3, 'Notify')).toEqual(signal); + expect(definitionOf(rt.ir3, 'Announce')).toEqual(signal); + const isConditional = (def: EventDefinition | undefined): boolean => + def?.kind === 'conditional'; + for (const [label, ir] of rt.hops) { + expect( + handlerTriggerDef(subProcess(ir, 'FulfilOrder'), isConditional), + label, + ).toEqual({ kind: 'conditional', condition: '${stockLevel < 5}' }); + expect(timerExpressions(ir), label).toEqual([ + '2026-08-01T09:00:00', + 'PT2H', + ]); + } + expect(handlerTriggerDef(rt.ir3, (d) => d?.kind === 'message')).toEqual({ + kind: 'message', + messageName: 'OrderCancelled', }); }); -describeSingleDiagram(rt); - -describeDiContainment( - rt, - () => { - const handlerIds = subProcess(rt.ir1, 'FulfilOrder') - .flowElements.filter((fe) => fe.kind === 'subProcess') - .map((fe) => fe.id); - expect(handlerIds.length).toBeGreaterThan(0); - return handlerIds; - }, - 'generated', -); - -describe('root sharing on the frozen .bpmn', () => { - it('the on signal handler, the emit, and the throw share one bpmn:Signal', () => { - const signals = [ - ...rt.frozenXml.matchAll(/ { + const signals = [ + ...rt.frozenXml.matchAll(/ ({ id, name })); + expect(signals).toEqual([{ id: expect.any(String), name: 'OrderFulfilled' }]); + for (const id of ['FulfilledStart', 'Notify', 'Announce']) { + expect(definitionRefOf(rt.frozenXml, id, 'signal'), id).toBe( + signals[0]!.id, ); - expect(definitionRefOf(rt.frozenXml, 'Notify', 'signal')).toBe(signalId); - expect(definitionRefOf(rt.frozenXml, 'Announce', 'signal')).toBe(signalId); - }); - - it('the one message handler gets the one bpmn:Message root', () => { - expect(messageRoots(rt.frozenXml).map((root) => root.name)).toEqual([ - 'OrderCancelled', - ]); - }); + } + expect(messageRoots(rt.frozenXml).map((root) => root.name)).toEqual([ + 'OrderCancelled', + ]); }); describeImportFirst( 'a handwritten .bpmn throwing one signal twice round-trips', IMPORT_FIRST_BPMN, (first) => { - it('recovers each trigger payload into the DSL surface', () => { + it('recovers each trigger payload into the DSL, both broadcasts on the one signal', () => { expect(first.dsl).toContain('emit signal Broadcast("ParcelDispatched")'); expect(first.dsl).toContain('throw signal Done("ParcelDispatched")'); expect(first.dsl).toContain('on timer(at: "2026-09-01T08:00:00") {'); expect(first.dsl).toContain('on condition(stockLevel < 5) {'); - }); - - it('both broadcasts resolve to the one signal name', () => { const signal = { kind: 'signal', signalName: 'ParcelDispatched' }; expect(definitionOf(first.ir, 'Broadcast')).toEqual(signal); expect(definitionOf(first.ir, 'Done')).toEqual(signal); diff --git a/tests/examples.health.test.ts b/tests/examples.health.test.ts index ae6220e..9502964 100644 --- a/tests/examples.health.test.ts +++ b/tests/examples.health.test.ts @@ -1,7 +1,5 @@ -// Directory-driven rather than a hand-kept list, so a new example is covered the -// moment it lands. The last block pins the lowered shape of the examples that -// double as walkthroughs: the round-trip block alone would pass an `await` -// lowered to a receive task just as cleanly. +// The last block pins lowered shapes the round trip alone would accept, such as +// an `await` lowered to a receive task. import { describe, it, expect } from 'vitest'; import { readdirSync, readFileSync } from 'node:fs'; @@ -29,111 +27,83 @@ if (EXAMPLES.length === 0) { const sourceOf = (file: string): string => readFileSync(resolve(PROCESSES_DIR, `${file}.bpmnscript`), 'utf-8'); -async function compile(file: string): Promise { - return irToXml(astToIr(await parseToAst(sourceOf(file)))); -} - describe('every deployable example', () => { - it.each(EXAMPLES)('%s opens validator-clean in the IDE', async (file) => { - const { diagnostics } = await validate( - readFileSync(resolve(PROCESSES_DIR, file), 'utf-8'), - ); - expect(diagnostics).toEqual([]); - }); -}); - -describe("every example round-trips through the tool's own output without a word", () => { - it.each(EXAMPLES)('%s', async (file) => { - const ir1 = astToIr( - await parseToAst(readFileSync(resolve(PROCESSES_DIR, file), 'utf-8')), - ); - - const xml1 = await irToXml(ir1); - const { ir: ir2, warnings: importWarnings } = await xmlToIr(xml1); - const { source: dslPrime, warnings: printWarnings } = irToDsl(ir2); - const ir3 = astToIr(await parseToAst(dslPrime)); - const { diagnostics } = await validate(dslPrime); - - // Mapped so a failure names the id and the message, not a range. - expect({ - importWarnings: importWarnings.map((w) => ({ - elementId: w.elementId, - category: w.category, - })), - printWarnings: printWarnings.map((w) => ({ - elementId: w.elementId, - category: w.category, - })), - dslPrimeDiagnostics: diagnostics.map((d) => ({ - severity: d.severity, - message: d.message, - })), - }).toEqual({ - importWarnings: [], - printWarnings: [], - dslPrimeDiagnostics: [], - }); - - expect(normalizeIr(ir3)).toEqual(normalizeIr(ir1)); - }); + it.each(EXAMPLES)( + '%s is validator-clean and round-trips through its own output without a word', + async (file) => { + const source = readFileSync(resolve(PROCESSES_DIR, file), 'utf-8'); + const ir1 = astToIr(await parseToAst(source)); + const { ir: ir2, warnings: importWarnings } = await xmlToIr( + await irToXml(ir1), + ); + const { source: dslPrime, warnings: printWarnings } = irToDsl(ir2); + const ir3 = astToIr(await parseToAst(dslPrime)); + + // Mapped so a failure names the id and the message, not a range. + const messages = async (dsl: string) => + (await validate(dsl)).diagnostics.map((d) => ({ + severity: d.severity, + message: d.message, + })); + const categories = (ws: { elementId?: string; category: string }[]) => + ws.map((w) => ({ elementId: w.elementId, category: w.category })); + expect({ + sourceDiagnostics: await messages(source), + importWarnings: categories(importWarnings), + printWarnings: categories(printWarnings), + dslPrimeDiagnostics: await messages(dslPrime), + }).toEqual({ + sourceDiagnostics: [], + importWarnings: [], + printWarnings: [], + dslPrimeDiagnostics: [], + }); + expect(normalizeIr(ir3)).toEqual(normalizeIr(ir1)); + }, + ); }); describe('construct shapes pinned only by a deployable example', () => { it('awaiting-confirmation desugars the await into an intermediateCatchEvent carrying the message definition', async () => { const ir = astToIr(await parseToAst(sourceOf('awaiting-confirmation'))); - const catchNode = ir.flowElements.find( - (el) => el.kind === 'intermediateCatchEvent', - ); - - expect(catchNode).toBeDefined(); - expect(catchNode).toMatchObject({ + expect( + ir.flowElements.find((el) => el.kind === 'intermediateCatchEvent'), + ).toMatchObject({ kind: 'intermediateCatchEvent', - eventDefinition: { - kind: 'message', - messageName: 'ConfirmationReceived', - }, + eventDefinition: { kind: 'message', messageName: 'ConfirmationReceived' }, }); }); - it('order-handling compiles to BPMN XML with the expected attached boundary events', async () => { - const xml = await compile('order-handling'); - - expect(xml).toContain( - '', - ); - expect(xml).toContain( - '', - ); - expect(xml).toContain( - '', - ); - expect(xml).toContain( - '', - ); - expect(xml).toContain( - '', - ); - }); - - it('charge-with-recovery compiles with an interrupting error boundary on the charge service task', async () => { - const xml = await compile('charge-with-recovery'); - - expect(xml).toContain( - '', - ); - }); - - it('compensating-saga compiles with the undo handler wired over the compensable subprocess', async () => { - const xml = await compile('compensating-saga'); - - expect(xml).toContain( - '', - ); - expect(xml).toMatch( - /[\s\S]*?/, - ); - expect(xml).toContain( - '', - ); + it.each([ + [ + 'order-handling', + 'the attached boundary events', + [ + '', + '', + '', + '', + '', + ], + ], + [ + 'charge-with-recovery', + 'an interrupting error boundary on the charge service task', + [ + '', + ], + ], + [ + 'compensating-saga', + 'the undo handler wired over the compensable subprocess', + [ + '', + /[\s\S]*?/, + '', + ], + ], + ])('%s compiles with %s', async (file, _what, fragments) => { + const xml = await irToXml(astToIr(await parseToAst(sourceOf(file)))); + for (const fragment of fragments) expect(xml).toMatch(fragment); }); }); diff --git a/tests/external-task.round-trip.test.ts b/tests/external-task.round-trip.test.ts index 08e19bb..257a1a4 100644 --- a/tests/external-task.round-trip.test.ts +++ b/tests/external-task.round-trip.test.ts @@ -1,15 +1,12 @@ -// The three topic-bound kinds each carry a different subset of the extras -// `parseExternalServiceTask` reads, so a priority, property or mapping that -// stops travelling on one tag, or one that appears from nowhere, fails the -// whole-object comparison below. +// Each topic-bound kind carries a different subset of the extras +// `parseExternalServiceTask` reads. -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; -import type { BpmnProcess, ServiceTaskBinding } from '@bpmn-script/transform'; +import type { ServiceTaskBinding } from '@bpmn-script/transform'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; -import { describeDiContainment } from './helpers/di-bounds.js'; -import { bindingOf, theOnly } from './helpers/ir-query.js'; +import { bindingOf } from './helpers/ir-query.js'; const rt = roundTripFixture('external-task', { dslPrimeFrom: 'generated', @@ -52,54 +49,32 @@ const EXTERNAL_BINDINGS: Record = { }, }; -describe('the frozen external-task binding contract', () => { - it('every extra survives at each hop and through import, on all three task kinds', () => { - const runs: (readonly [label: string, ir: BpmnProcess])[] = [ - ...rt.hops, - ['imported', rt.irFromImport], - ]; - for (const [label, ir] of runs) { - for (const id of Object.keys(EXTERNAL_BINDINGS)) { - expect( - bindingOf(ir, id), - `${id}.binding differs in ${label}`, - ).toStrictEqual(EXTERNAL_BINDINGS[id]); - } +it('keeps every extra on all three task kinds at each hop and through import, written on their tags', () => { + for (const [label, ir] of [ + ...rt.hops, + ['imported', rt.irFromImport], + ] as const) { + for (const [id, binding] of Object.entries(EXTERNAL_BINDINGS)) { + expect(bindingOf(ir, id), `${id} in ${label}`).toStrictEqual(binding); } - }); - - it('the frozen artifact writes the priority, properties and error mappings on their tags', () => { - expect(rt.frozenXml).toContain( - 'operaton:type="external" operaton:topic="charge-card" operaton:taskPriority="42"', - ); - expect(rt.frozenXml).toContain( - '\n' + - ' \n' + - ' \n' + - ' ', - ); - expect(rt.frozenXml).toContain( - '\n' + - ' ', - ); - expect(rt.frozenXml).toContain( - 'operaton:taskPriority="${amount > 1000 ? 90 : 10}"', - ); - }); - - it('the frozen artifact carries the two error roots in first-use order', () => { - const roots = [ - ...rt.frozenXml.matchAll(/ m[1]); - expect(roots).toEqual(['PAYMENT_DECLINED', 'GATEWAY_DOWN']); - }); + } + for (const written of [ + 'operaton:type="external" operaton:topic="charge-card" operaton:taskPriority="42"', + '\n' + + ' \n' + + ' \n' + + ' ', + '\n' + + ' ', + 'operaton:taskPriority="${amount > 1000 ? 90 : 10}"', + ]) { + expect(rt.frozenXml).toContain(written); + } + expect( + [...rt.frozenXml.matchAll(/ m[1], + ), + ).toEqual(['PAYMENT_DECLINED', 'GATEWAY_DOWN']); }); - -// The process-wide `on error(GATEWAY_DOWN)` handler lowers to a -// `triggeredByEvent` sub-process with a synthesized id, so it is located by -// what it is rather than by a name written in the fixture. -describeDiContainment(rt, () => [ - theOnly(rt.ir1, 'subProcess', (sp) => sp.triggeredByEvent === true).id, -]); diff --git a/tests/forms.round-trip.test.ts b/tests/forms.round-trip.test.ts index f43362a..9b7a1f8 100644 --- a/tests/forms.round-trip.test.ts +++ b/tests/forms.round-trip.test.ts @@ -1,15 +1,10 @@ -// Every extension a form field takes sits in one artifact, so a constraint, -// value, pattern or property that stops travelling in any direction, or one -// that appears from nowhere, fails the whole-array comparison below. - -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; import type { BpmnProcess, FormField } from '@bpmn-script/transform'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; import { elementById } from './helpers/ir-query.js'; -// Only a start event and a user task carry a form. function formFieldsOf( container: BpmnProcess, id: string, @@ -105,52 +100,36 @@ const CONFIRM_PAYMENT_FORM: FormField[] = [ }, ]; -describe('the frozen form-field contract', () => { - it('every field keeps its constraints, values, pattern and properties at every hop and through import', () => { - const runs: (readonly [label: string, ir: BpmnProcess])[] = [ - ...rt.hops, - ['imported', rt.irFromImport], - ]; - for (const [label, ir] of runs) { - expect( - formFieldsOf(ir, 'Applied'), - `Applied.formFields differs in ${label}`, - ).toStrictEqual(APPLIED_FORM); - expect( - formFieldsOf(ir, 'ConfirmPayment'), - `ConfirmPayment.formFields differs in ${label}`, - ).toStrictEqual(CONFIRM_PAYMENT_FORM); - } - }); - - it("the frozen 'plan' element writes its properties, validation and values in that order", () => { - expect(rt.frozenXml).toContain( - '\n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' ', - ); - }); - - it("the frozen 'fullName' element writes 'required' with no config", () => { - expect(rt.frozenXml).toContain( - '\n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' ', +it('keeps every field extension at every hop and through import, written properties, validation, values in that order', () => { + for (const [label, ir] of [ + ...rt.hops, + ['imported', rt.irFromImport], + ] as const) { + expect(formFieldsOf(ir, 'Applied'), label).toStrictEqual(APPLIED_FORM); + expect(formFieldsOf(ir, 'ConfirmPayment'), label).toStrictEqual( + CONFIRM_PAYMENT_FORM, ); - }); + } + expect(rt.frozenXml).toContain( + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' ', + ); + expect(rt.frozenXml).toContain( + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' ', + ); }); - -// The fixture has no nested container, so there is nothing for -// describeDiContainment to walk. diff --git a/tests/fuzz-regressions.test.ts b/tests/fuzz-regressions.test.ts index 296f3cd..895b413 100644 --- a/tests/fuzz-regressions.test.ts +++ b/tests/fuzz-regressions.test.ts @@ -1,26 +1,19 @@ -// Minimized shapes from fuzz finds, one row each: a fuzz run reaches a shape -// only while the generator still draws it, so each is pinned here. +// Minimized fuzz finds, pinned here since a fuzz run reaches a shape only +// while the generator still draws it. -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; import { xmlToIr, irToDsl } from '@bpmn-script/transform'; import type { BpmnProcess, FlowContainer } from '@bpmn-script/transform'; import { bpmnDoc } from './helpers/bpmn-doc.js'; -import { printDsl, roundTrip, validate } from './helpers/pipeline.js'; +import { printDsl, roundTrip, validationErrors } from './helpers/pipeline.js'; import { compareModels, modelSignature } from './helpers/model-equivalence.js'; import type { ModelComparison } from './helpers/model-equivalence.js'; -async function errorMessages(source: string) { - const { diagnostics } = await validate(source); - return diagnostics.filter((d) => d.severity === 1).map((d) => d.message); -} - -// Shapes whose print depends on the order the model lists its elements and -// flows in: a goto into a loop body enters the cycle a second time, past its -// head, and a chain or a start walked first claims what follows it. The third -// element pins the exact class the reversed-order reread compares back to -// (see `compareModels`); a row with none keeps the model identical. +// Shapes whose print depends on the model's element and flow order. The third +// column is the `compareModels` class of the reversed-order reread ('same' if +// absent). const ORDER_ROWS: [ title: string, source: string, @@ -2320,34 +2313,24 @@ const ROWS: [title: string, source: string, comparison?: ModelComparison][] = [ goto D }`, ], - ...ORDER_ROWS, ]; -describe('every regression input is accepted by the validator', () => { - it.each(ROWS.map(([title, source]) => [title, source] as const))( - '%s', - async (_title, source) => { - expect(await errorMessages(source)).toEqual([]); - }, - ); -}); - -describe('round-trip regressions from minimized fuzz finds', () => { - for (const [title, source] of ROWS) { - it(title, async () => { - const first = await roundTrip(source); - expect(await errorMessages(first.dsl)).toEqual([]); - expect(modelSignature(first.ir3)).toEqual(modelSignature(first.ir1)); +// Valid source prints valid, the same model, and stable on the next pass. +async function expectStableRoundTrip(source: string) { + expect(await validationErrors(source)).toEqual([]); + const first = await roundTrip(source); + expect(await validationErrors(first.dsl)).toEqual([]); + expect(modelSignature(first.ir3)).toEqual(modelSignature(first.ir1)); + expect((await roundTrip(first.dsl)).dsl).toBe(first.dsl); + return first; +} - const second = await roundTrip(first.dsl); - expect(second.dsl).toBe(first.dsl); - }); - } +it.each(ROWS)('%s', async (_title, source) => { + await expectStableRoundTrip(source); }); -// A model from another tool lists its elements and flows in any order, so -// which chain, start or loop entry the printer takes first must not depend on -// it, nor move once the print is read back in statement order. +// A model from another tool lists its elements and flows in any order, so the +// print must hold with the order reversed too. function reversed(c: C): C { return { ...c, @@ -2358,22 +2341,18 @@ function reversed(c: C): C { }; } -describe('an order-dependent shape prints the same model, stable on the next pass, when the model lists its elements in reverse', () => { - for (const [title, source, comparison = 'same'] of ORDER_ROWS) { - it(title, async () => { - const { ir1, ir2 } = await roundTrip(source); - const dsl = printDsl(reversed(ir2)); - expect(await errorMessages(dsl)).toEqual([]); - const { ir1: reread, dsl: next } = await roundTrip(dsl); - expect(compareModels(ir1, reread)).toBe(comparison); - expect(next).toBe(dsl); - }); - } -}); +it.each(ORDER_ROWS)( + '%s, in model order and reversed', + async (_title, source, comparison = 'same') => { + const { ir1, ir2 } = await expectStableRoundTrip(source); + const dsl = printDsl(reversed(ir2)); + expect(await validationErrors(dsl)).toEqual([]); + const { ir1: reread, dsl: next } = await roundTrip(dsl); + expect(compareModels(ir1, reread)).toBe(comparison); + expect(next).toBe(dsl); + }, +); -// A plain reprint of the round-tripped IR, and the same with element and flow -// order reversed (standing in for a model authored by another tool); a row -// picks whichever mechanism its regression needs. async function plain(source: string): Promise { return (await roundTrip(source)).ir2; } @@ -2381,18 +2360,16 @@ async function reversedOrder(source: string): Promise { return reversed((await roundTrip(source)).ir2); } -// The printer writes no boundary id, so an authored one is minted afresh on -// the next compile: printed first, it would take its minted sibling's base -// id. No script source produces that id collision, so the XML is mutated -// after a first round trip instead. +// The printer writes no boundary id, so an authored one printed first would +// take its minted sibling's base id on the next compile. No source produces +// that collision, so the XML is mutated instead. async function mintedSiblingOrder(source: string): Promise { const { xml } = await roundTrip(source); const { ir } = await xmlToIr(xml.replaceAll('Boundary_A_signal_2', 'MyB')); return reversed(ir); } -// Two authored ids share no minted rank to order by, so nothing but the -// model's own listing order can settle a print between them. +// Two authored ids share no minted rank, so only listing order settles them. async function modelListingOrder(source: string): Promise { const { xml } = await roundTrip(source); const { ir } = await xmlToIr( @@ -2403,17 +2380,15 @@ async function modelListingOrder(source: string): Promise { return ir; } -// Every row's print keeps every path the source keeps too, so a check that -// only compares reachable steps and conditions (as `ROWS`'s round-trip test -// does) would pass a misplaced print as readily as the right one; only the -// literal print, revalidated through the compiler, pins the regression. +// A misplaced print keeps the same paths, so the model comparison `ROWS` uses +// would pass it; only the literal print pins these. `expected` defaults to the +// source. const PRINTS_AS_WRITTEN: [ title: string, source: string, produce: (source: string) => Promise, + expected?: string, ][] = [ - // The join spelled as an extra `else { goto After }` reads the same paths - // reversed too. [ 'two sibling branches that each jump back before their if keep the join after the if in reverse order', `process p { @@ -2437,9 +2412,8 @@ const PRINTS_AS_WRITTEN: [ `, reversedOrder, ], - // The jump is the split's first conditioned route, the one a `do` test - // needs, but the split sits inside a block opened after the jump target, - // so the `while` would close the loop inside that block. + // The split sits in a block opened after the jump target, so a loop test + // there would close the loop inside that block. [ 'a jump back above the enclosing parallel, tested first, stays a goto inside its branch', `process p { @@ -2521,9 +2495,8 @@ const PRINTS_AS_WRITTEN: [ `, modelListingOrder, ], - // `Fail` and `X` are both authored, so inlining `step X throw error Fail(E)` - // re-derives the same ids either way: the authored-tail rule's reason to - // hoist a chain behind the block does not apply here. + // Both ids are authored, so the authored-tail rule's reason to hoist does + // not apply. [ 'a hoisted chain of named steps stays in its branch', `process p { @@ -2540,9 +2513,7 @@ const PRINTS_AS_WRITTEN: [ `, plain, ], - // `B` is also entered by the ordinary flow after the outer `if`, through - // the nested `if (c)`, so inlining `goto B`'s target there would print `B` - // before the steps that flow into it the ordinary way. + // Inlining `B` at the goto would print it before its ordinary predecessors. [ 'a goto stays a goto when the flow after its block also reaches the same target', `process p { @@ -2561,10 +2532,8 @@ const PRINTS_AS_WRITTEN: [ `, plain, ], - // A step ahead of the boundary handler's own `goto` moves the re-entering - // predecessor from the boundary event itself to a step it dominates; the - // walk must still trace that step back to the boundary rather than treating - // it as ordinary flow. + // The re-entering predecessor is a step the boundary dominates, which must + // still trace back to the boundary. [ "a boundary handler's own goto does not push a branch entry out of the branch when a step comes before it", `process claim-review { @@ -2582,21 +2551,9 @@ const PRINTS_AS_WRITTEN: [ `, plain, ], -]; - -describe('a printed regression re-parses and revalidates as written', () => { - it.each(PRINTS_AS_WRITTEN)('%s', async (_title, source, produce) => { - const printed = irToDsl(await produce(source)); - expect(printed.warnings).toEqual([]); - expect(printed.source).toBe(source); - expect(await errorMessages(printed.source)).toEqual([]); - }); -}); - -// Printed as a jump back, this loop reads the same paths, so the row contract -// alone would pass it: the print itself is the regression. -it('a boundary handler jumping into a do-while body leaves the loop printed as a do-while', async () => { - const source = `process p { + [ + 'a boundary handler jumping into a do-while body leaves the loop printed as a do-while', + `process p { var u: any do { user A @@ -2607,76 +2564,14 @@ it('a boundary handler jumping into a do-while body leaves the loop printed as a goto B } } -`; - const { dsl } = await roundTrip(source); - expect(dsl).toBe(source); - expect(await errorMessages(dsl)).toEqual([]); -}); - -// The source itself is refused (the validator counts the `emit link` as -// ending the branch even with its `await link` right behind it, so `user -// After` reads as unreachable there), so it is compiled without validation; -// only the print, which sinks `user After` -// into the branch that carries the live route, is checked. -it('an if whose only live branch crosses a link pair sinks the step after it into that branch', async () => { - const source = `process p { - var c: any - var x: any - if (c) { - if (x) { - emit link("L") - await link("L") - await { - message("M") { - } - signal("S") { - } - } - } else { - end E - } - user After - } else { - end Done - } -}`; - const { dsl } = await roundTrip(source); - expect(await errorMessages(dsl)).toEqual([]); - const second = await roundTrip(dsl); - expect(second.dsl).toBe(dsl); -}); - -// `goto X` after the unreachable `user Z` (dead code behind `end E`) is -// itself unreachable, so it must not count as a predecessor entering `X` -// from outside the branch. The source is refused for that dead code, not -// for the branch placement under test, so only the print is checked here. -it('an unreachable predecessor of a branch entry does not push its chain out of the branch', async () => { - const source = `process p { - var c: any - if (c) { - user X - if (c) { - end E1 - } - } else { - user W - } - user Y - end E - user Z - goto X -} -`; - const { dsl } = await roundTrip(source); - expect(dsl).toBe(source); -}); - -// `wallStaysOff`'s `postDominates(entry, join)` branch keeps a nested if -// branch's entry from walling itself off when the entry is also every route -// out of the join: without it, a later `goto A` from past the outer if hoists -// `A` behind the implicit end instead of leaving it inside the nested if. -it('a nested if branch a later goto jumps back into stays inside the if, not hoisted behind the implicit end', async () => { - const source = `process p { +`, + plain, + ], + // Without `wallStaysOff`'s `postDominates(entry, join)` case, the later + // `goto A` hoists `A` behind the implicit end. + [ + 'a nested if branch a later goto jumps back into stays inside the if, not hoisted behind the implicit end', + `process p { var c: any var d: any if (c) { @@ -2689,19 +2584,36 @@ it('a nested if branch a later goto jumps back into stays inside the if, not hoi user N user Z } -`; - const { dsl } = await roundTrip(source); - expect(dsl).toBe(source); - expect(await errorMessages(dsl)).toEqual([]); -}); - -// A nested do-while has two back-edge candidates sharing its head (the inner -// do's own test and the outer if's `goto B`), so `ownLoopTest` cannot name a -// single owner for the cycle; gating `headsEnteredPast` on that owner instead -// of the CFG's rank would drop the cycle there and let the walk pick the -// other if branch's flow to hold the loop instead. -it('an if whose branches both close on the same nested do-while keeps the loop in the else branch', async () => { - const source = `process p { +`, + plain, + ], + // Re-entry past the inner test gateway must not stop it dominating the outer. + [ + 'a boundary handler re-entering the outer body past a nested do-while leaves both loops printed as do-while', + `process p { + var u: any + var v: any + do { + do { + user A + user B + } while (v) + user C + } while (u) + user E + on E: signal("R") { + goto C + } +} +`, + plain, + ], + // The inner test and the outer `goto B` both close the head, so gating + // `headsEnteredPast` on `ownLoopTest`'s single owner instead of the CFG's + // rank would let the other branch hold the loop. + [ + 'an if whose branches both close on the same nested do-while keeps the loop in the else branch', + `process p { var d: any var e: any var x: any @@ -2722,9 +2634,8 @@ it('an if whose branches both close on the same nested do-while keeps the loop i goto B } } -`; - const { dsl } = await roundTrip(source); - expect(dsl).toBe( +`, + plain, 'process p {\n' + ' var d: any\n' + ' var e: any\n' + @@ -2746,41 +2657,138 @@ it('an if whose branches both close on the same nested do-while keeps the loop i ' } while (e)\n' + ' }\n' + '}\n', - ); - expect(await errorMessages(dsl)).toEqual([]); + ], + // Deciding whether the first branch's chain prints inline must stop at nodes + // its entry does not loop-dominate, or the `goto Charge7` exit pulls + // `Release19` in. + [ + "a goto between an if-chain's branches does not pull the target branch's chain into the other branch's inline check", + `process p { + var order: any + var urgent: any + var approved: any + user Charge7 + if (order['div']) { + user InFirst + await message Wait14("OrderReceived") { + } + parallel { + if ((order == null)) { + call Call15(process: "invoice-approval") + emit signal("Ready") + } + if (urgent) { + service check-stock16(class: "org.acme.Audit") + goto Charge7 + } + } + throw signal Fail18("StockLow") + } else if (approved) { + service Release19(class: "com.example.orders.Ship") + goto Wait14 + } else { + service Charge(class: "com.example.Delegate") + } + end Done21 +} +`, + plain, + `process p { + var approved: any + var order: any + var urgent: any + user Charge7 + if (order["div"]) { + user InFirst + await message Wait14("OrderReceived") + parallel { + if ((order == null)) { + call Call15(process: "invoice-approval") + emit signal("Ready") + } + if (urgent) { + service check-stock16(class: "org.acme.Audit") + goto Charge7 + } + } + throw signal Fail18("StockLow") + } else if (approved) { + service Release19(class: "com.example.orders.Ship") + goto Wait14 + } else { + service Charge(class: "com.example.Delegate") + } + end Done21 +} +`, + ], +]; + +it.each(PRINTS_AS_WRITTEN)( + '%s', + async (_title, source, produce, expected = source) => { + const printed = irToDsl(await produce(source)); + expect(printed.warnings).toEqual([]); + expect(printed.source).toBe(expected); + expect(await validationErrors(printed.source)).toEqual([]); + }, +); + +// The validator refuses the source (it counts `emit link` as ending the branch, +// so `user After` reads as unreachable); only the print is checked. +it('an if whose only live branch crosses a link pair sinks the step after it into that branch', async () => { + const source = `process p { + var c: any + var x: any + if (c) { + if (x) { + emit link("L") + await link("L") + await { + message("M") { + } + signal("S") { + } + } + } else { + end E + } + user After + } else { + end Done + } +}`; + const { dsl } = await roundTrip(source); + expect(await validationErrors(dsl)).toEqual([]); + const second = await roundTrip(dsl); + expect(second.dsl).toBe(dsl); }); -// Both loops read the same paths whether nested or flattened to if/goto, so -// the row contract alone would pass this too: the print itself is the -// regression. The handler re-enters the outer body past the inner loop's -// test gateway, which must not stop that gateway from dominating the outer -// one's. -it('a boundary handler re-entering the outer body past a nested do-while leaves both loops printed as do-while', async () => { +// The unreachable `goto X` must not count as entering `X` from outside the +// branch. The source is refused for that dead code, so only the print is checked. +it('an unreachable predecessor of a branch entry does not push its chain out of the branch', async () => { const source = `process p { - var u: any - var v: any - do { - do { - user A - user B - } while (v) - user C - } while (u) - user E - on E: signal("R") { - goto C + var c: any + if (c) { + user X + if (c) { + end E1 + } + } else { + user W } + user Y + end E + user Z + goto X } `; const { dsl } = await roundTrip(source); expect(dsl).toBe(source); - expect(await errorMessages(dsl)).toEqual([]); }); -// The generator cannot draw one gateway that both merges and splits, so this -// shape comes in as modeled XML: J joins two of F's routes and forks again. -// The script has no mixed gateway, so J prints as a join and a fork, which is -// why the path signature, counting gateways per route, is not compared. +// J joins two of F's routes and forks again; the script has no mixed gateway, +// so J prints as a join and a fork. const MIXED_JOIN = bpmnDoc( [ '', @@ -2842,21 +2850,24 @@ it('a gateway that merges a fork with an ending branch and splits again prints a } } `); - expect(await errorMessages(dsl1)).toEqual([]); + expect(await validationErrors(dsl1)).toEqual([]); const second = await roundTrip(dsl1); expect(second.dsl).toBe(dsl1); }); -// No script source lowers to this: the implicit start and a conditional start -// both enter T, which runs into the implicit end. A body opening with `start B` -// would have no implicit start, so the print must jump to T first. +const conditionalStart = (id: string, variable: string): string => + `` + + '' + + `\${${variable}}` + + ''; + +// The implicit start and a conditional start both enter T. A body opening with +// `start B` would have no implicit start, so the print jumps to T first. it('an implicit start and a conditional start entering the same step keep both starts and the implicit end', async () => { const xml = bpmnDoc( [ '', - '' + - '${c}' + - '', + conditionalStart('B', 'c'), '', '', '', @@ -2873,28 +2884,20 @@ it('an implicit start and a conditional start entering the same step keep both s user T } `); - expect(await errorMessages(dsl)).toEqual([]); + expect(await validationErrors(dsl)).toEqual([]); const { ir1: reread, dsl: next } = await roundTrip(dsl); expect(compareModels(ir, reread)).toBe('restructured'); expect(next).toBe(dsl); }); -// A link throw's catch prints right behind it with no `goto` between them -// (`emit link` / `await link`), so the flow the throw carried still runs on -// through the catch's own routes. `start C` enters the merge that chain -// reaches and `start B` enters the step past it, so both need their own -// `goto` closing that still-live flow first, the same as a start entering a -// step an ordinary fall-through chain already reaches. +// The catch prints right behind its link throw, so the flow still runs on +// through it; each start needs a `goto` closing that live flow first. it('a start entering a step reached only through a printed link hop keeps the flow closed with a goto', async () => { const xml = bpmnDoc( [ '', - '' + - '${c}' + - '', - '' + - '${d}' + - '', + conditionalStart('B', 'c'), + conditionalStart('C', 'd'), '', '', '', @@ -2925,15 +2928,12 @@ it('a start entering a step reached only through a printed link hop keeps the fl user T } `); - expect(await errorMessages(dsl)).toEqual([]); + expect(await validationErrors(dsl)).toEqual([]); const { ir1: reread, dsl: dsl2 } = await roundTrip(dsl); expect(compareModels(ir, reread)).toBe('restructured'); - // The round trip compiles the merge gateway away: three plain flows land on - // `T` directly, so `Ca`'s own chain now reaches the elided end on its own. - // The tail rule still has to treat that end as displaced, since `B` and `C` - // reach it too, or this second print pushes `end EndEvent_p` off the tail - // and prints it as a name the compiler refuses. - expect(await errorMessages(dsl2)).toEqual([]); + // The merge is compiled away, so `Ca` reaches the elided end on its own; the + // end must still count as displaced, or it prints under a refused name. + expect(await validationErrors(dsl2)).toEqual([]); expect(dsl2).toBe(`process p { var c: any var d: any @@ -2951,18 +2951,13 @@ it('a start entering a step reached only through a printed link hop keeps the fl expect(dsl3).toBe(dsl2); }); -// The elided end's own branch does nothing once the walk reaches the end -// itself: pushing a `goto` there instead would name a target the deferred -// `end` print has not reached yet, so the leftover edge into it degrades to -// the unstructured-region marker rather than a wrong jump. The deferred `end` -// still lands ahead of `start B` in the printed order. +// A `goto` to the deferred end would name a target not yet printed, so the +// leftover edge degrades to the marker rather than a wrong jump. it('a conditional start entering the elided end keeps the end printed before the start', async () => { const xml = bpmnDoc( [ '', - '' + - '${c}' + - '', + conditionalStart('B', 'c'), '', '', '', @@ -2999,73 +2994,3 @@ it('a conditional start entering the elided end keeps the end printed before the }, ]); }); - -// Minimized from a fuzz find: the `else if` branch's own `service Release19 -// goto Wait14` chain is walked while deciding whether the first branch's own -// chain (`InFirst`, `Wait14`, the `parallel`, `Fail18`) can print inline. That -// walk must stop at every node the first branch's entry does not -// loop-dominate, or it follows the `parallel`'s `goto Charge7` exit past the -// nodes the branch owns and reads `Release19` as part of it. The row contract -// alone would pass the misplaced print too, since the model stays equivalent -// either way; the print itself is the regression. -it("a goto between an if-chain's branches does not pull the target branch's chain into the other branch's inline check", async () => { - const source = `process p { - var order: any - var urgent: any - var approved: any - user Charge7 - if (order['div']) { - user InFirst - await message Wait14("OrderReceived") { - } - parallel { - if ((order == null)) { - call Call15(process: "invoice-approval") - emit signal("Ready") - } - if (urgent) { - service check-stock16(class: "org.acme.Audit") - goto Charge7 - } - } - throw signal Fail18("StockLow") - } else if (approved) { - service Release19(class: "com.example.orders.Ship") - goto Wait14 - } else { - service Charge(class: "com.example.Delegate") - } - end Done21 -} -`; - const { dsl } = await roundTrip(source); - expect(await errorMessages(dsl)).toEqual([]); - expect(dsl).toBe(`process p { - var approved: any - var order: any - var urgent: any - user Charge7 - if (order["div"]) { - user InFirst - await message Wait14("OrderReceived") - parallel { - if ((order == null)) { - call Call15(process: "invoice-approval") - emit signal("Ready") - } - if (urgent) { - service check-stock16(class: "org.acme.Audit") - goto Charge7 - } - } - throw signal Fail18("StockLow") - } else if (approved) { - service Release19(class: "com.example.orders.Ship") - goto Wait14 - } else { - service Charge(class: "com.example.Delegate") - } - end Done21 -} -`); -}); diff --git a/tests/fuzz.test.ts b/tests/fuzz.test.ts index 83241f2..31925e1 100644 --- a/tests/fuzz.test.ts +++ b/tests/fuzz.test.ts @@ -1,46 +1,13 @@ -// Round-trip fuzz harness over generated programs. +// Round-trip fuzz: per seed, generate a program, then parse -> astToIr -> irToXml +// -> xmlToIr -> irToDsl -> revalidate -> astToIr, compare, and print a second time. +// A valid seed fails on a FAIL_CLASSES class, any print warning, or an unstable +// second print. // -// Per seed: generate a program, validate it, then push its source through -// parse -> astToIr -> irToXml -> xmlToIr -> irToDsl -> validate -> astToIr, -// each stage inside a try, with the outcome classified (`runPipeline`, -// `symptomOf`). A validator-clean seed fails the oracle when its class is -// `crash`, `timeout`, `import-refused`, `dsl-invalid`, `silent-change` or -// `warned-restructuring`, when its first print carries any print warning, or -// when its second print differs from its first (`failingSeeds`, one -// `toEqual([])` assertion over every one of those). A seed counts as clean -// when `compareModels` (`./helpers/model-equivalence.ts`) reports `same`, -// `canonical` (differs only by JUEL quote style or a minted coordinate id) -// or `restructured` (a documented hoist, backward `goto` or branch sinking -// that keeps every step's content and the routes between them); only -// `changed` can fail. -// -// Environment: FUZZ_N (programs, default 200), FUZZ_SEED (first seed, -// default 1; program i uses FUZZ_SEED + i), FUZZ_OUT (output directory; -// when set, writes `results.jsonl`, `summary.json` and minimized programs -// under `min/` and runs minimization; when unset, writes nothing and skips -// minimization), FUZZ_SHUFFLE (`reverse`, or a number that seeds a -// permutation; unset runs in compiler order). The same FUZZ_SEED and FUZZ_N -// reproduce the same corpus on any tree; minimization re-applies the same -// FUZZ_SHUFFLE mode to every candidate. -// -// FUZZ_SHUFFLE reorders the imported model's `flowElements` and -// `sequenceFlows`, in the process and every nested container, before the -// first print: as another BPMN tool might list them. A gateway's own -// outgoing flows keep their relative order within that reordering, because -// Operaton evaluates a gateway's conditioned routes in document order, so -// shuffling them would change which route the model actually takes, not -// just how the printer reads it. Keeping route order fixed removes that -// artifact instead of asking the comparison to reconstruct -// engine-observable order semantics. -// -// Another tool can still list a gateway's routes in any order, so a seed that -// passes in that mode is printed a second time with every flow reordered, -// routes included (`runSeed`). That print is a different model, so it skips -// the comparison and fails only on the other conditions: a crash or refusal, -// output the validator refuses, a print warning, or a second pass that -// differs. Its symptoms carry a `routes|` prefix. -// -// Full run: FUZZ_N=2000 FUZZ_OUT= npm test --workspace tests -- fuzz.test.ts +// FUZZ_N (default 200), FUZZ_SEED (default 1), FUZZ_OUT (writes results.jsonl, +// summary.json and minimized programs to min/), FUZZ_SHUFFLE (`reverse` or a +// numeric seed) reorders the imported model before printing. A gateway's own +// outgoing flows keep their order, since Operaton evaluates conditions in +// document order; passing seeds get a second `routes|` run that shuffles those too. import { describe, it, expect } from 'vitest'; import { appendFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; @@ -61,14 +28,17 @@ import type { FlowElement, } from '@bpmn-script/transform'; import type { Model } from '@bpmn-script/language'; +import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver-types'; import { validate } from './helpers/pipeline.js'; import { normalizeIr } from './helpers/normalize-ir.js'; import { compareModels, + stable, type ModelComparison, } from './helpers/model-equivalence.js'; import { + countStatements, generateProgram, minimize, renderProgram, @@ -98,7 +68,6 @@ function parseShuffleMode(raw: string | undefined): ShuffleMode | undefined { const SHUFFLE = parseShuffleMode(process.env.FUZZ_SHUFFLE); -// A seeded LCG, so (programSeed, mode) always draws the same permutation. function permutationRng(programSeed: number, mode: number): () => number { let state = Math.abs(programSeed * 2654435761 + mode) % 2147483647 || 1; return () => { @@ -116,12 +85,7 @@ function permuted(items: readonly T[], rand: () => number): T[] { return a; } -// Reorders a flow list the way another BPMN tool might list it, keeping each -// source's own outgoing flows in their original relative order: Operaton -// evaluates a gateway's conditioned routes in document order, so shuffling -// them changes which route the model takes, not just how the printer reads -// it (see the header comment). Only the order of the (source, group) blocks -// shuffles. +// Shuffles per-source blocks only; a gateway's routes keep their order. function reorderFlows( flows: readonly F[], mode: ShuffleMode, @@ -143,11 +107,7 @@ function reorderFlows( return reorderedSources.flatMap((s) => bySource.get(s)!); } -// Reorders `flowElements` and `sequenceFlows` of `container` and every -// nested sub-process body before the first print, a gateway's own routes -// too when `routes` is set. One `rand` stream is shared across the whole -// walk, so nested containers of the same size still draw distinct -// permutations. +// One shared `rand`, so same-size nested containers draw distinct permutations. function reorderContainer( container: T, mode: ShuffleMode, @@ -175,16 +135,7 @@ function reorderContainer( return walk(container) as T; } -type Cls = - | 'crash' - | 'timeout' - | 'import-refused' - | 'dsl-invalid' - | 'silent-change' - | 'warned-restructuring' - | 'clean'; - -const CLASS_ORDER: Cls[] = [ +const CLASS_ORDER = [ 'crash', 'timeout', 'import-refused', @@ -192,20 +143,10 @@ const CLASS_ORDER: Cls[] = [ 'silent-change', 'warned-restructuring', 'clean', -]; +] as const; +type Cls = (typeof CLASS_ORDER)[number]; -// A validator-clean seed fails on one of these classes regardless of the -// second pass, when its first print carries any print warning (which -// includes every `warned-restructuring` seed, since that class requires at -// least one), or when the second print differs from the first. -const FAIL_CLASSES = new Set([ - 'crash', - 'timeout', - 'import-refused', - 'dsl-invalid', - 'silent-change', - 'warned-restructuring', -]); +const FAIL_CLASSES = new Set(CLASS_ORDER.filter((c) => c !== 'clean')); interface Outcome { cls: Cls; @@ -239,8 +180,7 @@ class TimeoutError extends Error { } } -// Bounds an async stage. A stage that never yields to the event loop cannot be -// interrupted from inside the process; the bound fires as soon as it does. +// Fires only once the stage yields; a synchronous hang cannot be interrupted. async function bounded(stage: string, fn: () => Promise): Promise { let timer: ReturnType | undefined; const clock = new Promise((_, reject) => { @@ -253,26 +193,6 @@ async function bounded(stage: string, fn: () => Promise): Promise { } } -function isRefusal(e: unknown): e is UnsupportedConstructError { - return e instanceof UnsupportedConstructError; -} - -// `toEqual` semantics on a string: sorted keys, `undefined` dropped. -function stable(x: unknown): string { - return JSON.stringify(x, (_k, v) => { - if (v && typeof v === 'object' && !Array.isArray(v)) { - const o = v as Record; - return Object.fromEntries( - Object.keys(o) - .sort() - .filter((k) => o[k] !== undefined) - .map((k) => [k, o[k]]), - ); - } - return v; - }); -} - // Element lists keyed by id so a diff names elements, not positions. function keyed(v: unknown): unknown { if (Array.isArray(v)) { @@ -342,8 +262,7 @@ function short(x: unknown): string { return s.length > 160 ? s.slice(0, 157) + '...' : s; } -// The generator's four process names and every counter, so one symptom is one -// key whatever process and statement numbers a seed drew. +// Blanks process names and numbers so one symptom is one key across seeds. const PROCESS_NAME_PATTERN = new RegExp(PROCESS_NAMES.join('|'), 'g'); function normalizeMessage(m: string): string { @@ -354,8 +273,6 @@ function normalizeMessage(m: string): string { .slice(0, 200); } -// The leaves a structural diff touched, digits and process names blanked so a -// moved coordinate or a different statement count does not split the group. function diffShape(diff: DiffEntry[]): string { const leaf = (path: string): string => { const segs = path.replace(/^\(positional\) /, '').split('.'); @@ -392,14 +309,12 @@ async function parseModel(text: string): Promise<{ dispose: () => Promise; }> { const result = await validate(text); - const messageOf = (m: string | { value: string }): string => - typeof m === 'string' ? m : m.value; const errors = result.diagnostics - .filter((d) => d.severity === 1) - .map((d) => messageOf(d.message)); + .filter((d) => d.severity === DiagnosticSeverity.Error) + .map((d) => Diagnostic.getMessageString(d)); const warnings = result.diagnostics - .filter((d) => d.severity === 2) - .map((d) => messageOf(d.message)); + .filter((d) => d.severity === DiagnosticSeverity.Warning) + .map((d) => Diagnostic.getMessageString(d)); const dispose = (result as { dispose?: () => Promise }).dispose ?? (async () => {}); return { @@ -429,8 +344,6 @@ function fails(o: Outcome): boolean { ); } -// The route-order-preserving run, and in FUZZ_SHUFFLE mode the fully -// reordered one when the first passes (see the header comment). async function runSeed(text: string, programSeed: number): Promise { const o = await runPipeline(text, programSeed, false); if (SHUFFLE === undefined || fails(o)) return o; @@ -464,34 +377,41 @@ async function runPipeline( return o; }; let stage = 'compile'; - try { - const ir1 = await compile(text); - - stage = 'serialize'; - let xml1: string; + // A layout failure still carries the complete, diagram-less model. + const serialize = async (ir: BpmnProcess): Promise => { try { - xml1 = await bounded(stage, () => irToXml(ir1)); + return await bounded(stage, () => irToXml(ir)); } catch (e) { if (!(e instanceof LayoutError)) throw e; - xml1 = e.xml; o.layoutFallback = true; + return e.xml; } - - stage = 'import'; - let imported: Awaited>; + }; + const importXml = async ( + xml: string, + ): Promise> | undefined> => { try { - imported = await bounded(stage, () => xmlToIr(xml1)); + return await bounded(stage, () => xmlToIr(xml)); } catch (e) { - if (isRefusal(e)) { - return finish({ - cls: 'import-refused', - stage, - message: e.message, - errorName: e.name, - }); - } - throw e; + if (!(e instanceof UnsupportedConstructError)) throw e; + finish({ + cls: 'import-refused', + stage, + message: e.message, + errorName: e.name, + }); + return undefined; } + }; + try { + const ir1 = await compile(text); + + stage = 'serialize'; + const xml1 = await serialize(ir1); + + stage = 'import'; + const imported = await importXml(xml1); + if (imported === undefined) return o; o.importWarnings = imported.warnings.length; o.importWarningCategories = [ ...new Set(imported.warnings.map((w) => w.category)), @@ -536,30 +456,11 @@ async function runPipeline( const n3 = normalizeIr(ir3); stage = 'serialize2'; - let xml2: string; - try { - xml2 = await bounded(stage, () => irToXml(ir3)); - } catch (e) { - if (!(e instanceof LayoutError)) throw e; - xml2 = e.xml; - o.layoutFallback = true; - } + const xml2 = await serialize(ir3); stage = 'import2'; - let imported2: Awaited>; - try { - imported2 = await bounded(stage, () => xmlToIr(xml2)); - } catch (e) { - if (isRefusal(e)) { - return finish({ - cls: 'import-refused', - stage, - message: e.message, - errorName: e.name, - }); - } - throw e; - } + const imported2 = await importXml(xml2); + if (imported2 === undefined) return o; stage = 'print2'; const dsl2 = irToDsl(imported2.ir).source; @@ -571,9 +472,8 @@ async function runPipeline( stage = 'compare'; const comparison = compareModels(ir1, ir3); if (comparison === 'changed') { - // Two flows can share a canonical key after normalization (a conditioned - // and a fallback flow between one fork and join), which `keyed` collapses; - // the positional diff then names the difference. + // `keyed` collapses flows sharing a canonical key (conditioned + fallback + // between one fork and join); fall back to a positional diff. let entries = diffPaths(keyed(n1), keyed(n3)); if (entries.length === 0) { entries = diffPaths(n1, n3).map((d) => ({ @@ -621,16 +521,12 @@ function symptomOf(o: Outcome): string { case 'warned-restructuring': return `warned-restructuring|${o.printWarningCategories.join(',')}`; case 'clean': - // A clean comparison can still carry a print warning (the model did - // not change, but the print warned about it); that keeps the seed out - // of the ordinary `clean` bucket so it is minimized and reported. if (o.printWarningCategories.length > 0) return `clean-warned|${o.printWarningCategories.join(',')}`; return o.secondPassStable === false ? 'clean-unstable' : 'clean'; } } -// Whether a minimization candidate still shows the failure being reduced. function sameFailure(original: Outcome, candidate: Outcome): boolean { if ( candidate.cls !== original.cls || @@ -666,23 +562,11 @@ interface SeedRow { outcome?: Outcome; } -function countStatements(p: Program): number { - let n = 0; - const walk = (body: Program['body']): void => { - for (const s of body) { - n++; - if ('body' in s) walk(s.body); - if (s.k === 'if') { - walk(s.then); - for (const e of s.elseIfs) walk(e.body); - if (s.else) walk(s.else); - } - if (s.k === 'parallel' || s.k === 'race') - for (const b of s.branches) walk(b.body); - } - }; - walk(p.body); - return n; +type Tally = Record; + +function tally(t: Tally, key: string, seed: number): void { + t[key] ??= { count: 0, exampleSeed: seed }; + t[key].count++; } function gitHead(): string { @@ -699,7 +583,6 @@ describe('round-trip fuzz', () => { { timeout: 6 * 60 * 60 * 1000 }, async () => { if (OUT !== undefined) { - // A stale minimized program from an earlier run would misattribute a symptom. rmSync(join(OUT, 'min'), { recursive: true, force: true }); mkdirSync(join(OUT, 'min'), { recursive: true }); writeFileSync(join(OUT, 'results.jsonl'), ''); @@ -741,7 +624,6 @@ describe('round-trip fuzz', () => { } } - // ---- aggregate ---- const valid = rows.filter((r) => r.valid); const classes = Object.fromEntries( CLASS_ORDER.map((c) => [c, 0]), @@ -762,14 +644,8 @@ describe('round-trip fuzz', () => { }; const layoutFallbackSeeds: number[] = []; const cleanButUnstable: number[] = []; - const importWarningCategories: Record< - string, - { count: number; exampleSeed: number } - > = {}; - const importWarningMessages: Record< - string, - { count: number; exampleSeed: number } - > = {}; + const importWarningCategories: Tally = {}; + const importWarningMessages: Tally = {}; const unstable: { seed: number; cls: Cls; @@ -777,28 +653,14 @@ describe('round-trip fuzz', () => { before: string; after: string; }[] = []; - const printWarningCategories: Record< - string, - { count: number; exampleSeed: number } - > = {}; - const invalidMessages: Record< - string, - { count: number; exampleSeed: number } - > = {}; - const warningMessages: Record< - string, - { count: number; exampleSeed: number } - > = {}; + const printWarningCategories: Tally = {}; + const invalidMessages: Tally = {}; + const warningMessages: Tally = {}; for (const r of rows) { - for (const w of r.warningMessages ?? []) { - const m = normalizeMessage(w); - warningMessages[m] ??= { count: 0, exampleSeed: r.seed }; - warningMessages[m].count++; - } + for (const w of r.warningMessages ?? []) + tally(warningMessages, normalizeMessage(w), r.seed); if (!r.valid) { - const m = normalizeMessage(r.errors![0]); - invalidMessages[m] ??= { count: 0, exampleSeed: r.seed }; - invalidMessages[m].count++; + tally(invalidMessages, normalizeMessage(r.errors![0]), r.seed); continue; } const o = r.outcome!; @@ -809,18 +671,15 @@ describe('round-trip fuzz', () => { else side.importWarningsNonEmpty++; } for (const c of o.importWarningCategories) { - importWarningCategories[c] ??= { count: 0, exampleSeed: r.seed }; - importWarningCategories[c].count++; + tally(importWarningCategories, c, r.seed); } for (const m of o.importWarningMessages) { - importWarningMessages[m] ??= { count: 0, exampleSeed: r.seed }; - importWarningMessages[m].count++; + tally(importWarningMessages, m, r.seed); } if (o.unstableLines) unstable.push({ seed: r.seed, cls: o.cls, ...o.unstableLines }); for (const c of o.printWarningCategories) { - printWarningCategories[c] ??= { count: 0, exampleSeed: r.seed }; - printWarningCategories[c].count++; + tally(printWarningCategories, c, r.seed); } if (o.secondPassStable === undefined) side.secondPassNotReached++; else if (o.secondPassStable) side.secondPassStable++; @@ -838,7 +697,6 @@ describe('round-trip fuzz', () => { if (fails(o)) failingSeeds.push(r.seed); } - // ---- symptoms ---- interface Symptom { key: string; cls: Cls; @@ -858,9 +716,6 @@ describe('round-trip fuzz', () => { const symptoms = new Map(); for (const r of valid) { const o = r.outcome!; - // Every failing outcome gets a symptom entry, a warned or unstable - // clean seed included, so it is minimized and reported like any - // other failure. if (!fails(o)) continue; const s = symptoms.get(o.symptom) ?? { key: o.symptom, @@ -876,10 +731,7 @@ describe('round-trip fuzz', () => { string, { categories: string[]; seeds: number[]; example: string } >(); - const warnedCategoryCounts: Record< - string, - { count: number; exampleSeed: number } - > = {}; + const warnedCategoryCounts: Tally = {}; for (const r of valid) { const o = r.outcome!; if (o.cls !== 'warned-restructuring') continue; @@ -892,18 +744,15 @@ describe('round-trip fuzz', () => { w.seeds.push(r.seed); warned.set(key, w); for (const c of o.printWarningCategories) { - warnedCategoryCounts[c] ??= { count: 0, exampleSeed: r.seed }; - warnedCategoryCounts[c].count++; + tally(warnedCategoryCounts, c, r.seed); } } - // ---- minimize one seed per symptom ---- if (OUT !== undefined) { const outDir = OUT; let k = 0; for (const s of symptoms.values()) { k++; - // the smallest failing seed of the group is the cheapest to reduce const seed = s.seeds .map((sd) => ({ sd, diff --git a/tests/gateway-settings.round-trip.test.ts b/tests/gateway-settings.round-trip.test.ts index bc574da..75c61dd 100644 --- a/tests/gateway-settings.round-trip.test.ts +++ b/tests/gateway-settings.round-trip.test.ts @@ -1,9 +1,4 @@ -// One artifact carries all ten spellings a gateway statement takes, so what -// this suite catches is a setting that stops travelling in one of the four -// directions, or that lands on the wrong gateway of a split-and-join pair, -// while the control flow around it looks unchanged. - -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; import { ENGINE_KEYS } from '@bpmn-script/language'; import { isGateway } from '@bpmn-script/transform'; @@ -11,10 +6,9 @@ import type { FlowContainer, JobSettings } from '@bpmn-script/transform'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; import { bpmnDoc } from './helpers/bpmn-doc.js'; -import { describeDiContainment } from './helpers/di-bounds.js'; import { allElements } from './helpers/ir-query.js'; import { describeImportFirst } from './helpers/import-first.js'; -import { roundTrip, validate } from './helpers/pipeline.js'; +import { roundTrip, validationErrors } from './helpers/pipeline.js'; import { modelSignature } from './helpers/model-equivalence.js'; const rt = roundTripFixture('gateway-settings', { @@ -23,9 +17,7 @@ const rt = roundTripFixture('gateway-settings', { recompile: 'clean', }); -// Every gateway of the artifact, the ones written bare included, so a setting -// that appears on a gateway nothing wrote it for fails as loudly as one that -// stops travelling. +// Bare gateways included, so a setting nothing wrote fails too. const GATEWAY_SETTINGS: Record = { 'Gateway_loan-application_2_split': { asyncBefore: true, @@ -102,78 +94,62 @@ function gatewayWireSettings(xml: string): [string, string, string][] { const HEAD_LINE = /^(if |} else if |while |} while |parallel|await)/; -describe('the frozen gateway-settings contract', () => { - it('every gateway carries exactly the settings written for it at every hop', () => { - for (const [label, ir] of rt.hops) { - expect( - settingsByGateway(ir), - `gateway settings differ in ${label}`, - ).toEqual(GATEWAY_SETTINGS); - } - }); - - it('the wire carries the attributes and the retry child on the gateway elements', () => { - expect(gatewayWireSettings(rt.frozenXml)).toEqual([ - ['Gateway_loan-application_2_split', 'operaton:asyncBefore', 'true'], - ['Gateway_loan-application_2_split', 'operaton:exclusive', 'false'], - ['Gateway_loan-application_2_split', 'operaton:jobPriority', '30'], - ['Gateway_loan-application_2_join', 'operaton:asyncBefore', 'true'], - ['Gateway_loan-application_2_join', 'operaton:jobPriority', '20'], - ['Gateway_loan-application_3_loop', 'operaton:asyncAfter', 'true'], - [ - 'Gateway_loan-application_3_loop', - 'operaton:failedJobRetryTimeCycle', - 'R3/PT10M', - ], - ['Gateway_loan-application_4_0_loop', 'operaton:asyncBefore', 'true'], - ['Gateway_loan-application_4_0_loop', 'operaton:exclusive', 'false'], - ['Gateway_loan-application_4_0_loop', 'operaton:jobPriority', '10'], - ['Gateway_loan-application_5_fork', 'operaton:asyncBefore', 'true'], - ['Gateway_loan-application_5_join', 'operaton:asyncAfter', 'true'], - ['Gateway_loan-application_5_join', 'operaton:exclusive', 'false'], - [ - 'Gateway_loan-application_5_join', - 'operaton:failedJobRetryTimeCycle', - 'R2/PT1M', - ], - ['Gateway_loan-application_6_fork', 'operaton:asyncBefore', 'true'], - [ - 'Gateway_loan-application_6_fork', - 'operaton:jobPriority', - '${selfEmployed ? 90 : 50}', - ], - ['Gateway_loan-application_6_join', 'operaton:asyncBefore', 'true'], - ['Gateway_loan-application_8_race', 'operaton:asyncBefore', 'true'], - ['Gateway_loan-application_8_race', 'operaton:jobPriority', '5'], - ['Gateway_loan-application_8_join', 'operaton:asyncBefore', 'true'], - ]); - }); - - // Every head, the bare ones included, so a parens printed on a gateway that - // carries nothing fails too; the fixture harness re-parses and validates - // DSL'. - it('the printed script carries the settings on the statement heads', () => { - const heads = rt.dslPrime - .split('\n') - .map((line) => line.trim()) - .filter((line) => HEAD_LINE.test(line)); - expect(heads).toEqual([ - 'if (creditScore >= 700) (asyncBefore: true, exclusive: false, jobPriority: 30, joinAsyncBefore: true, joinJobPriority: 20) {', - '} else if (creditScore >= 600) {', - 'while (documentsMissing) (asyncAfter: true, retryCycle: "R3/PT10M") {', - 'if (selfEmployed) {', - '} while (referencesPending) (asyncBefore: true, exclusive: false, jobPriority: 10)', - 'parallel (asyncBefore: true, joinAsyncAfter: true, joinExclusive: false, joinRetryCycle: "R2/PT1M") {', - 'parallel (asyncBefore: true, jobPriority: "${selfEmployed ? 90 : 50}", joinAsyncBefore: true) {', - 'if (amount > 250000) {', - 'await (asyncBefore: true, jobPriority: 5, joinAsyncBefore: true) {', - ]); - }); +it('keeps exactly the settings written for each gateway at every hop, on the wire and on the printed statement heads', () => { + for (const [label, ir] of rt.hops) { + expect(settingsByGateway(ir), label).toEqual(GATEWAY_SETTINGS); + } + expect(gatewayWireSettings(rt.frozenXml)).toEqual([ + ['Gateway_loan-application_2_split', 'operaton:asyncBefore', 'true'], + ['Gateway_loan-application_2_split', 'operaton:exclusive', 'false'], + ['Gateway_loan-application_2_split', 'operaton:jobPriority', '30'], + ['Gateway_loan-application_2_join', 'operaton:asyncBefore', 'true'], + ['Gateway_loan-application_2_join', 'operaton:jobPriority', '20'], + ['Gateway_loan-application_3_loop', 'operaton:asyncAfter', 'true'], + [ + 'Gateway_loan-application_3_loop', + 'operaton:failedJobRetryTimeCycle', + 'R3/PT10M', + ], + ['Gateway_loan-application_4_0_loop', 'operaton:asyncBefore', 'true'], + ['Gateway_loan-application_4_0_loop', 'operaton:exclusive', 'false'], + ['Gateway_loan-application_4_0_loop', 'operaton:jobPriority', '10'], + ['Gateway_loan-application_5_fork', 'operaton:asyncBefore', 'true'], + ['Gateway_loan-application_5_join', 'operaton:asyncAfter', 'true'], + ['Gateway_loan-application_5_join', 'operaton:exclusive', 'false'], + [ + 'Gateway_loan-application_5_join', + 'operaton:failedJobRetryTimeCycle', + 'R2/PT1M', + ], + ['Gateway_loan-application_6_fork', 'operaton:asyncBefore', 'true'], + [ + 'Gateway_loan-application_6_fork', + 'operaton:jobPriority', + '${selfEmployed ? 90 : 50}', + ], + ['Gateway_loan-application_6_join', 'operaton:asyncBefore', 'true'], + ['Gateway_loan-application_8_race', 'operaton:asyncBefore', 'true'], + ['Gateway_loan-application_8_race', 'operaton:jobPriority', '5'], + ['Gateway_loan-application_8_join', 'operaton:asyncBefore', 'true'], + ]); + const heads = rt.dslPrime + .split('\n') + .map((line) => line.trim()) + .filter((line) => HEAD_LINE.test(line)); + expect(heads).toEqual([ + 'if (creditScore >= 700) (asyncBefore: true, exclusive: false, jobPriority: 30, joinAsyncBefore: true, joinJobPriority: 20) {', + '} else if (creditScore >= 600) {', + 'while (documentsMissing) (asyncAfter: true, retryCycle: "R3/PT10M") {', + 'if (selfEmployed) {', + '} while (referencesPending) (asyncBefore: true, exclusive: false, jobPriority: 10)', + 'parallel (asyncBefore: true, joinAsyncAfter: true, joinExclusive: false, joinRetryCycle: "R2/PT1M") {', + 'parallel (asyncBefore: true, jobPriority: "${selfEmployed ? 90 : 50}", joinAsyncBefore: true) {', + 'if (amount > 250000) {', + 'await (asyncBefore: true, jobPriority: 5, joinAsyncBefore: true) {', + ]); }); -// A modeled document names its gateways, so the split and the join reach the -// printer under authored ids rather than the coordinate ids the frozen artifact -// carries, and the `join*` keys have to be recovered from the shape alone. +// Authored gateway ids, so the `join*` keys are recovered from the shape alone. const AUTHORED_SPLIT_AND_JOIN = bpmnDoc( ' \n' + ' \n' + @@ -235,9 +211,6 @@ it('a branch step that loops back through a join with settings prints inside the ].join('\n'); const first = await roundTrip(source); expect(first.dsl).toBe(source); - const { diagnostics } = await validate(first.dsl); - expect(diagnostics.filter((d) => d.severity === 1)).toEqual([]); + expect(await validationErrors(first.dsl)).toEqual([]); expect(modelSignature(first.ir3)).toEqual(modelSignature(first.ir1)); }); - -describeDiContainment(rt, ['CollectReferences']); diff --git a/tests/golden/README.md b/tests/golden/README.md index d1992b0..05e85bf 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -53,9 +53,9 @@ Everything each section below calls a contract must stay exactly as it is. ## What the pair tests do -Each pair has a round-trip test at `tests/.round-trip.test.ts`, and `tests/helpers/round-trip-fixture.ts` registers what they share: reproduce the pipeline and compare byte-for-byte against the frozen `.bpmn`, round-trip the source through XML and back asserting IR equivalence through `tests/helpers/normalize-ir.ts`, re-parse and re-validate the restructured DSL, and open the authored fixture validator-clean. +Each pair has a round-trip test at `tests/.round-trip.test.ts`, and `tests/helpers/round-trip-fixture.ts` registers what they share: reproduce the pipeline and compare byte-for-byte against the frozen `.bpmn`, check its layout (one diagram, no two sibling shapes overlapping except a boundary on its host's edge, every nested shape strictly inside its sub-process), round-trip the source through XML and back asserting IR equivalence through `tests/helpers/normalize-ir.ts`, re-parse and re-validate the restructured DSL, and open the authored fixture validator-clean. Every pair but `intermediate-catch` also asserts that the frozen `.bpmn` imports without a single warning and re-desugars back to the IR the fixture compiled to. -The DI assertion is each suite's own, since what the layout has to get right differs: nested shapes inside their parent's bounds wherever a fixture nests (the `isExpanded` hint from `irToXml`, without which a disconnected event sub-process leaks into the root plane), and a boundary shape centered on its host's lower edge in `boundary-events`. +Nested shapes stay inside their parent only through the `isExpanded` hint from `irToXml`, without which a disconnected event sub-process leaks into the root plane. Per-fixture extras, such as which root elements must be shared or how strictly the restructured DSL is validated, live in the test file. `tests/e2e/deploy-sweep.test.ts` deploys every frozen `.bpmn` here, every fixture under `tests/fixtures/`, and every example, on the engine, both as written and rebuilt from its own print; that suite lives outside the pair suites and runs once per file rather than once per pair. diff --git a/tests/goto-fallback.round-trip.test.ts b/tests/goto-fallback.round-trip.test.ts index effbe53..1659481 100644 --- a/tests/goto-fallback.round-trip.test.ts +++ b/tests/goto-fallback.round-trip.test.ts @@ -1,13 +1,9 @@ // A `goto` names a statement, and gateways have no statement form, so an edge // into a gateway is expressible only through the gateway's successor, and only -// while the gateway routes one way. The shapes below sit on both sides of that -// line, and the clean ones at the end pin that the fallback never turns a -// `while` into a jump. +// while the gateway routes one way. import { describe, it, expect } from 'vitest'; -import { DiagnosticSeverity } from 'vscode-languageserver-types'; - import { xmlToIr, astToIr, @@ -15,73 +11,74 @@ import { irToXml, UNSTRUCTURED_MARKER, } from '@bpmn-script/transform'; -import type { BpmnProcess } from '@bpmn-script/transform'; +import type { BpmnProcess, FlowElement } from '@bpmn-script/transform'; import { realNodeReachability } from './helpers/real-node-reachability.js'; import { parseToAst as parseUnvalidated, - printDsl, - validate, + validationErrors, } from './helpers/pipeline.js'; -// Validates as well as parses: a `goto` naming an elided node, or one that -// leaves a later statement unreachable, parses fine and fails only in the -// validator. +// A `goto` naming an elided node, or one that leaves a later statement +// unreachable, parses fine and fails only in the validator. async function parseToAst(source: string) { - const { document, diagnostics } = await validate(source); - const problems = diagnostics.filter( - (d) => d.severity === DiagnosticSeverity.Error, - ); + const problems = await validationErrors(source); if (problems.length > 0) { - throw new Error( - 'Errors in emitted DSL:\n' + problems.map((d) => d.message).join('\n'), - ); + throw new Error('Errors in emitted DSL:\n' + problems.join('\n')); } - return document.parseResult.value; -} - -async function emit(ir: BpmnProcess) { - const { ir: imported, warnings } = await xmlToIr(await irToXml(ir)); - expect(warnings).toEqual([]); - return { imported, dsl: printDsl(imported) }; + return parseUnvalidated(source); } -const flow = (id: string, from: string, to: string, condition?: string) => ({ +const proc = ( + id: string, + flowElements: FlowElement[], + flows: [id: string, from: string, to: string, condition?: string][], +): BpmnProcess => ({ id, - sourceRef: from, - targetRef: to, - ...(condition !== undefined ? { conditionExpression: condition } : {}), + isExecutable: true, + flowElements, + sequenceFlows: flows.map(([fid, sourceRef, targetRef, condition]) => ({ + id: fid, + sourceRef, + targetRef, + ...(condition !== undefined ? { conditionExpression: condition } : {}), + })), }); const task = (id: string) => ({ kind: 'userTask' as const, id }); +const xor = (id: string) => ({ kind: 'exclusiveGateway' as const, id }); +const start = { kind: 'startEvent' as const, id: 'Start_1' }; +const end = (id = 'End_1') => ({ kind: 'endEvent' as const, id }); -const JOIN_HEADED_LOOP: BpmnProcess = { - id: 'JoinHeadedLoop', - isExecutable: true, - flowElements: [ - { kind: 'startEvent', id: 'Start_1' }, - { kind: 'exclusiveGateway', id: 'Gateway_H_join' }, - task('T1'), - { kind: 'exclusiveGateway', id: 'Gateway_X_split' }, - task('T2'), - { kind: 'endEvent', id: 'End_1' }, - ], - sequenceFlows: [ - flow('f1', 'Start_1', 'Gateway_H_join'), - flow('f2', 'Gateway_H_join', 'T1'), - flow('f3', 'T1', 'Gateway_X_split'), - flow('f4', 'Gateway_X_split', 'T2', '${c}'), - flow('f5', 'Gateway_X_split', 'End_1'), - flow('f6', 'T2', 'Gateway_H_join'), - ], -}; - -describe('back-edge into a pass-through join whose out-edge is already consumed', () => { - // The structured walk printed the join's out-edge, but printing an edge does - // not stop it existing, so the jump resolves through the join to `T1`. - it("carries the back-edge as a `goto` naming the join's successor and keeps the reachability", async () => { - const { imported, dsl } = await emit(JOIN_HEADED_LOOP); - expect(dsl).toBe( +// `gained` is what the recompiled print reaches beyond the import; null skips +// the check, for rows whose print drops an edge behind a marker. +describe('an edge with no statement form degrades without inventing a target, and clean shapes stay structured', () => { + it.each< + [title: string, ir: BpmnProcess, printed: string[], gained: string[] | null] + >([ + [ + // Printing the join's out-edge does not stop it existing, so the jump + // resolves through the join to `T1`. + "a back-edge into a pass-through join whose out-edge is already printed is a `goto` naming the join's successor", + proc( + 'JoinHeadedLoop', + [ + start, + xor('Gateway_H_join'), + task('T1'), + xor('Gateway_X_split'), + task('T2'), + end(), + ], + [ + ['f1', 'Start_1', 'Gateway_H_join'], + ['f2', 'Gateway_H_join', 'T1'], + ['f3', 'T1', 'Gateway_X_split'], + ['f4', 'Gateway_X_split', 'T2', '${c}'], + ['f5', 'Gateway_X_split', 'End_1'], + ['f6', 'T2', 'Gateway_H_join'], + ], + ), [ 'process JoinHeadedLoop {', ' var c: any', @@ -94,46 +91,24 @@ describe('back-edge into a pass-through join whose out-edge is already consumed' ' end End_1', '}', '', - ].join('\n'), - ); - const reDesugared = astToIr(await parseToAst(dsl)); - expect(realNodeReachability(reDesugared)).toEqual( - realNodeReachability(imported), - ); - }); -}); - -const BACK_EDGE_CONDITION_LOOP: BpmnProcess = { - id: 'BackEdgeConditionLoop', - isExecutable: true, - flowElements: [ - { kind: 'startEvent', id: 'Start_1' }, - { kind: 'exclusiveGateway', id: 'Gateway_L_loop' }, - task('Body_1'), - { kind: 'endEvent', id: 'End_1' }, - ], - sequenceFlows: [ - flow('f1', 'Start_1', 'Gateway_L_loop'), - flow('f2', 'Gateway_L_loop', 'Body_1'), - flow('f3', 'Gateway_L_loop', 'End_1', '${done}'), - flow('f4', 'Body_1', 'Gateway_L_loop', '${more}'), - ], -}; - -describe('loop condition on the back-edge (no expressible jump target)', () => { - // No loop pattern matches, so the head prints as an `if` and the back-edge - // still points at a two-way gateway. A jump to the head would re-run the - // `done` test and a jump to the body would skip it, so the edge is dropped - // and the marker names where. - it('drops the conditioned back-edge with a marker naming the gateway, and never invents a target', async () => { - const { imported, dsl } = await emit(BACK_EDGE_CONDITION_LOOP); - // The loss happens on emission, not on import: the IR still has the edge. - expect( - imported.sequenceFlows.find( - (f) => f.sourceRef === 'Body_1' && f.targetRef === 'Gateway_L_loop', - )?.conditionExpression, - ).toBe('${more}'); - expect(dsl).toBe( + ], + [], + ], + [ + // No loop pattern matches, so the back-edge still points at a two-way + // gateway: a jump to the head would re-run the `done` test and a jump to + // the body would skip it. + 'a conditioned back-edge into a two-way loop head is dropped with a marker naming the gateway', + proc( + 'BackEdgeConditionLoop', + [start, xor('Gateway_L_loop'), task('Body_1'), end()], + [ + ['f1', 'Start_1', 'Gateway_L_loop'], + ['f2', 'Gateway_L_loop', 'Body_1'], + ['f3', 'Gateway_L_loop', 'End_1', '${done}'], + ['f4', 'Body_1', 'Gateway_L_loop', '${more}'], + ], + ), [ 'process BackEdgeConditionLoop {', ' var done: any', @@ -147,45 +122,38 @@ describe('loop condition on the back-edge (no expressible jump target)', () => { ' end End_1', '}', '', - ].join('\n'), - ); - await expect(parseToAst(dsl)).resolves.toBeDefined(); - }); -}); - -// The approve-review loop as a modeler draws it: the split inside the body and -// the loop gateway both carry a condition on every route and name no default. -// The route leaving the loop puts the split's immediate post-dominator outside -// it, so the split has neither a clean join nor a guard clause. -const REVIEW_LOOP: BpmnProcess = { - id: 'ReviewLoop', - isExecutable: true, - flowElements: [ - { kind: 'startEvent', id: 'Start_1' }, - task('Approve'), - { kind: 'exclusiveGateway', id: 'Gateway_approved' }, - task('Review'), - { kind: 'exclusiveGateway', id: 'Gateway_clarified' }, - task('Pay'), - { kind: 'endEvent', id: 'End_1' }, - { kind: 'endEvent', id: 'End_2' }, - ], - sequenceFlows: [ - flow('f1', 'Start_1', 'Approve'), - flow('f2', 'Approve', 'Gateway_approved'), - flow('f3', 'Gateway_approved', 'Pay', '${approved}'), - flow('f4', 'Gateway_approved', 'Review', '${!approved}'), - flow('f5', 'Review', 'Gateway_clarified'), - flow('f6', 'Gateway_clarified', 'Approve', '${clarified}'), - flow('f7', 'Gateway_clarified', 'End_2', '${!clarified}'), - flow('f8', 'Pay', 'End_1'), - ], -}; - -describe('a split inside a loop body whose every route is conditioned', () => { - it('keeps the route that stays in the loop, so only the leaving route is a jump', async () => { - const { imported, dsl } = await emit(REVIEW_LOOP); - expect(dsl).toBe( + ], + null, + ], + [ + // Every route of both gateways is conditioned and none is a default, so + // the split's post-dominator lies outside the loop. The chain closes with + // no `else`, so the recompiled split falls through to the loop gateway: + // `Approve` gains the two routes it takes. + 'a split inside a loop body whose every route is conditioned keeps the route that stays in the loop', + proc( + 'ReviewLoop', + [ + start, + task('Approve'), + xor('Gateway_approved'), + task('Review'), + xor('Gateway_clarified'), + task('Pay'), + end(), + end('End_2'), + ], + [ + ['f1', 'Start_1', 'Approve'], + ['f2', 'Approve', 'Gateway_approved'], + ['f3', 'Gateway_approved', 'Pay', '${approved}'], + ['f4', 'Gateway_approved', 'Review', '${!approved}'], + ['f5', 'Review', 'Gateway_clarified'], + ['f6', 'Gateway_clarified', 'Approve', '${clarified}'], + ['f7', 'Gateway_clarified', 'End_2', '${!clarified}'], + ['f8', 'Pay', 'End_1'], + ], + ), [ 'process ReviewLoop {', ' var approved: any', @@ -204,52 +172,32 @@ describe('a split inside a loop body whose every route is conditioned', () => { ' end End_1', '}', '', - ].join('\n'), - ); - - // The chain closes with no `else`, so the recompiled split falls through - // to the loop gateway where the model had no route: `Approve` gains the - // two routes that gateway takes, and loses none it had. - const reDesugared = astToIr(await parseToAst(dsl)); - expect(realNodeReachability(reDesugared)).toEqual( - [ - ...realNodeReachability(imported), - 'Approve->Approve', - 'Approve->End_2', - ].sort(), - ); - }); -}); - -const SURPLUS_EDGE_ON_TASK: BpmnProcess = { - id: 'SurplusEdgeOnTask', - isExecutable: true, - flowElements: [ - { kind: 'startEvent', id: 'Start_1' }, - { kind: 'exclusiveGateway', id: 'Gateway_H_join' }, - task('T1'), - task('Cont'), - task('Later'), - { kind: 'endEvent', id: 'End_1' }, - ], - sequenceFlows: [ - flow('f1', 'Start_1', 'Gateway_H_join'), - flow('f2', 'Gateway_H_join', 'T1'), - flow('f3', 'T1', 'Cont'), - flow('f4', 'T1', 'Gateway_H_join'), - flow('f5', 'Cont', 'Later'), - flow('f6', 'Later', 'End_1'), - ], -}; - -describe('surplus out-edge on a plain node keeps the fall-through', () => { - // A bare `goto` beside the fall-through would end the chain and leave - // everything after it unreachable, so both routes head a branch. The back - // edge names `T1`, the one step the join leads to, and each route ends in - // its own branch, so the fork needs no merge to print as a block. - it('gives both routes a branch rather than a jump beside the fall-through, and keeps the rest reachable', async () => { - const { imported, dsl } = await emit(SURPLUS_EDGE_ON_TASK); - expect(dsl).toBe( + ], + ['Approve->Approve', 'Approve->End_2'], + ], + [ + // A bare `goto` beside the fall-through would leave the rest + // unreachable, so both routes head a branch. + 'a surplus out-edge on a plain node gives both routes a branch rather than a jump beside the fall-through', + proc( + 'SurplusEdgeOnTask', + [ + start, + xor('Gateway_H_join'), + task('T1'), + task('Cont'), + task('Later'), + end(), + ], + [ + ['f1', 'Start_1', 'Gateway_H_join'], + ['f2', 'Gateway_H_join', 'T1'], + ['f3', 'T1', 'Cont'], + ['f4', 'T1', 'Gateway_H_join'], + ['f5', 'Cont', 'Later'], + ['f6', 'Later', 'End_1'], + ], + ), [ 'process SurplusEdgeOnTask {', ' start Start_1', @@ -266,37 +214,27 @@ describe('surplus out-edge on a plain node keeps the fall-through', () => { ' }', '}', '', - ].join('\n'), - ); - const reDesugared = astToIr(await parseToAst(dsl)); - expect(realNodeReachability(reDesugared)).toEqual( - realNodeReachability(imported), - ); - }); -}); - -// A synthesized terminal is not printed at all, and `await` prints only its -// trigger, so a jump into either has nothing to name. -describe('a goto never names a node the emitter elides', () => { - it.each<[title: string, ir: BpmnProcess, printed: string[]]>([ + ], + [], + ], [ - 'marks the edge instead of naming a synthesized terminal', - { - id: 'ElidedTerminal', - isExecutable: true, - flowElements: [ - { kind: 'startEvent', id: 'Start_1' }, + // A synthesized terminal is not printed, so a jump into it has nothing to name. + 'an edge into a synthesized terminal is marked, not a goto', + proc( + 'ElidedTerminal', + [ + start, task('T1'), - { kind: 'exclusiveGateway', id: 'Gateway_E_join' }, - { kind: 'endEvent', id: 'EndEvent_ElidedTerminal' }, + xor('Gateway_E_join'), + end('EndEvent_ElidedTerminal'), ], - sequenceFlows: [ - flow('f1', 'Start_1', 'T1'), - flow('f2', 'T1', 'Gateway_E_join'), - flow('f3', 'Gateway_E_join', 'EndEvent_ElidedTerminal'), - flow('f4', 'T1', 'Gateway_E_join'), + [ + ['f1', 'Start_1', 'T1'], + ['f2', 'T1', 'Gateway_E_join'], + ['f3', 'Gateway_E_join', 'EndEvent_ElidedTerminal'], + ['f4', 'T1', 'Gateway_E_join'], ], - }, + ), [ 'process ElidedTerminal {', ' start Start_1', @@ -310,14 +248,15 @@ describe('a goto never names a node the emitter elides', () => { '}', '', ], + null, ], [ - 'marks the edge instead of naming an awaited catch event', - { - id: 'CatchLoop', - isExecutable: true, - flowElements: [ - { kind: 'startEvent', id: 'Start_1' }, + // `await` prints only its trigger, so a jump into it has nothing to name. + 'an edge into an awaited catch event is marked, not a goto', + proc( + 'CatchLoop', + [ + start, { kind: 'intermediateCatchEvent', id: 'Catch_1', @@ -329,12 +268,12 @@ describe('a goto never names a node the emitter elides', () => { }, task('T1'), ], - sequenceFlows: [ - flow('f1', 'Start_1', 'Catch_1'), - flow('f2', 'Catch_1', 'T1'), - flow('f3', 'T1', 'Catch_1'), + [ + ['f1', 'Start_1', 'Catch_1'], + ['f2', 'Catch_1', 'T1'], + ['f3', 'T1', 'Catch_1'], ], - }, + ), [ 'process CatchLoop {', ' start Start_1', @@ -344,45 +283,36 @@ describe('a goto never names a node the emitter elides', () => { '}', '', ], + null, ], - ])('%s', async (_title, ir, printed) => { - const { dsl } = await emit(ir); - expect(dsl).toBe(printed.join('\n')); - await expect(parseToAst(dsl)).resolves.toBeDefined(); - }); -}); - -describe('shapes that structure cleanly stay structured', () => { - it.each<[title: string, ir: BpmnProcess, printed: string[]]>([ [ - 'nests an `if` inside an `if` branch', - { - id: 'NestedIf', - isExecutable: true, - flowElements: [ - { kind: 'startEvent', id: 'Start_1' }, - { kind: 'exclusiveGateway', id: 'Gateway_O_split' }, - { kind: 'exclusiveGateway', id: 'Gateway_I_split' }, + 'an `if` nested inside an `if` branch stays nested', + proc( + 'NestedIf', + [ + start, + xor('Gateway_O_split'), + xor('Gateway_I_split'), task('Inner_1'), task('Inner_2'), - { kind: 'exclusiveGateway', id: 'Gateway_I_join' }, + xor('Gateway_I_join'), task('Outer_1'), - { kind: 'exclusiveGateway', id: 'Gateway_O_join' }, - { kind: 'endEvent', id: 'End_1' }, + xor('Gateway_O_join'), + end(), ], - sequenceFlows: [ - flow('f1', 'Start_1', 'Gateway_O_split'), - flow('f2', 'Gateway_O_split', 'Gateway_I_split', '${a}'), - flow('f3', 'Gateway_O_split', 'Outer_1'), - flow('f4', 'Gateway_I_split', 'Inner_1', '${b}'), - flow('f5', 'Gateway_I_split', 'Inner_2'), - flow('f6', 'Inner_1', 'Gateway_I_join'), - flow('f7', 'Inner_2', 'Gateway_I_join'), - flow('f8', 'Gateway_I_join', 'Gateway_O_join'), - flow('f9', 'Outer_1', 'Gateway_O_join'), - flow('f10', 'Gateway_O_join', 'End_1'), + [ + ['f1', 'Start_1', 'Gateway_O_split'], + ['f2', 'Gateway_O_split', 'Gateway_I_split', '${a}'], + ['f3', 'Gateway_O_split', 'Outer_1'], + ['f4', 'Gateway_I_split', 'Inner_1', '${b}'], + ['f5', 'Gateway_I_split', 'Inner_2'], + ['f6', 'Inner_1', 'Gateway_I_join'], + ['f7', 'Inner_2', 'Gateway_I_join'], + ['f8', 'Gateway_I_join', 'Gateway_O_join'], + ['f9', 'Outer_1', 'Gateway_O_join'], + ['f10', 'Gateway_O_join', 'End_1'], ], - }, + ), [ 'process NestedIf {', ' var a: any', @@ -401,34 +331,34 @@ describe('shapes that structure cleanly stay structured', () => { '}', '', ], + [], ], [ - 'emits two sibling `if`s for two independent splits in sequence', - { - id: 'SiblingIfs', - isExecutable: true, - flowElements: [ - { kind: 'startEvent', id: 'Start_1' }, - { kind: 'exclusiveGateway', id: 'Gateway_A_split' }, + 'two independent splits in sequence print as two sibling `if`s', + proc( + 'SiblingIfs', + [ + start, + xor('Gateway_A_split'), task('A_1'), - { kind: 'exclusiveGateway', id: 'Gateway_A_join' }, - { kind: 'exclusiveGateway', id: 'Gateway_B_split' }, + xor('Gateway_A_join'), + xor('Gateway_B_split'), task('B_1'), - { kind: 'exclusiveGateway', id: 'Gateway_B_join' }, - { kind: 'endEvent', id: 'End_1' }, + xor('Gateway_B_join'), + end(), ], - sequenceFlows: [ - flow('f1', 'Start_1', 'Gateway_A_split'), - flow('f2', 'Gateway_A_split', 'A_1', '${a}'), - flow('f3', 'Gateway_A_split', 'Gateway_A_join'), - flow('f4', 'A_1', 'Gateway_A_join'), - flow('f5', 'Gateway_A_join', 'Gateway_B_split'), - flow('f6', 'Gateway_B_split', 'B_1', '${b}'), - flow('f7', 'Gateway_B_split', 'Gateway_B_join'), - flow('f8', 'B_1', 'Gateway_B_join'), - flow('f9', 'Gateway_B_join', 'End_1'), + [ + ['f1', 'Start_1', 'Gateway_A_split'], + ['f2', 'Gateway_A_split', 'A_1', '${a}'], + ['f3', 'Gateway_A_split', 'Gateway_A_join'], + ['f4', 'A_1', 'Gateway_A_join'], + ['f5', 'Gateway_A_join', 'Gateway_B_split'], + ['f6', 'Gateway_B_split', 'B_1', '${b}'], + ['f7', 'Gateway_B_split', 'Gateway_B_join'], + ['f8', 'B_1', 'Gateway_B_join'], + ['f9', 'Gateway_B_join', 'End_1'], ], - }, + ), [ 'process SiblingIfs {', ' var a: any', @@ -444,43 +374,35 @@ describe('shapes that structure cleanly stay structured', () => { '}', '', ], + [], ], - ])('%s', async (_title, ir, printed) => { - const dsl = printDsl(ir); + ])('%s', async (_title, ir, printed, gained) => { + const { ir: imported, warnings } = await xmlToIr(await irToXml(ir)); + expect(warnings).toEqual([]); + // Any loss happens on emission, never on import. + expect(imported.sequenceFlows).toEqual(ir.sequenceFlows); + + const { source: dsl } = irToDsl(imported); expect(dsl).toBe(printed.join('\n')); - await expect(parseToAst(dsl)).resolves.toBeDefined(); + const reDesugared = astToIr(await parseToAst(dsl)); + if (gained !== null) { + expect(realNodeReachability(reDesugared)).toEqual( + [...realNodeReachability(imported), ...gained].sort(), + ); + } }); }); const compiled = async (source: string) => astToIr(await parseToAst(source)); -// `do { end X } while (true)` compiles to a loop gateway with no incoming flow -// (the body always ends, so `lowerDoWhile` wires no back edge), and the -// validator refuses the statement it proves unreachable, so the shapes carrying -// it skip validation or are built as IR, the way a Modeler file would import. +// `do { end X } while (true)` compiles to a loop gateway with no incoming flow, +// and the validator refuses the statement it proves unreachable, so those +// shapes skip validation or are built as IR, the way a Modeler file imports. const compiledUnvalidated = async (source: string) => astToIr(await parseUnvalidated(source)); -const DANGLING_LOOP_HEAD: BpmnProcess = { - id: 'DanglingLoopHead', - isExecutable: true, - flowElements: [ - { kind: 'startEvent', id: 'S' }, - { kind: 'endEvent', id: 'X' }, - { kind: 'exclusiveGateway', id: 'Gateway_L_loop', defaultFlowId: 'f3' }, - { kind: 'endEvent', id: 'Done' }, - ], - sequenceFlows: [ - flow('f1', 'S', 'X'), - flow('f2', 'Gateway_L_loop', 'X', '${true}'), - flow('f3', 'Gateway_L_loop', 'Done'), - ], -}; - -// Shapes a fuzz run drew, each exported, imported and printed. A row pins the -// print's whole warning list by category and whether the print validates, so a -// shape that stops being reported, or starts failing, turns the row red rather -// than the next fuzz run. +// Fuzz-drawn shapes: each row pins the print's warning categories and whether +// it validates. describe('composite shapes through compile, import and print', () => { it.each< [ @@ -560,7 +482,27 @@ describe('composite shapes through compile, import and print', () => { ], [ 'a post-test loop whose body ends prints its unreachable head as a jump', - () => Promise.resolve(DANGLING_LOOP_HEAD), + () => + Promise.resolve( + proc( + 'DanglingLoopHead', + [ + { kind: 'startEvent', id: 'S' }, + end('X'), + { + kind: 'exclusiveGateway', + id: 'Gateway_L_loop', + defaultFlowId: 'f3', + }, + end('Done'), + ], + [ + ['f1', 'S', 'X'], + ['f2', 'Gateway_L_loop', 'X', '${true}'], + ['f3', 'Gateway_L_loop', 'Done'], + ], + ), + ), [], false, ], diff --git a/tests/helpers/di-bounds.ts b/tests/helpers/di-bounds.ts index 0625262..9ddd1b3 100644 --- a/tests/helpers/di-bounds.ts +++ b/tests/helpers/di-bounds.ts @@ -1,9 +1,7 @@ -import { describe, it, expect } from 'vitest'; +import { expect } from 'vitest'; import type { FlowContainer, FlowElement } from '@bpmn-script/transform'; -import type { RoundTrip } from './round-trip-fixture.js'; - export interface Bounds { x: number; y: number; @@ -42,47 +40,6 @@ function overlaps(a: Bounds, b: Bounds): boolean { ); } -// A boundary shape is centered on the lower edge of the host it watches, so that -// one pair overlaps by design. -function sitsOnTheEdgeOf(attacher: FlowElement, host: FlowElement): boolean { - return ( - attacher.kind === 'boundaryEvent' && attacher.attachedToRef === host.id - ); -} - -export function describeNoOverlappingShapes(rt: RoundTrip): void { - describe('DI layout on the frozen .bpmn', () => { - it('every flow node has a shape and only a boundary and its own host overlap', () => { - const bounds = parseShapeBounds(rt.frozenXml); - const shapes = rt.ir1.flowElements.map( - (fe) => [fe, boundsOf(bounds, fe.id)] as const, - ); - - for (let i = 0; i < shapes.length; i++) { - for (let j = i + 1; j < shapes.length; j++) { - const [aEl, a] = shapes[i]!; - const [bEl, b] = shapes[j]!; - if (sitsOnTheEdgeOf(aEl, bEl) || sitsOnTheEdgeOf(bEl, aEl)) { - continue; - } - expect( - overlaps(a, b), - `${aEl.id} ${JSON.stringify(a)} overlaps ${bEl.id} ${JSON.stringify(b)}`, - ).toBe(false); - } - } - }); - }); -} - -export function describeSingleDiagram(rt: RoundTrip): void { - describe('DI on the generated .bpmn', () => { - it('exactly one bpmndi:BPMNDiagram is emitted', () => { - expect(rt.generatedXml.match(/ parent.x && @@ -92,58 +49,60 @@ function strictlyInside(child: Bounds, parent: Bounds): boolean { ); } -// The root process has no shape, so its direct children are unbounded. The -// recursion still descends into them. -function assertShapeContainment( +// A boundary shape is centered on its host's lower edge, so it overlaps the +// host and may overlap a sibling on the same edge. +function attachedPair(a: FlowElement, b: FlowElement): boolean { + const host = (fe: FlowElement): string | undefined => + fe.kind === 'boundaryEvent' ? fe.attachedToRef : undefined; + return ( + host(a) === b.id || + host(b) === a.id || + (host(a) !== undefined && host(a) === host(b)) + ); +} + +function expectNoOverlap( container: FlowContainer, bounds: Map, - isRoot: boolean, ): void { - const parentBounds = isRoot ? undefined : bounds.get(container.id); - if (!isRoot) { - expect( - parentBounds, - `sub-process ${container.id} has no BPMNShape`, - ).toBeDefined(); - } - for (const fe of container.flowElements) { - if (parentBounds !== undefined) { - const childBounds = bounds.get(fe.id); - expect(childBounds, `child ${fe.id} has no BPMNShape`).toBeDefined(); + const shapes = container.flowElements.map( + (fe) => [fe, boundsOf(bounds, fe.id)] as const, + ); + shapes.forEach(([aEl, a], i) => { + for (const [bEl, b] of shapes.slice(i + 1)) { + if (attachedPair(aEl, bEl)) continue; expect( - strictlyInside(childBounds!, parentBounds), - `${fe.id} ${JSON.stringify(childBounds)} not inside ${container.id} ${JSON.stringify(parentBounds)}`, - ).toBe(true); - } - if (fe.kind === 'subProcess') { - assertShapeContainment(fe, bounds, false); + overlaps(a, b), + `${aEl.id} ${JSON.stringify(a)} overlaps ${bEl.id} ${JSON.stringify(b)}`, + ).toBe(false); } - } + }); } // The layout library places a disconnected event sub-process and its children -// inside the parent only with the `isExpanded="true"` stub irToXml emits; drop -// the stub and this block fails. `requiredIds` keeps the walk from passing -// because the interesting containers are absent; a thunk defers ids that come -// from the IR, readable only once the pipeline has run. -export function describeDiContainment( - rt: RoundTrip, - requiredIds: readonly string[] | (() => readonly string[]) = [], - source: 'generated' | 'frozen' = 'frozen', +// inside the parent only with the `isExpanded="true"` stub irToXml emits. +function expectContained( + container: FlowContainer, + bounds: Map, + parent?: Bounds, ): void { - describe(`DI containment on the ${source} .bpmn`, () => { - it('every child shape lies strictly inside its parent sub-process bounds', () => { - const bounds = parseShapeBounds( - source === 'frozen' ? rt.frozenXml : rt.generatedXml, - ); - - const ids = - typeof requiredIds === 'function' ? requiredIds() : requiredIds; - for (const id of ids) { - expect(bounds.has(id), `missing BPMNShape for ${id}`).toBe(true); - } + for (const fe of container.flowElements) { + const box = boundsOf(bounds, fe.id); + if (parent !== undefined) { + expect( + strictlyInside(box, parent), + `${fe.id} ${JSON.stringify(box)} not inside ${container.id} ${JSON.stringify(parent)}`, + ).toBe(true); + } + if (fe.kind === 'subProcess') expectContained(fe, bounds, box); + } +} - assertShapeContainment(rt.ir1, bounds, true); - }); - }); +// One diagram, a shape per flow node, siblings apart (a boundary and its own +// host excepted), and every child strictly inside its sub-process. +export function expectSoundLayout(xml: string, process: FlowContainer): void { + expect(xml.match(/>> 0 || 1; } next(): number { - // mulberry32's 32-bit state mixing is inherently bitwise. + // mulberry32 /* eslint-disable no-bitwise */ let t = (this.state += 0x6d2b79f5); t = Math.imul(t ^ (t >>> 15), t | 1); @@ -58,26 +47,22 @@ export class Rng { } } -// --------------------------------------------------------------------------- -// Program model -// --------------------------------------------------------------------------- - -export type ExprType = 'boolean' | 'number' | 'string' | 'unknown'; +type ExprType = 'boolean' | 'number' | 'string' | 'unknown'; -export type Expr = +type Expr = | { t: 'leaf'; text: string; type: ExprType } | { t: 'bin'; op: string; l: Expr; r: Expr; type: ExprType } | { t: 'un'; op: string; e: Expr; type: ExprType } | { t: 'paren'; e: Expr; type: ExprType } | { t: 'tern'; c: Expr; a: Expr; b: Expr; type: ExprType }; -export interface Item { +interface Item { key?: string; value: string; flag?: true; } -export interface FormFieldSpec { +interface FormFieldSpec { id: string; type: string; label?: string; @@ -87,7 +72,7 @@ export interface FormFieldSpec { props: { name: string; value: string }[]; } -export type Member = +type Member = | { m: 'io'; dir: string; name: string; value: string } | { m: 'listener'; @@ -108,7 +93,7 @@ export type Member = } | { m: 'errorMapping'; code: string; condition: Expr }; -export interface Repeat { +interface Repeat { cardinality?: string; element?: string; collection?: string; @@ -116,7 +101,7 @@ export interface Repeat { until?: Expr; } -export type Stmt = +type Stmt = | { k: 'start'; name: string; @@ -206,7 +191,7 @@ export type Stmt = body: Stmt[]; }; -export interface CodeDecl { +interface CodeDecl { kind: 'error' | 'escalation'; name: string; items: Item[]; @@ -220,14 +205,8 @@ export interface Program { body: Stmt[]; } -// --------------------------------------------------------------------------- -// Printer -// --------------------------------------------------------------------------- - -// A ternary arm and a binary operand are one precedence level down in the -// grammar, so a ternary nested there is parenthesized; everything else is -// printed as built. -export function renderExpr(e: Expr): string { +// A ternary arm or binary operand is one grammar precedence level down. +function renderExpr(e: Expr): string { const operand = (x: Expr): string => x.t === 'tern' ? `(${renderExpr(x)})` : renderExpr(x); switch (e.t) { @@ -263,8 +242,7 @@ function indent(lines: string[], depth: number): string[] { } function renderFence(script: string): string[] { - // A fenced script keeps its own newlines; the tag line and the closing fence - // are part of the token. + // The script already contains its tag line and closing fence. return script.split('\n'); } @@ -362,7 +340,6 @@ function renderBlock(body: Stmt[]): string[] { } function withBlock(head: string, lines: string[]): string[] { - // `head {` on one line, the rest indented, closing brace alone. return [`${head} ${lines[0]}`, ...lines.slice(1)]; } @@ -371,7 +348,7 @@ function renderMemberBlock(members: Member[]): string[] | undefined { return ['{', ...indent(renderMembers(members), 1), '}']; } -export function renderStmt(s: Stmt): string[] { +function renderStmt(s: Stmt): string[] { switch (s.k) { case 'start': case 'end': { @@ -509,10 +486,6 @@ export function renderProgram(p: Program): string { return out.join('\n'); } -// --------------------------------------------------------------------------- -// Generator -// --------------------------------------------------------------------------- - export const PROCESS_NAMES = [ 'order-fulfilment', 'claim-review', @@ -579,21 +552,14 @@ const CYCLES = ['R3/PT10M', 'R/PT1H', '0 0 9 * * ?']; const RETRY = ['R3/PT10M', 'R5/PT5M']; interface Scope { - // the container (process, subprocess, attempt, host-less handler) this block belongs to container: 'process' | 'subprocess' | 'attempt' | 'handler'; - // names of statements in this container (goto targets) - targets: string[]; - // names of activity statements in this container usable as handler hosts, with kind - hosts: { name: string; kind: string }[]; - // statements at this container level that end a chain (for `await link` placement) - linkCatchesWanted: string[]; // link names emitted in this container without a catch yet + targets: string[]; // goto targets + hosts: { name: string; kind: string }[]; // handler hosts + linkCatchesWanted: string[]; // emitted links without a catch yet usedHandlers: Set; // trigger|code per subscription scope - // nesting depth of blocks depth: number; - // whether we are inside a parallel/race branch (goto/link into it refused) - inBranch: boolean; - // whether a hosted handler body: no start allowed, statements shared with host container - hostedBody: boolean; + inBranch: boolean; // goto/link into a parallel or race branch is refused + hostedBody: boolean; // no start; shares the host container's statements } export class Generator { @@ -654,7 +620,6 @@ export class Generator { hostedBody: false, }; const body: Stmt[] = []; - // starts if (r.chance(0.8)) { body.push(this.startEvent(true)); if (r.chance(0.25)) body.push(this.startEvent(false)); @@ -824,7 +789,6 @@ export class Generator { type: 'boolean', }; if (c === 5) { - // A ternary arm is a LogicalOr in the grammar, so a nested ternary needs parens. return { t: 'tern', c: this.boolExpr(depth + 1), @@ -842,7 +806,6 @@ export class Generator { }; } - // A value in one of the four forms: quoted string, bare variable, template, integer. private valueForm(allowInt = true, allowBare = true): string { const r = this.rng; const c = r.int(0, allowInt ? 3 : 2); @@ -1324,8 +1287,7 @@ export class Generator { value: '"embedded:app:forms/triage.html"', }); } else { - // Same-looking condition as the branch above, but each `chance` - // call draws independently, so this is not a dead branch. + // Not dead: each `chance` draws independently. if (r.chance(0.2)) { items.push({ key: 'formRef', value: '"payout-approval"' }); items.push( @@ -1799,7 +1761,6 @@ export class Generator { return body.some((s) => this.terminates(s)); } - // A run of statements whose flow stays live except possibly at the very end. private chain(scope: Scope, processLevel: boolean): Stmt[] { const r = this.rng; const out: Stmt[] = []; @@ -1815,7 +1776,6 @@ export class Generator { out.push(this.simpleTask(scope)); this.stmtCount++; } - // A terminal statement at the end of a chain, sometimes. if ( !this.terminates(out[out.length - 1]) && r.chance(scope.depth === 0 ? 0.5 : 0.2) @@ -1826,7 +1786,7 @@ export class Generator { this.stmtCount++; } } - // A second chain after a terminal statement: a `start` at process level or an `await link` catch. + // Reopen flow after a terminal: a `start` at process level or an `await link`. if ( this.terminates(out[out.length - 1]) && !scope.inBranch && @@ -1922,7 +1882,6 @@ export class Generator { } } - // Trailing handlers of a container, hosted and host-less. private handlers(scope: Scope, body: Stmt[]): Stmt[] { const r = this.rng; const out: Stmt[] = []; @@ -2045,8 +2004,7 @@ export class Generator { return out; } - // Every `emit link` of the container gets its catch: close the flow, then - // one `await link` per pending name followed by a step. + // Every `emit link` needs an `await link` catch in the same container. private flushLinks(scope: Scope, body: Stmt[]): void { if (scope.linkCatchesWanted.length === 0) return; if (!this.terminates(body[body.length - 1])) { @@ -2074,7 +2032,7 @@ export class Generator { } } -export function childBlocks(s: Stmt): Stmt[][] { +function childBlocks(s: Stmt): Stmt[][] { switch (s.k) { case 'sub': case 'while': @@ -2099,17 +2057,12 @@ export function generateProgram(seed: number): Program { return new Generator(seed).generate(); } -// --------------------------------------------------------------------------- -// Reducer: delta debugging over the program model -// --------------------------------------------------------------------------- - export type Keep = (candidate: Program) => Promise; function clone(x: T): T { return JSON.parse(JSON.stringify(x)) as T; } -// Every statement list in the program, addressed so a candidate can drop one entry. function statementLists(p: Program): Stmt[][] { const lists: Stmt[][] = [p.body]; const walk = (body: Stmt[]): void => { @@ -2124,6 +2077,10 @@ function statementLists(p: Program): Stmt[][] { return lists; } +export function countStatements(p: Program): number { + return statementLists(p).reduce((n, list) => n + list.length, 0); +} + async function dropStatements(p: Program, keep: Keep): Promise { let changed = true; while (changed) { @@ -2144,7 +2101,7 @@ async function dropStatements(p: Program, keep: Keep): Promise { if (changed) break; } } - // Replace a compound statement by its body (unwrap), one at a time. + // Unwrap compound statements into their bodies. changed = true; while (changed) { changed = false; @@ -2172,7 +2129,6 @@ async function dropStatements(p: Program, keep: Keep): Promise { return p; } -// Every array of droppable settings/members in the program. function droppableArrays(p: Program): unknown[][] { const arrays: unknown[][] = [p.header, p.vars, p.codes]; const walk = (body: Stmt[]): void => { @@ -2218,7 +2174,6 @@ async function dropEntries(p: Program, keep: Keep): Promise { if (changed) break; } } - // Optional scalar fields: repeat clauses, else blocks, names, triggers, scripts, labels. const optionalFields: [string, string][] = [ ['task', 'repeat'], ['call', 'repeat'], @@ -2270,7 +2225,6 @@ function allNodes(p: Program): unknown[] { return nodes; } -// Every expression slot, as a getter/setter pair over a cloned program. function exprSlots(p: Program): { get: () => Expr; set: (e: Expr) => void }[] { const slots: { get: () => Expr; set: (e: Expr) => void }[] = []; const walk = (body: Stmt[]): void => { @@ -2332,7 +2286,6 @@ function simplerForms(e: Expr): Expr[] { return out; } -// Children of an expression, for a depth-first replacement walk. function exprChildren(e: Expr): { get: () => Expr; set: (x: Expr) => void }[] { switch (e.t) { case 'bin': @@ -2360,7 +2313,6 @@ async function simplifyExpressions(p: Program, keep: Keep): Promise { changed = false; const slotCount = exprSlots(p).length; for (let si = 0; si < slotCount && !changed; si++) { - // Try every node in the slot's tree, root first. const paths: number[][] = []; const collect = (e: Expr, path: number[]): void => { paths.push(path); @@ -2396,7 +2348,7 @@ export async function minimize(p: Program, keep: Keep): Promise { p = await dropStatements(p, keep); p = await dropEntries(p, keep); p = await simplifyExpressions(p, keep); - // A second statement pass: entries dropped above may have freed statements. + // Dropped entries can free more statements. p = await dropStatements(p, keep); return p; } diff --git a/tests/helpers/import-first.ts b/tests/helpers/import-first.ts index b8b86bb..a027bd5 100644 --- a/tests/helpers/import-first.ts +++ b/tests/helpers/import-first.ts @@ -1,7 +1,6 @@ -// The other direction of a golden pair: a handwritten .bpmn is the source, and -// the DSL printed from it has to re-desugar onto what was imported. The two -// halves share no synthesized id, so only the structural re-key in -// normalize-ir makes them meet. +// A handwritten .bpmn as the source: the DSL printed from it must re-desugar +// onto the import. The two share no synthesized id, so only normalizeIr's +// structural re-key makes them meet. import { describe, it, expect, beforeAll } from 'vitest'; @@ -11,7 +10,6 @@ import type { BpmnProcess, ImportWarning } from '@bpmn-script/transform'; import { normalizeIr } from './normalize-ir.js'; import { parseToAst, printDsl } from './pipeline.js'; -/** The one warning `xmlToIr` pushes for a document declaring the camunda namespace, against the process `elementId`. */ export const camundaAliasWarning = (elementId: string): ImportWarning => ({ elementId, category: 'unmappedConstruct', @@ -29,8 +27,7 @@ export interface ImportFirst { reDesugared: BpmnProcess; } -// `extra` registers what is specific to the fixture. The handle it receives is -// filled by a beforeAll, so read it only from inside an `it` body. +// The handle `extra` receives is filled by a beforeAll; read it only in an `it`. export function describeImportFirst( what: string, xml: string, @@ -48,14 +45,11 @@ export function describeImportFirst( first.reDesugared = astToIr(await parseToAst(first.dsl)); }); - it('imports with exactly the expected warnings', () => { + it('imports with exactly the expected warnings and re-desugars normalized-equal to the import', () => { expect(first.warnings).toEqual(expectedWarnings); + expect(normalizeIr(first.reDesugared)).toEqual(normalizeIr(first.ir)); }); extra(first); - - it('the re-desugared IR is normalized-equal to the import', () => { - expect(normalizeIr(first.reDesugared)).toEqual(normalizeIr(first.ir)); - }); }); } diff --git a/tests/helpers/ir-query.ts b/tests/helpers/ir-query.ts index 8560152..8b23783 100644 --- a/tests/helpers/ir-query.ts +++ b/tests/helpers/ir-query.ts @@ -79,9 +79,8 @@ export function definitionOf( return undefined; } -// A handler's trigger sits on the start event of the event sub-process it -// lowers to, so match on what it catches, never on the synthesized id; every -// depth contributes, an unset trigger included. +// Handler triggers at every depth, read off each event sub-process's start +// event since the handler's id is synthesized. export function handlerTriggerDefs( container: FlowContainer, ): (EventDefinition | undefined)[] { @@ -104,8 +103,6 @@ export function handlerTriggerDef( return handlerTriggerDefs(container).find(match); } -// Locating a node by what it is, not by id, is what lets a suite pin a carrier -// whose id the lowering synthesizes. export function allOf( container: FlowContainer, kind: K, diff --git a/tests/helpers/model-equivalence.test.ts b/tests/helpers/model-equivalence.test.ts index 334250f..b65cb8c 100644 --- a/tests/helpers/model-equivalence.test.ts +++ b/tests/helpers/model-equivalence.test.ts @@ -5,31 +5,35 @@ import { parseToAst } from './pipeline.js'; const compile = async (source: string) => astToIr(await parseToAst(source)); -// Shared by the rows that must be `changed`; each variant differs in one place. -const baseline = (body: string): string => `process p { - var a: any - var b: any -${body} -}`; +// Each `changed` row swaps one part of this baseline. const ifA = (then: string, otherwise: string): string => ` if (a) { step ${then} } else { step ${otherwise} }`; -const parallel = ` parallel { +const ifB = ` if (b) { + step W + }`; +const PARTS = [ + ifA('X', 'Y'), + ` parallel { { step P1 } { step P2 } - }`; -const ifB = ` if (b) { - step W - }`; -const base = baseline( - [ifA('X', 'Y'), parallel, ' step Z(asyncBefore: true)', ifB].join('\n'), -); + }`, + ' step Z(asyncBefore: true)', + ifB, +]; +const withPart = (index: number, part: string): string => + `process p { + var a: any + var b: any +${PARTS.map((p, i) => (i === index ? part : p)).join('\n')} +}`; +const base = withPart(-1, ''); const joinSource = `process p { if (true) (joinAsyncAfter: true) { @@ -190,54 +194,31 @@ const table: [title: string, a: string, b: string, want: ModelComparison][] = [ [ 'swapped if and else bodies are changed', base, - baseline( - [ifA('Y', 'X'), parallel, ' step Z(asyncBefore: true)', ifB].join('\n'), - ), + withPart(0, ifA('Y', 'X')), 'changed', ], [ 'a condition replaced by true is changed', base, - baseline( - [ - ifA('X', 'Y'), - parallel, - ' step Z(asyncBefore: true)', - ifB.replace('(b)', '(true)'), - ].join('\n'), - ), + withPart(3, ifB.replace('(b)', '(true)')), 'changed', ], [ 'asyncBefore dropped from a step is changed', base, - baseline([ifA('X', 'Y'), parallel, ' step Z', ifB].join('\n')), + withPart(2, ' step Z'), 'changed', ], [ 'a parallel printed as a sequence is changed', base, - baseline( - [ - ifA('X', 'Y'), - ' step P1\n step P2', - ' step Z(asyncBefore: true)', - ifB, - ].join('\n'), - ), + withPart(1, ' step P1\n step P2'), 'changed', ], [ 'an added else branch is changed', base, - baseline( - [ - ifA('X', 'Y'), - parallel, - ' step Z(asyncBefore: true)', - ifB.replace(/\}$/, '} else {\n step W2\n }'), - ].join('\n'), - ), + withPart(3, ifB.replace(/\}$/, '} else {\n step W2\n }')), 'changed', ], [ diff --git a/tests/helpers/model-equivalence.ts b/tests/helpers/model-equivalence.ts index bcc28f9..3ac291b 100644 --- a/tests/helpers/model-equivalence.ts +++ b/tests/helpers/model-equivalence.ts @@ -1,9 +1,7 @@ -// Decides whether a recompiled print keeps the model of its source. Three -// layers, cheapest first: `normalizeIr` equality; the same after canonicalizing -// JUEL bodies and minted coordinate ids; and a path signature that accepts a -// restructured print as long as every step keeps its content and reaches the -// same next steps through the same conditions, default routes, non-exclusive -// gateways and gateway settings. +// Whether a recompiled print keeps its source's model, cheapest check first: +// `normalizeIr` equality; that after canonicalizing JUEL and minted ids; and a +// path signature where every step keeps its content and reaches the same next +// steps through the same conditions, defaults and non-trivial gateways. import { isGateway, parseJuel } from '@bpmn-script/transform'; import type { BpmnProcess, FlowElement } from '@bpmn-script/transform'; @@ -28,7 +26,7 @@ function canonicalForm(ir: BpmnProcess): string { } // `toEqual` semantics on a string: sorted keys, `undefined` dropped. -function stable(x: unknown): string { +export function stable(x: unknown): string { return JSON.stringify(x, (_k, v: unknown) => { if (v !== null && typeof v === 'object' && !Array.isArray(v)) { const o = v as Record; @@ -43,13 +41,11 @@ function stable(x: unknown): string { }); } -// Every IR field that can hold an expression body. const EXPRESSION_KEYS = /^(conditionExpression|condition|expression|value|source|collection|cardinality|completionCondition|delegateExpression|code|stringValue)$/; const EXPRESSION_BODY = /^[$#]\{[\s\S]*\}$/; -// A body inside the JUEL subset compares by its parse tree, so quote style and -// spacing do not count; a raw body stays verbatim. +// A body in the JUEL subset compares by parse tree; a raw body stays verbatim. function canonicalExpressions(value: T): T { if (Array.isArray(value)) return value.map(canonicalExpressions) as T; if (value === null || typeof value !== 'object') return value; @@ -71,17 +67,15 @@ function canonicalExpressions(value: T): T { ) as T; } -// `normalizeIr` re-keys gateways, handlers and boundary events but not the -// coordinate ids the compiler mints for unnamed events, a handler body's own -// start and end, and the handler itself (`Catch_p_0_b1`, `Throw_p_2`, -// `EventSubProcess_p_3`), which move whenever the printer reorders or hoists a -// statement. Each is renamed to its content (`#n` on a tie in element order). +// Minted coordinate ids (`Catch_p_0_b1`, `Throw_p_2`, `EventSubProcess_p_3`) +// move whenever the printer reorders or hoists a statement, so each is renamed +// to its content (`#n` on a tie). const COORDINATE_ID = /^(Catch_|Throw_|StartEvent_EventSubProcess_|EndEvent_EventSubProcess_|EndEvent_Boundary_)/; const EVENT_SUB_PROCESS_ID = /^EventSubProcess_/; const COORDINATE_GATEWAY_ID = /^Gateway_.+_(split|join|fork|loop|race)$/; -export function canonicalizeCoordinateIds(container: BpmnProcess): BpmnProcess { +function canonicalizeCoordinateIds(container: BpmnProcess): BpmnProcess { const rename = new Map(); const used = new Map(); const mint = (id: string, base: string): void => { @@ -107,11 +101,9 @@ export function canonicalizeCoordinateIds(container: BpmnProcess): BpmnProcess { } } - // A gateway's coordinate also sits inside the neighbour lists `normalizeIr` - // keys other gateways by, so it is renamed after the events, by its own - // kind and its neighbours under their new names; a second round lets a - // gateway whose neighbours are gateways pick up their first-round names. - // The `_join` suffix survives so `normalizeIr` still inlines a pass-through join. + // Gateways are renamed after the events, by kind and renamed neighbours; the + // second round gives gateway neighbours their first-round names. The role + // suffix survives so `normalizeIr` still inlines a pass-through join. const byId = new Map(elements.map((e) => [e.id, e])); for (let round = 0; round < 2; round++) { const previous = new Map(rename); @@ -158,8 +150,7 @@ export function canonicalizeCoordinateIds(container: BpmnProcess): BpmnProcess { return { ...container, flowElements, sequenceFlows }; } -// A handler's own settings plus its trigger; two handlers with one trigger are -// a validator error, so this cannot tie in a valid program. +// Cannot tie: two handlers with one trigger are a validator error. function handlerContent(handler: FlowElement & { kind: 'subProcess' }): string { const start = handler.flowElements.find((e) => e.kind === 'startEvent'); return stable({ @@ -179,12 +170,8 @@ function ownContent(el: object): Record { return rest; } -/** - * Sorted, readable lines; two IRs keep the same model iff their signatures are - * equal. Per container: one line for its own content, one per non-gateway - * element, and one per pair of non-gateway elements joined through gateways - * only, labelled with what the route crosses. - */ +// Per container: its content, each non-gateway node, and each gateway-only +// route between two nodes labelled with what it crosses. export function modelSignature(ir: BpmnProcess): string[] { return [ ...new Set( @@ -200,10 +187,9 @@ function containerSignature(container: BpmnProcess): string[] { outgoing.set(f.sourceRef, [...(outgoing.get(f.sourceRef) ?? []), f]); } - // An exclusive gateway without settings is pure routing: a hoist, a loop - // printed as a backward `goto` or a step sunk into its only live branch adds - // or removes exactly those, while the conditions on its flows still label - // the route. Any other gateway changes how the route runs and stays in. + // A settings-free exclusive gateway is pure routing that a hoist or a loop + // printed as a `goto` adds or removes; its flow conditions still label the + // route. Any other gateway changes how the route runs. const gatewayMark = (el: FlowElement): string | undefined => { const { name: _name, ...settings } = ownContent(el); return el.kind === 'exclusiveGateway' && Object.keys(settings).length === 1 @@ -214,9 +200,7 @@ function containerSignature(container: BpmnProcess): string[] { const nodes = container.flowElements.filter((el) => !isGateway(el)); const routes: { from: string; route: string; to: string }[] = []; for (const el of nodes) { - // Every simple path through gateways; a gateway already on the current - // path is not re-entered, so gateway-only cycles terminate and the result - // does not depend on flow order. + // Simple paths only, so gateway-only cycles terminate. // ponytail: exponential in the length of a gateway-only chain; fine for // the chains the compiler emits, memoize per gateway if that changes. const walk = (at: string, labels: string[], onPath: Set): void => { @@ -266,12 +250,9 @@ function containerSignature(container: BpmnProcess): string[] { return lines; } -// A node is named by its id and content. The `#n` tie-break on a minted id is -// positional, so it is dropped; nodes that then share a name (identical -// unnamed events) are told apart by the routes into and out of them, refined -// until the partition is stable, and numbered in the sorted order of those -// route shapes. Without that, identical events in two branches whose gotos -// are swapped would produce the same set of lines. +// A node is named by id and content with the positional `#n` dropped; nodes +// that then tie (identical unnamed events) are told apart by colour refinement +// over their routes, or swapped gotos between them would go unnoticed. function nodeKeys( byId: Map, nodes: FlowElement[], @@ -300,8 +281,7 @@ function nodeKeys( ]), ]), ); - // Shapes can grow each round; renaming them to their rank keeps them short - // and still depends only on the graph, not on element order. + // Ranks keep shapes short and independent of element order. const ranked = [...new Set(next.values())].sort(); shape = new Map( [...next].map(([id, s]) => [id, String(ranked.indexOf(s))]), diff --git a/tests/helpers/normalize-ir.test.ts b/tests/helpers/normalize-ir.test.ts index accaced..4983852 100644 --- a/tests/helpers/normalize-ir.test.ts +++ b/tests/helpers/normalize-ir.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from 'vitest'; import { normalizeIr } from './normalize-ir.js'; -import { gatewayDefaultFlowId, isGateway } from '@bpmn-script/transform'; import type { BpmnProcess, + EventDefinition, FlowElement, SequenceFlow, } from '@bpmn-script/transform'; @@ -17,10 +17,7 @@ function process( function boundary( id: string, attachedToRef: string, - eventDefinition: Extract< - FlowElement, - { kind: 'boundaryEvent' } - >['eventDefinition'], + eventDefinition: EventDefinition, cancelActivity?: false, ): FlowElement { return { @@ -29,184 +26,191 @@ function boundary( attachedToRef, eventDefinition, ...(cancelActivity === false ? { cancelActivity } : {}), - }; -} - -function boundaryIds(ir: BpmnProcess): string[] { - return normalizeIr(ir) - .flowElements.filter((fe) => fe.kind === 'boundaryEvent') - .map((fe) => fe.id); + } as FlowElement; } +const ERROR_A = { kind: 'error', errorCode: 'A' } as const; +const ERROR_B = { kind: 'error', errorCode: 'B' } as const; +const CANCEL = { kind: 'message', messageName: 'Cancel' } as const; const TIMER_PT2H = { kind: 'timer', timerKind: 'duration', expression: 'PT2H', } as const; -describe('normalizeIr: boundary-event re-key', () => { - it('keeps two same-host same-trigger boundary handlers distinct by payload, regardless of authored order', () => { - const forward = process( +describe('normalizeIr', () => { + // Each boundary pair differs in one signature axis (payload, host, + // interrupting flag); dropping that axis collapses the pair onto `#1`. + it('re-keys boundaries and gateways by content regardless of authored ids and order, and leaves tasks and events alone', () => { + const tasks: FlowElement[] = [ + { kind: 'userTask', id: 'Pack', name: 'Pack the order' }, + { kind: 'userTask', id: 'Ship' }, + { kind: 'endEvent', id: 'EndEvent_Timeout_Boundary' }, + ]; + const authored = process( [ - { kind: 'userTask', id: 'Pack' }, - boundary('Boundary_Pack_error', 'Pack', { - kind: 'error', - errorCode: 'A', - }), - boundary('Boundary_Pack_error_2', 'Pack', { - kind: 'error', - errorCode: 'B', - }), + ...tasks, + { kind: 'exclusiveGateway', id: 'Decide', name: 'Packed?' }, + boundary('Boundary_Pack_error', 'Pack', ERROR_A), + boundary('Boundary_Pack_error_2', 'Pack', ERROR_B), + boundary('Boundary_Ship_error', 'Ship', ERROR_A), + boundary('Boundary_Pack_message', 'Pack', CANCEL), + boundary('Boundary_Pack_message_2', 'Pack', CANCEL, false), ], [ - { id: 'Flow_A', sourceRef: 'Boundary_Pack_error', targetRef: 'Pack' }, - { - id: 'Flow_B', - sourceRef: 'Boundary_Pack_error_2', - targetRef: 'Pack', - }, + { id: 'Flow_1', sourceRef: 'Pack', targetRef: 'Decide' }, + { id: 'Flow_2', sourceRef: 'Boundary_Pack_error', targetRef: 'Ship' }, ], ); - - const reversed = process( + const reordered = process( [ - { kind: 'userTask', id: 'Pack' }, - boundary('Timeout_Boundary_1', 'Pack', { - kind: 'error', - errorCode: 'B', - }), - boundary('Timeout_Boundary_2', 'Pack', { - kind: 'error', - errorCode: 'A', - }), + boundary('B5', 'Pack', CANCEL, false), + boundary('B4', 'Pack', CANCEL), + boundary('B3', 'Ship', ERROR_A), + boundary('B2', 'Pack', ERROR_B), + boundary('B1', 'Pack', ERROR_A), + { kind: 'exclusiveGateway', id: 'Gateway_p_0_split' }, + ...tasks, ], [ - { id: 'Flow_X', sourceRef: 'Timeout_Boundary_1', targetRef: 'Pack' }, - { id: 'Flow_Y', sourceRef: 'Timeout_Boundary_2', targetRef: 'Pack' }, - ], - ); - - expect(normalizeIr(forward)).toEqual(normalizeIr(reversed)); - expect(new Set(boundaryIds(forward)).size).toBe(2); - }); - - it.each([ - [ - 'the host', - boundary('X', 'Pack', { kind: 'error', errorCode: 'A' }), - boundary('Y', 'Ship', { kind: 'error', errorCode: 'A' }), - ], - [ - 'the interrupting flag', - boundary('X', 'Pack', { kind: 'message', messageName: 'Cancel' }), - boundary('Y', 'Pack', { kind: 'message', messageName: 'Cancel' }, false), - ], - ])( - 'two boundaries alike in all but %s get distinct ids without a positional suffix', - (_axis, a, b) => { - // Drop the axis from the signature and the two collapse onto one - // canonical id told apart only by `#1`. - const ids = boundaryIds( - process( - [ - { kind: 'userTask', id: 'Pack' }, - { kind: 'userTask', id: 'Ship' }, - a, - b, - ], - [], - ), - ); - expect(ids).toHaveLength(2); - expect(ids.filter((id) => id.includes('#'))).toEqual([]); - }, - ); - - it('re-keys gateways and boundaries alone: a task and an end event keep their ids, and only the gateway loses its name', () => { - const ir = process( - [ - { kind: 'userTask', id: 'Review', name: 'Review the claim' }, - { kind: 'exclusiveGateway', id: 'Decide', name: 'Approved?' }, - boundary('Timeout_Boundary', 'Review', TIMER_PT2H), - { kind: 'endEvent', id: 'EndEvent_Timeout_Boundary' }, + { id: 'Flow_X', sourceRef: 'Pack', targetRef: 'Gateway_p_0_split' }, + { id: 'Flow_Y', sourceRef: 'B1', targetRef: 'Ship' }, ], - [{ id: 'Flow_1', sourceRef: 'Review', targetRef: 'Decide' }], ); - expect(normalizeIr(ir).flowElements).toEqual([ - { - kind: 'boundaryEvent', - id: 'Boundary_[host:Review]_[trigger:timer]_[code:duration PT2H]_[interrupting]', - attachedToRef: 'Review', - eventDefinition: TIMER_PT2H, - }, + const errorA = + 'Boundary_[host:Pack]_[trigger:error]_[code:A]_[interrupting]'; + const normalized = normalizeIr(authored); + expect(normalizeIr(reordered)).toEqual(normalized); + expect(normalized.flowElements).toEqual([ + boundary(errorA, 'Pack', ERROR_A), + boundary( + 'Boundary_[host:Pack]_[trigger:error]_[code:B]_[interrupting]', + 'Pack', + ERROR_B, + ), + boundary( + 'Boundary_[host:Pack]_[trigger:message]_[code:Cancel]_[interrupting]', + 'Pack', + CANCEL, + ), + boundary( + 'Boundary_[host:Pack]_[trigger:message]_[code:Cancel]_[non-interrupting]', + 'Pack', + CANCEL, + false, + ), + boundary( + 'Boundary_[host:Ship]_[trigger:error]_[code:A]_[interrupting]', + 'Ship', + ERROR_A, + ), { kind: 'endEvent', id: 'EndEvent_Timeout_Boundary' }, { kind: 'exclusiveGateway', - id: 'Gateway_exclusiveGateway_[in:Review]_[out:]', + id: 'Gateway_exclusiveGateway_[in:Pack]_[out:]', }, - { kind: 'userTask', id: 'Review', name: 'Review the claim' }, + { kind: 'userTask', id: 'Pack', name: 'Pack the order' }, + { kind: 'userTask', id: 'Ship' }, + ]); + expect(normalized.sequenceFlows.map((sf) => sf.id)).toEqual([ + `Flow_${errorA}_Ship`, + 'Flow_Pack_Gateway_exclusiveGateway_[in:Pack]_[out:]', ]); }); -}); -describe('normalizeIr: gateway re-key', () => { - it('gives two structurally identical inclusive forks one canonical id, and re-keys the default flow with them', () => { - // Same shape, different authored ids: the fork, the join and every flow - // between them must canonicalize equal, `defaultFlowId` included. - const shape = (fork: string, join: string, dflt: string): BpmnProcess => - process( - [ - { kind: 'inclusiveGateway', id: fork, defaultFlowId: dflt }, - { kind: 'userTask', id: 'Review' }, - { kind: 'userTask', id: 'Skip' }, - { kind: 'inclusiveGateway', id: join }, - ], - [ - { - id: 'Flow_1', - sourceRef: fork, - targetRef: 'Review', - conditionExpression: '${big}', - }, - { id: dflt, sourceRef: fork, targetRef: 'Skip' }, - { id: 'Flow_3', sourceRef: 'Review', targetRef: join }, - { id: 'Flow_4', sourceRef: 'Skip', targetRef: join }, - ], - ); - - const authored = normalizeIr(shape('Any', 'AllDone', 'ToSkip')); - const synthesized = normalizeIr( - shape( + it.each([ + { + title: 'an inclusive fork and join, default flow re-keyed with the fork', + shape: (fork: string, join: string, dflt: string): BpmnProcess => + process( + [ + { kind: 'inclusiveGateway', id: fork, defaultFlowId: dflt }, + { kind: 'userTask', id: 'Review' }, + { kind: 'userTask', id: 'Skip' }, + { kind: 'inclusiveGateway', id: join }, + ], + [ + { + id: 'Flow_1', + sourceRef: fork, + targetRef: 'Review', + conditionExpression: '${big}', + }, + { id: dflt, sourceRef: fork, targetRef: 'Skip' }, + { id: 'Flow_3', sourceRef: 'Review', targetRef: join }, + { id: 'Flow_4', sourceRef: 'Skip', targetRef: join }, + ], + ), + authored: ['Any', 'AllDone', 'ToSkip'] as const, + synthesized: [ 'Gateway_p_0_fork', 'Gateway_p_0_join', 'Flow_Gateway_p_0_fork_default', - ), - ); - - expect(authored).toEqual(synthesized); - const defaultOf = (ir: BpmnProcess): string | undefined => - ir.flowElements - .filter(isGateway) - .map(gatewayDefaultFlowId) - .find((id) => id !== undefined); - expect(defaultOf(authored)).not.toBe('ToSkip'); - expect( - authored.sequenceFlows.some((sf) => sf.id === defaultOf(authored)), - ).toBe(true); - }); + ] as const, + gateways: [ + { + kind: 'inclusiveGateway', + id: 'Gateway_inclusiveGateway_[in:]_[out:Review,Skip]', + defaultFlowId: + 'Flow_Gateway_inclusiveGateway_[in:]_[out:Review,Skip]_Skip', + }, + { + kind: 'inclusiveGateway', + id: 'Gateway_inclusiveGateway_[in:Review,Skip]_[out:]', + }, + ], + }, + { + title: 'an event-based race', + shape: (id: string): BpmnProcess => + process( + [ + { kind: 'eventBasedGateway', id }, + { + kind: 'intermediateCatchEvent', + id: 'Wait', + eventDefinition: TIMER_PT2H, + }, + { kind: 'userTask', id: 'Escalate' }, + ], + [ + { id: 'Flow_1', sourceRef: 'Escalate', targetRef: id }, + { id: 'Flow_2', sourceRef: id, targetRef: 'Wait' }, + ], + ), + authored: ['FirstOf'] as const, + synthesized: ['Gateway_p_1_race'] as const, + gateways: [ + { + kind: 'eventBasedGateway', + id: 'Gateway_eventBasedGateway_[in:Escalate]_[out:Wait]', + }, + ], + }, + ])( + 'keys $title by position, so authored and synthesized ids meet', + ({ shape, authored, synthesized, gateways }) => { + const build = shape as (...ids: string[]) => BpmnProcess; + const normalized = normalizeIr(build(...authored)); + expect(normalizeIr(build(...synthesized))).toEqual(normalized); + expect( + normalized.flowElements.filter((fe) => fe.kind.endsWith('Gateway')), + ).toEqual(gateways); + }, + ); // Revert: the `subProcess` branch returning its normalized container // without `reKeyedDefault` leaves the block row's default at `Flow_2`. it.each([ [ - 'a task naming a generated route as its default', + 'a task naming a generated route', 'userTask', 'Flow_2', 'Flow_Triage_Skip', ], [ - 'a block naming a generated route as its default', + 'a block naming a generated route', 'subProcess', 'Flow_2', 'Flow_Triage_Skip', @@ -219,26 +223,27 @@ describe('normalizeIr: gateway re-key', () => { kind === 'subProcess' ? { kind, id: 'Triage', flowElements: [], sequenceFlows: [] } : { kind, id: 'Triage' }; - const ir = process( - [ - { - ...triage, - ...(authored === undefined ? {} : { defaultFlowId: authored }), - }, - { kind: 'userTask', id: 'Review' }, - { kind: 'userTask', id: 'Skip' }, - ], - [ - { - id: 'Flow_1', - sourceRef: 'Triage', - targetRef: 'Review', - conditionExpression: '${big}', - }, - { id: 'Flow_2', sourceRef: 'Triage', targetRef: 'Skip' }, - ], + const normalized = normalizeIr( + process( + [ + { + ...triage, + ...(authored === undefined ? {} : { defaultFlowId: authored }), + }, + { kind: 'userTask', id: 'Review' }, + { kind: 'userTask', id: 'Skip' }, + ], + [ + { + id: 'Flow_1', + sourceRef: 'Triage', + targetRef: 'Review', + conditionExpression: '${big}', + }, + { id: 'Flow_2', sourceRef: 'Triage', targetRef: 'Skip' }, + ], + ), ); - const normalized = normalizeIr(ir); expect(normalized.flowElements.find((fe) => fe.id === 'Triage')).toEqual({ ...triage, ...(expected === undefined ? {} : { defaultFlowId: expected }), @@ -250,37 +255,12 @@ describe('normalizeIr: gateway re-key', () => { }, ); - it('re-keys an event-based gateway by its position, the way the other three kinds are re-keyed', () => { - const race = (id: string): BpmnProcess => - process( - [ - { kind: 'eventBasedGateway', id }, - { - kind: 'intermediateCatchEvent', - id: 'Wait', - eventDefinition: TIMER_PT2H, - }, - { kind: 'userTask', id: 'Escalate' }, - ], - [ - { id: 'Flow_1', sourceRef: 'Escalate', targetRef: id }, - { id: 'Flow_2', sourceRef: id, targetRef: 'Wait' }, - ], - ); - - const authored = normalizeIr(race('FirstOf')); - expect(normalizeIr(race('Gateway_p_1_race'))).toEqual(authored); - const gateway = authored.flowElements.find( - (fe) => fe.kind === 'eventBasedGateway', - ); - expect(gateway?.id).toBe( - 'Gateway_eventBasedGateway_[in:Escalate]_[out:Wait]', - ); - }); -}); - -describe('normalizeIr: pass-through join', () => { - const PASS_THROUGH_JOIN = 'Gateway_exclusiveGateway_[in:A,B]_[out:E]'; + const JOIN = 'Gateway_exclusiveGateway_[in:A,B]_[out:E]'; + const TASKS: FlowElement[] = [ + { kind: 'userTask', id: 'A' }, + { kind: 'userTask', id: 'B' }, + { kind: 'userTask', id: 'E' }, + ]; // Revert: drop the settings guard in `inlinePassThroughJoins` -> the kept // row red, its join inlined and its setting gone from the comparison. @@ -288,50 +268,27 @@ describe('normalizeIr: pass-through join', () => { [ 'a pass-through join carrying a setting is kept and re-keyed', { asyncBefore: true as const }, + [...TASKS, { kind: 'exclusiveGateway', id: JOIN, asyncBefore: true }], [ - { kind: 'userTask', id: 'A' }, - { kind: 'userTask', id: 'B' }, - { kind: 'userTask', id: 'E' }, - { kind: 'exclusiveGateway', id: PASS_THROUGH_JOIN, asyncBefore: true }, - ], - [ - { - id: `Flow_A_${PASS_THROUGH_JOIN}`, - sourceRef: 'A', - targetRef: PASS_THROUGH_JOIN, - }, - { - id: `Flow_B_${PASS_THROUGH_JOIN}`, - sourceRef: 'B', - targetRef: PASS_THROUGH_JOIN, - }, - { - id: `Flow_${PASS_THROUGH_JOIN}_E`, - sourceRef: PASS_THROUGH_JOIN, - targetRef: 'E', - }, + { id: `Flow_A_${JOIN}`, sourceRef: 'A', targetRef: JOIN }, + { id: `Flow_B_${JOIN}`, sourceRef: 'B', targetRef: JOIN }, + { id: `Flow_${JOIN}_E`, sourceRef: JOIN, targetRef: 'E' }, ], ], [ 'a pass-through join carrying none is inlined', {}, - [ - { kind: 'userTask', id: 'A' }, - { kind: 'userTask', id: 'B' }, - { kind: 'userTask', id: 'E' }, - ], + TASKS, [ { id: 'Flow_A_E', sourceRef: 'A', targetRef: 'E' }, { id: 'Flow_B_E', sourceRef: 'B', targetRef: 'E' }, ], ], - ] as const)('%s', (_title, settings, flowElements, sequenceFlows) => { + ])('%s', (_title, settings, flowElements, sequenceFlows) => { const ir = process( [ - { kind: 'userTask', id: 'A' }, - { kind: 'userTask', id: 'B' }, + ...TASKS, { kind: 'exclusiveGateway', id: 'Gateway_p_1_join', ...settings }, - { kind: 'userTask', id: 'E' }, ], [ { id: 'Flow_A', sourceRef: 'A', targetRef: 'Gateway_p_1_join' }, @@ -339,11 +296,6 @@ describe('normalizeIr: pass-through join', () => { { id: 'Flow_J', sourceRef: 'Gateway_p_1_join', targetRef: 'E' }, ], ); - - expect(normalizeIr(ir)).toEqual({ - ...ir, - flowElements, - sequenceFlows, - }); + expect(normalizeIr(ir)).toEqual({ ...ir, flowElements, sequenceFlows }); }); }); diff --git a/tests/helpers/normalize-ir.ts b/tests/helpers/normalize-ir.ts index a6db52f..926a652 100644 --- a/tests/helpers/normalize-ir.ts +++ b/tests/helpers/normalize-ir.ts @@ -1,9 +1,7 @@ -// One half of a round-trip comparison carries authored ids, the other the ids -// the pipeline synthesizes (ADR 0010); this canonicalizes that difference away -// before `toEqual`. Gateways, boundaries and event sub-processes are re-keyed -// by structural position, since the handwritten counterpart is hand-named; -// task and event ids are never re-keyed and have to survive verbatim. Flows -// never cross a sub-process boundary, so every step runs per container. +// Canonicalizes authored vs synthesized ids before `toEqual`: gateways, +// boundaries and event sub-processes are re-keyed by structure; task and event +// ids must survive verbatim. Flows never cross a sub-process, so this runs per +// container. import { ENGINE_KEYS } from '@bpmn-script/language'; import { @@ -15,7 +13,6 @@ import { type SequenceFlow, } from '@bpmn-script/transform'; -// The synthesized join family: XOR after `if/else`, AND after `parallel`. const SYNTHESIZED_JOIN_ID = /^Gateway_.+_join$/; export function normalizeIr(ir: BpmnProcess): BpmnProcess { @@ -23,7 +20,7 @@ export function normalizeIr(ir: BpmnProcess): BpmnProcess { } function normalizeContainer(container: T): T { - // Must run before re-keying: it gives both halves the same element set. + // Before re-keying, so both halves have the same element set. const inlined = inlinePassThroughJoins(container); const gatewayIdMap = buildCanonicalIds(inlined, (fe) => @@ -37,9 +34,8 @@ function normalizeContainer(container: T): T { const flowElements: FlowElement[] = inlined.flowElements .map((fe) => { - // A plain sub-process keeps its authored id; a `triggeredByEvent` one - // has no surface id and is re-keyed to its trigger. Its `defaultFlowId` - // names a flow of this container, not of its body. + // A `triggeredByEvent` one has no surface id. Its `defaultFlowId` names + // a flow of this container, not of its body. if (fe.kind === 'subProcess') { const normalized = { ...normalizeContainer(fe), ...reKeyedDefault(fe) }; return fe.triggeredByEvent === true @@ -47,7 +43,6 @@ function normalizeContainer(container: T): T { : normalized; } - // Only its own id moves; `attachedToRef` is an authored host id. if (fe.kind === 'boundaryEvent') { return { ...fe, id: canonicalId(fe.id) }; } @@ -55,15 +50,12 @@ function normalizeContainer(container: T): T { if (!isGateway(fe)) return { ...fe, ...reKeyedDefault(fe) }; const id = canonicalId(fe.id); - // The structured syntax has no slot for a gateway label, so only - // gateways lose their name. + // The structured syntax has no slot for a gateway label. const { name: _name, ...withoutName } = fe; return { ...withoutName, id, ...reKeyedDefault(fe) }; }) .sort((a, b) => a.id.localeCompare(b.id)); - // The key a flow ends up under after `normalizeFlow`, so a `defaultFlowId` - // agrees with the flow it names; absent when the element names none. function reKeyedDefault(fe: FlowElement): { defaultFlowId?: string } { const declared = 'defaultFlowId' in fe ? fe.defaultFlowId : undefined; if (declared === undefined) return {}; @@ -78,7 +70,6 @@ function normalizeContainer(container: T): T { .map((sf) => normalizeFlow(sf, canonicalId)) .sort((a, b) => a.id.localeCompare(b.id)); - // The spread keeps every other field, so this really is a `T`. return { ...container, flowElements, @@ -86,10 +77,9 @@ function normalizeContainer(container: T): T { } as T; } -// A re-synthesized `if/else` grows a join the hand-authored IR never had, so -// such a join is treated as transparent. One carrying a job setting stays: -// inlining it would take the setting out of both sides of the comparison, and -// a `join*` key one direction drops is what the comparison has to catch. +// A re-synthesized `if/else` grows a join the authored IR never had, so such a +// join is transparent. One carrying a job setting stays, or a dropped `join*` +// key would vanish from both sides of the comparison. function inlinePassThroughJoins(ir: FlowContainer): FlowContainer { const successorOf = new Map(); for (const fe of ir.flowElements) { @@ -118,9 +108,7 @@ function inlinePassThroughJoins(ir: FlowContainer): FlowContainer { return { ...ir, flowElements, sequenceFlows }; } -// A `signatureOf` returning undefined skips the element, so each signature -// function also decides its own membership. Ties get a positional `#1`, `#2` -// suffix in flowElements order. +// `undefined` skips the element; ties get a positional `#n` suffix. function buildCanonicalIds( ir: FlowContainer, signatureOf: (fe: FlowElement) => string | undefined, @@ -138,9 +126,8 @@ function buildCanonicalIds( return map; } -// Once the join is inlined, a hand-named gateway and its synthesized twin sit -// at the same topological position, so adjacency keys them equally. `kind` is -// in the key so a XOR and an AND in one position never collapse together. +// After join inlining, a hand-named gateway and its synthesized twin share +// their adjacency. function gatewaySignature( fe: FlowElement, ir: FlowContainer, @@ -159,10 +146,8 @@ function gatewaySignature( return `Gateway_${fe.kind}_[in:${incoming.join(',')}]_[out:${outgoing.join(',')}]`; } -// An `on` handler's sub-process id is a synthesized coordinate that moves when -// a round trip re-orders the container's statements, so key it off the trigger -// start event instead. Two handlers with the same signature are a validator -// error, so they cannot reach here from a valid program. +// A handler's synthesized id moves when statements re-order, so key it by its +// trigger; two handlers with one trigger are a validator error. function eventSubProcessSignature(fe: FlowElement): string | undefined { if (fe.kind !== 'subProcess' || fe.triggeredByEvent !== true) return undefined; @@ -179,10 +164,8 @@ function eventSubProcessSignature(fe: FlowElement): string | undefined { return `EventSubProcess_[trigger:${kind}]_[code:${code}]_[${interrupting}]`; } -// `on Pack: error(A)` and `on Pack: error(B)` both base to -// `Boundary_Pack_error`, and the `_2` suffix that separates them is stable when -// generating but not on import, because moddle may present the children in -// either order. Keying on host plus trigger payload sidesteps that. +// The `_2` suffix separating two same-trigger boundaries on one host is not +// stable on import, since moddle may present them in either order. function boundarySignature(fe: FlowElement): string | undefined { if (fe.kind !== 'boundaryEvent') return undefined; @@ -210,9 +193,8 @@ function definitionPayloadKey(def: EventDefinition | undefined): string { return `${def.timerKind} ${def.expression}`; case 'conditional': return def.condition; - // Payload-free; a container takes one undo block and a block one cancel - // handler, and a terminate is never a trigger, so a constant cannot - // collide. + // Constants cannot collide: one undo block per container, one cancel + // handler per block, and terminate is never a trigger. case 'compensation': return ''; case 'terminate': @@ -226,7 +208,7 @@ function definitionPayloadKey(def: EventDefinition | undefined): string { } } -// `Flow_` is the prefix of every generated flow id. +// `Flow_` prefixes every generated flow id. function normalizeFlow( sf: SequenceFlow, canonicalId: (id: string) => string, diff --git a/tests/helpers/pipeline.ts b/tests/helpers/pipeline.ts index 2bffeec..191749b 100644 --- a/tests/helpers/pipeline.ts +++ b/tests/helpers/pipeline.ts @@ -3,6 +3,7 @@ import { EmptyFileSystem } from 'langium'; import { parseHelper, validationHelper } from 'langium/test'; import { createBpmnScriptServices } from '@bpmn-script/language'; import type { Model } from '@bpmn-script/language'; +import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver-types'; import { astToIr, @@ -18,14 +19,18 @@ const services = createBpmnScriptServices(EmptyFileSystem); export const parse = parseHelper(services.BpmnScript); export const validate = validationHelper(services.BpmnScript); -// For suites that assert printed source; the warnings channel is covered where -// it lives. +export async function validationErrors(source: string): Promise { + const { diagnostics } = await validate(source); + return diagnostics + .filter((d) => d.severity === DiagnosticSeverity.Error) + .map((d) => Diagnostic.getMessageString(d)); +} + export function printDsl(ir: BpmnProcess): string { return irToDsl(ir).source; } -// Throws rather than returning: a round-tripped source that will not re-parse -// is itself a round-trip failure and must abort the test. +// Throws: DSL that will not re-parse is itself a round-trip failure. export async function parseToAst(source: string): Promise { const document = await parse(source); const errors = document.parseResult.parserErrors; @@ -62,9 +67,7 @@ export interface RoundTripRun { hops: IrHops; } -// The cli's build.ts and the extension's conversion-core.ts run the same -// astToIr -> irToXml chain, each with its own failure reporting; here a -// failure throws. Like both, a layout failure keeps the diagram-less XML, +// Like the cli and the extension, a layout failure keeps the diagram-less XML, // since the model it carries is complete. export async function roundTrip(source: string): Promise { const ir1 = astToIr(await parseToAst(source)); @@ -96,8 +99,7 @@ export function roundTripOf(source: string): RoundTripRun { } // The second pass starts from the first's printed DSL, catching a value that -// is stable on the first print but drifts on the second, which one hop each -// direction cannot see. +// drifts only on the second print. export interface RoundTripTwice { xml1: string; dsl1: string; diff --git a/tests/helpers/real-node-reachability.ts b/tests/helpers/real-node-reachability.ts index db92162..3a50d2b 100644 --- a/tests/helpers/real-node-reachability.ts +++ b/tests/helpers/real-node-reachability.ts @@ -1,10 +1,7 @@ import { isGateway, type BpmnProcess } from '@bpmn-script/transform'; -// Gateways are synthesized scaffolding and get fresh ids on every re-desugar, -// so the raw flow-endpoint sets of an imported graph and its round-tripped -// counterpart never match even when nothing was lost. Contracting each gateway -// to a transparent routing point leaves the authored-node connectivity, which a -// lossless round trip does preserve. Returns sorted `source->target` pairs. +// Sorted `source->target` pairs between non-gateway nodes, gateways contracted, +// since re-desugaring gives every gateway a fresh id. export function realNodeReachability(ir: BpmnProcess): string[] { const gatewayById = new Map( ir.flowElements.map((fe) => [fe.id, isGateway(fe)]), diff --git a/tests/helpers/round-trip-fixture.ts b/tests/helpers/round-trip-fixture.ts index 620a802..e4f7916 100644 --- a/tests/helpers/round-trip-fixture.ts +++ b/tests/helpers/round-trip-fixture.ts @@ -1,101 +1,70 @@ -// The shared blocks every golden-pair suite registers, listed under "What the -// pair tests do" in tests/golden/README.md. Suites differ only through -// RoundTripOptions; one needing an extra assertion re-opens the describe in its -// own file. +// The checks every golden-pair suite shares; a suite adds its own in its file. -import { describe, it, expect, beforeAll } from 'vitest'; +import { it, expect, beforeAll } from 'vitest'; import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { Model } from '@bpmn-script/language'; - import { xmlToIr, astToIr, irToXml } from '@bpmn-script/transform'; -import type { BpmnProcess } from '@bpmn-script/transform'; +import type { BpmnProcess, ImportWarning } from '@bpmn-script/transform'; + +import { DiagnosticSeverity } from 'vscode-languageserver-types'; +import { expectSoundLayout } from './di-bounds.js'; import { normalizeIr } from './normalize-ir.js'; -import { irHops, parse, parseToAst, printDsl, validate } from './pipeline.js'; +import { irHops, parseToAst, printDsl, validate } from './pipeline.js'; import type { IrHops } from './pipeline.js'; -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const GOLDEN_DIR = resolve(__dirname, '../golden'); -const EXAMPLES_DIR = resolve(__dirname, '../../examples/spring-boot/processes'); +const GOLDEN_DIR = resolve( + dirname(fileURLToPath(import.meta.url)), + '../golden', +); export interface RoundTripOptions { - // Basename under examples/spring-boot/processes/, asserted validator-clean - // alongside the fixture. Not every construct has one. - example?: string; - - // Where DSL' is read back out of. 'frozen' re-reads the golden .bpmn from - // disk, so the suite is driven by the file rather than by the generator. + // 'frozen' reads DSL' back out of the golden .bpmn instead of the generated XML. dslPrimeFrom?: 'generated' | 'frozen'; - - // Registers the import-path block: the frozen artifact imports warning-free - // and re-desugars normalized-equal to IR1. + // The frozen .bpmn imports warning-free and re-desugars normalized-equal to IR1. importPath?: boolean; - - // 'clean' checks every diagnostic and fits only fixtures that name every - // throw and emit, so nothing synthesizes an id the reserved-name check - // rejects; 'errors' checks error severity alone, for fixtures whose decompiled - // form does warn; 'errors-standalone' is 'errors' in a block of its own. - recompile: 'errors' | 'clean' | 'errors-standalone'; - - validatorCleanTitles?: [describeTitle: string, itTitle: string]; + // 'clean' checks every diagnostic on DSL'; 'errors' only error severity, for + // fixtures with unnamed throws whose synthesized ids draw a reserved-name warning. + recompile: 'errors' | 'clean'; } export interface RoundTrip { - parse: typeof parse; - validate: typeof validate; - parseToAst: (source: string) => Promise; - fixtureSrc: string; frozenXml: string; generatedXml: string; - // ir1 = astToIr(parse(fixture)); ir2 = xmlToIr(...) per dslPrimeFrom; - // ir3 = re-desugared from DSL'. ir1: BpmnProcess; ir2: BpmnProcess; ir3: BpmnProcess; hops: IrHops; dslPrime: string; - // The next two are only filled when `importPath` is on. - importWarnings: Awaited>['warnings']; + // Filled only with `importPath`: the frozen .bpmn imported, printed and re-desugared. irFromImport: BpmnProcess; + importWarnings: ImportWarning[]; } -// `name` is the basename the `.bpmnscript` and `.bpmn` share. The returned -// handle is filled by a beforeAll, so read it only from inside an `it` body. +// `name` is the basename the `.bpmnscript` and `.bpmn` share. The handle is +// filled by a beforeAll, so read it only from inside an `it` body. export function roundTripFixture( name: string, options: RoundTripOptions, ): RoundTrip { - const rt = { parse, validate, parseToAst } as RoundTrip; - let exampleSrc: string; + const rt = {} as RoundTrip; + const read = (ext: string): string => + readFileSync(resolve(GOLDEN_DIR, `${name}.${ext}`), 'utf-8'); beforeAll(async () => { - rt.fixtureSrc = readFileSync( - resolve(GOLDEN_DIR, `${name}.bpmnscript`), - 'utf-8', - ); - rt.frozenXml = readFileSync(resolve(GOLDEN_DIR, `${name}.bpmn`), 'utf-8'); - if (options.example !== undefined) { - exampleSrc = readFileSync( - resolve(EXAMPLES_DIR, `${options.example}.bpmnscript`), - 'utf-8', - ); - } - + rt.fixtureSrc = read('bpmnscript'); + rt.frozenXml = read('bpmn'); rt.ir1 = astToIr(await parseToAst(rt.fixtureSrc)); rt.generatedXml = await irToXml(rt.ir1); - ({ ir: rt.ir2 } = await xmlToIr( options.dslPrimeFrom === 'frozen' ? rt.frozenXml : rt.generatedXml, )); rt.dslPrime = printDsl(rt.ir2); rt.ir3 = astToIr(await parseToAst(rt.dslPrime)); rt.hops = irHops(rt.ir1, rt.ir2, rt.ir3); - if (options.importPath === true) { const imported = await xmlToIr(rt.frozenXml); rt.importWarnings = imported.warnings; @@ -103,81 +72,22 @@ export function roundTripFixture( } }); - describe('golden generation: the pipeline output matches the frozen .bpmn', () => { - it('irToXml(astToIr(parse(fixture))) equals the frozen artifact byte-for-byte', () => { - expect(rt.generatedXml).toBe(rt.frozenXml); - }); - }); - - const idempotenceTitle = - options.dslPrimeFrom === 'frozen' - ? "idempotence: golden .bpmn -> IR2 -> DSL' -> IR3" - : "idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3"; - - describe(idempotenceTitle, () => { - it('normalizeIr(IR1) equals normalizeIr(IR3)', () => { - expect(normalizeIr(rt.ir3)).toEqual(normalizeIr(rt.ir1)); - }); - - it("the restructured DSL' re-parses with zero parser errors", async () => { - const document = await parse(rt.dslPrime); - expect(document.parseResult.parserErrors).toHaveLength(0); - }); - - if (options.recompile === 'errors') { - it('the decompiled DSL recompiles without validation errors', async () => { - const { diagnostics } = await validate(rt.dslPrime); - expect(diagnostics.filter((d) => d.severity === 1)).toEqual([]); - }); - } - - if (options.recompile === 'clean') { - it("the restructured DSL' is validator-clean (named throws re-parse cleanly)", async () => { - const { diagnostics } = await validate(rt.dslPrime); - expect(diagnostics).toEqual([]); - }); - } - }); - - if (options.recompile === 'errors-standalone') { - describe("recompile-validity: the decompiled DSL' recompiles clean", () => { - it("the decompiled DSL' validates with zero error diagnostics", async () => { - const { diagnostics } = await validate(rt.dslPrime); - expect(diagnostics.filter((d) => d.severity === 1)).toEqual([]); - }); - }); - } - - if (options.importPath === true) { - describe('import path: the frozen artifact imports cleanly and round-trips', () => { - it('xmlToIr(frozen) produces no warnings', () => { - expect(rt.importWarnings).toEqual([]); - }); - - it('imported -> DSL -> re-desugared IR is normalized-equal to IR1', () => { - expect(normalizeIr(rt.irFromImport)).toEqual(normalizeIr(rt.ir1)); - }); - }); - } - - const [cleanDescribe, cleanIt] = options.validatorCleanTitles ?? [ - options.example !== undefined - ? 'the authored programs open validator-clean' - : 'the authored program opens validator-clean', - 'the fixture produces no diagnostics at all', - ]; - - describe(cleanDescribe, () => { - it(cleanIt, async () => { - const { diagnostics } = await validate(rt.fixtureSrc); - expect(diagnostics).toEqual([]); - }); - - if (options.example !== undefined) { - it('the deployable example produces no diagnostics at all', async () => { - const { diagnostics } = await validate(exampleSrc); - expect(diagnostics).toEqual([]); - }); + it('regenerates the frozen .bpmn byte for byte with a sound layout, round-trips to the same IR and opens validator-clean', async () => { + expect(rt.generatedXml).toBe(rt.frozenXml); + expectSoundLayout(rt.frozenXml, rt.ir1); + expect(normalizeIr(rt.ir3)).toEqual(normalizeIr(rt.ir1)); + expect((await validate(rt.fixtureSrc)).diagnostics).toEqual([]); + const { diagnostics } = await validate(rt.dslPrime); + expect( + diagnostics.filter( + (d) => + options.recompile === 'clean' || + d.severity === DiagnosticSeverity.Error, + ), + ).toEqual([]); + if (options.importPath === true) { + expect(rt.importWarnings).toEqual([]); + expect(normalizeIr(rt.irFromImport)).toEqual(normalizeIr(rt.ir1)); } }); diff --git a/tests/import-contract.round-trip.test.ts b/tests/import-contract.round-trip.test.ts index fe4949c..1fb5ab8 100644 --- a/tests/import-contract.round-trip.test.ts +++ b/tests/import-contract.round-trip.test.ts @@ -1,9 +1,8 @@ -// One document exercises every construct the import contract converts or -// refuses at once, so an interaction between them (a spurious warning, a -// refusal that stops naming its own construct) shows up here even when each -// construct's own isolated test stays green. +// One document holding every construct the import converts or refuses, so an +// interaction between them (a spurious warning, a refusal naming the wrong +// construct) shows even when each construct's own test stays green. -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { astToIr, @@ -20,16 +19,14 @@ import { UnsupportedExtensionFormError, UnsupportedServiceTaskFormError, } from '@bpmn-script/transform'; -import type { BpmnProcess, ImportWarning } from '@bpmn-script/transform'; +import type { ImportWarning } from '@bpmn-script/transform'; import { bpmnDoc } from './helpers/bpmn-doc.js'; import { normalizeIr } from './helpers/normalize-ir.js'; import { parseToAst, printDsl, validate } from './helpers/pipeline.js'; -// `GlobalScriptTask.script` is declared `isAttr: true` in the moddle schema, -// but the BPMN XSD makes `` an element, so this root exercises -// the case where a root's own unparsable child must not be reported a second -// time against the process. +// moddle declares `GlobalScriptTask.script` an attribute where the XSD has an +// element; the root's unparsable child must not be reported again on the process. const GLOBAL_SCRIPT_TASK_ROOT = ' \n' + ' x\n' + @@ -48,8 +45,7 @@ const FIXTURE = bpmnDoc( ' \n' + ' \n' + ' \n' + - // A literal comparison: a free variable would draw an unrelated "not - // declared" diagnostic. + // A free variable here would draw an unrelated "not declared" diagnostic. ' ${1 < 2}\n' + ' \n' + ' \n' + @@ -107,37 +103,15 @@ const EXPECTED_WARNINGS: ImportWarning[] = [ }, ]; -describe('a document holding every construct the import converts', () => { - const state = {} as { - ir: BpmnProcess; - warnings: ImportWarning[]; - dsl: string; - }; - - beforeAll(async () => { - const { ir, warnings } = await xmlToIr(FIXTURE); - state.ir = ir; - state.warnings = warnings; - state.dsl = printDsl(ir); - }); - - it('imports with exactly the warnings the contract names', () => { - expect(state.warnings).toEqual(EXPECTED_WARNINGS); - }); - - it('the script printed from that import validates clean', async () => { - const { diagnostics } = await validate(state.dsl); - expect(diagnostics).toEqual([]); - }); - - it('the re-desugared IR is normalized-equal to the import', async () => { - const reDesugared = astToIr(await parseToAst(state.dsl)); - expect(normalizeIr(reDesugared)).toEqual(normalizeIr(state.ir)); - }); +it('a document holding every construct the import converts warns exactly per the contract and prints a clean, equivalent script', async () => { + const { ir, warnings } = await xmlToIr(FIXTURE); + expect(warnings).toEqual(EXPECTED_WARNINGS); + const dsl = printDsl(ir); + const { diagnostics } = await validate(dsl); + expect(diagnostics).toEqual([]); + expect(normalizeIr(astToIr(await parseToAst(dsl)))).toEqual(normalizeIr(ir)); }); -// Typed to the `Error` base since the table mixes every refusal class in one -// column; the one caller reading a subclass field narrows itself. async function refusalError( xml: string, errorClass: abstract new (...args: never[]) => Error, @@ -151,31 +125,29 @@ async function refusalError( throw new Error('expected xmlToIr to refuse the document'); } -const CONNECTOR_CHILD = - 'http-connector' + - ''; +// Start `S` -> `id` -> end `E`, with `extra` after the flows. +const oneStep = (id: string, element: string, extra = '') => + bpmnDoc( + ' \n' + + ` ${element}\n` + + ' \n' + + ` \n` + + ` ` + + extra, + ); -const PAIRED_TRIPLE_XML = bpmnDoc( - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' ', -); +const CONNECTOR = + 'http-connector' + + ''; + +const COMPENSATED_ROOM = + '\n' + + ' \n' + + ' \n' + + ' '; -const REWRITE_PREVIEW = [ - 'subprocess Compensated_ReserveRoom {', - ' service ReserveRoom(class: "com.example.Reserve")', - ' on compensation {', - ' user CancelReservation', - ' }', - '}', -].join('\n'); +const association = (target: string) => + `\n `; describe('every construct the import refuses names itself in its own message', () => { it.each([ @@ -195,88 +167,64 @@ describe('every construct the import refuses names itself in its own message', ( ], [ 'a script task with a deployment resource names the resource', - bpmnDoc( - ' \n' + - ' 1 + 1\n' + - ' \n' + - ' \n' + - ' ', + oneStep( + 'Compute', + '1 + 1', ), UnsupportedExtensionFormError, ['names an external resource ("deployment://check.groovy")', "'Compute'"], ], [ 'a connector as the only implementation names the connector', - bpmnDoc( - ' \n' + - ` ${CONNECTOR_CHILD}\n` + - ' \n' + - ' \n' + - ' ', + oneStep( + 'Notify', + `${CONNECTOR}`, ), UnsupportedServiceTaskFormError, [CONNECTOR_CONSTRUCT], ], [ 'a connector beside a class names the connector, not the class', - bpmnDoc( - ' \n' + - ' ' + - `${CONNECTOR_CHILD}\n` + - ' \n' + - ' \n' + - ' ', + oneStep( + 'NotifyBeta', + `${CONNECTOR}`, ), UnsupportedServiceTaskFormError, [CONNECTOR_CONSTRUCT], ], [ 'an unpaired isForCompensation activity falls back to the general wording', - bpmnDoc( - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' ', + oneStep( + 'CancelReservation', + '', ), UnsupportedEventFeatureError, [IS_FOR_COMPENSATION_DETAIL], ], [ 'an unpaired compensation boundary event falls back to the general wording', - bpmnDoc( - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' ', - ), + oneStep('ReserveRoom', COMPENSATED_ROOM), UnsupportedEventFeatureError, [COMPENSATION_BOUNDARY_DETAIL], ], [ 'a paired compensation triple names the compensated activity, the boundary, and the handler', - PAIRED_TRIPLE_XML, + oneStep( + 'ReserveRoom', + COMPENSATED_ROOM + + '\n ', + association('CancelReservation'), + ), UnsupportedEventFeatureError, ['ReserveRoom', 'CompensationBoundary', 'CancelReservation'], ], [ 'a compensation association pointing at a non-handler falls back to the general wording', - bpmnDoc( - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' ', + oneStep( + 'ReserveRoom', + COMPENSATED_ROOM + '\n ', + association('NotAHandler'), ), UnsupportedEventFeatureError, [COMPENSATION_BOUNDARY_DETAIL], @@ -317,25 +265,16 @@ describe('every construct the import refuses names itself in its own message', ( ], [ 'bpmn:adHocSubProcess stays refused on engine evidence', - bpmnDoc( - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' ', + oneStep( + 'T', + '', ), UnsupportedElementError, ['bpmn:AdHocSubProcess'], ], [ 'bpmn:complexGateway stays refused on engine evidence', - bpmnDoc( - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' ', - ), + oneStep('T', ''), UnsupportedElementError, ['bpmn:ComplexGateway'], ], @@ -345,24 +284,4 @@ describe('every construct the import refuses names itself in its own message', ( expect(error.message).toContain(needle); } }); - - it("the paired triple's printed rewrite re-parses through the compiler", async () => { - const error = (await refusalError( - PAIRED_TRIPLE_XML, - UnsupportedEventFeatureError, - )) as UnsupportedEventFeatureError; - // `.detail` is the raw refusal text, unlike `.message`, which trails it - // with the generic event-surface closing sentence right after the - // rewrite's own closing brace, with no blank line to split on. - const marker = 'Write it by hand instead:\n\n'; - const cut = error.detail.indexOf(marker); - expect(cut).toBeGreaterThan(-1); - - const rewrite = error.detail.slice(cut + marker.length); - expect(rewrite).toBe(REWRITE_PREVIEW); - - const wrapped = `process Preview {\n${rewrite}\n}\n`; - const { diagnostics } = await validate(wrapped); - expect(diagnostics).toEqual([]); - }); }); diff --git a/tests/input-output.round-trip.test.ts b/tests/input-output.round-trip.test.ts index 5e764c2..f6a3e53 100644 --- a/tests/input-output.round-trip.test.ts +++ b/tests/input-output.round-trip.test.ts @@ -1,8 +1,7 @@ -// The zero-warning import roundTripFixture registers is the load-bearing -// assertion here: a parameter the importer cannot represent surfaces as an -// extensionAttribute warning, not as a difference in the IR. +// A parameter the importer cannot represent surfaces as an import warning, +// not as an IR difference, so the fixture's zero-warning import is the check. -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; import type { FlowContainer, @@ -10,7 +9,6 @@ import type { IoValue, } from '@bpmn-script/transform'; -import { describeDiContainment } from './helpers/di-bounds.js'; import { allElements, theOnly } from './helpers/ir-query.js'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; @@ -37,8 +35,6 @@ function carriesParameters(el: FlowElement): el is ParameterCarrier { return (PARAMETER_CARRIERS as readonly string[]).includes(el.kind); } -// Structural, not the DSL spelling: an expectation written in the printer's own -// output would move whenever the printer does. function renderValue(value: IoValue): string { switch (value.kind) { case 'text': @@ -57,9 +53,7 @@ function renderValue(value: IoValue): string { } } -// Keyed by carrier id, so the comparison is blind to the order flow elements -// come back in but strict about the order inside one carrier, which is what the -// engine evaluates. +// Blind to element order, strict about parameter order within a carrier. function parameterSignatures( container: FlowContainer, ): Record { @@ -103,87 +97,59 @@ const EXPECTED_PARAMETERS: Record = { ], }; -describe("idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3", () => { - it('every parameter keeps its direction, name, and value shape, in order, at every hop', () => { - for (const [label, ir] of rt.hops) { - expect(parameterSignatures(ir), `parameters differ in ${label}`).toEqual( - EXPECTED_PARAMETERS, - ); - } - }); - - it('every map key prints quoted, keyword-shaped or not', () => { - const literals = rt.dslPrime - .split('\n') - .map((line) => line.trim()) - .filter((line) => /^(input|output) \w+ = [[{]/.test(line)); - expect(literals).toEqual([ - 'input auditChecks = [{ "field": "receipts", "rule": "present" }, { "field": "mileage", "rule": "within-policy" }]', - 'output payoutReceipt = { "start": "${payoutStart}", "lines": ["principal", "vat"] }', - ]); - }); -}); - -describe('the call activity keeps its variable mappings and its parameters apart', () => { - it('both mechanisms survive on the same node at every hop, neither absorbing the other', () => { - for (const [label, ir] of rt.hops) { - const call = theOnly(ir, 'callActivity', (c) => c.id === 'PayOut'); - - expect(call.inMappings, `in mappings differ in ${label}`).toEqual([ - { kind: 'variable', source: 'claimTotal', target: 'claimTotal' }, - ]); - expect(call.outMappings, `out mappings differ in ${label}`).toEqual([ +it('keeps every parameter in order at every hop, the call activity its mappings beside them, and prints every map key quoted', () => { + for (const [label, ir] of rt.hops) { + expect(parameterSignatures(ir), label).toEqual(EXPECTED_PARAMETERS); + const call = theOnly(ir, 'callActivity', (c) => c.id === 'PayOut'); + expect([call.inMappings, call.outMappings], label).toEqual([ + [{ kind: 'variable', source: 'claimTotal', target: 'claimTotal' }], + [ { kind: 'variable', source: 'paymentReference', target: 'paymentReference', }, - ]); - expect( - call.inputParameters?.map((p) => p.name), - `input parameters differ in ${label}`, - ).toEqual(['payoutChannel']); - expect( - call.outputParameters?.map((p) => p.name), - `output parameters differ in ${label}`, - ).toEqual(['payoutReceipt']); - } - }); - - // Variable mappings sit beside the inputOutput block, not inside it. Nested - // in it they would still be present but would stop being read as mappings. - it('the frozen call activity serializes both, as siblings under extensionElements', () => { - expect( - //.exec( - rt.frozenXml, - )?.[0], - ).toBe( - [ - '', - ' ', - ' ', - ' sepa', - ' ', - ' ', - ' ${payoutStart}', - ' ', - ' ', - ' principal', - ' vat', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' Flow_AuditClaim_PayOut', - ' Flow_PayOut_ClaimSettled', - ' ', - ].join('\n'), - ); - }); + ], + ]); + } + const literals = rt.dslPrime + .split('\n') + .map((line) => line.trim()) + .filter((line) => /^(input|output) \w+ = [[{]/.test(line)); + expect(literals).toEqual([ + 'input auditChecks = [{ "field": "receipts", "rule": "present" }, { "field": "mileage", "rule": "within-policy" }]', + 'output payoutReceipt = { "start": "${payoutStart}", "lines": ["principal", "vat"] }', + ]); }); -describeDiContainment(rt); +// Nested inside inputOutput, the mappings would stop being read as mappings. +it('writes the call activity mappings as siblings of its inputOutput block', () => { + expect( + //.exec(rt.frozenXml)?.[0], + ).toBe( + [ + '', + ' ', + ' ', + ' sepa', + ' ', + ' ', + ' ${payoutStart}', + ' ', + ' ', + ' principal', + ' vat', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' Flow_AuditClaim_PayOut', + ' Flow_PayOut_ClaimSettled', + ' ', + ].join('\n'), + ); +}); diff --git a/tests/intermediate-catch.round-trip.test.ts b/tests/intermediate-catch.round-trip.test.ts index abe98c3..76379a2 100644 --- a/tests/intermediate-catch.round-trip.test.ts +++ b/tests/intermediate-catch.round-trip.test.ts @@ -1,79 +1,27 @@ -import { describe, it, expect } from 'vitest'; - -import type { FlowContainer, FlowElement } from '@bpmn-script/transform'; +import { it, expect } from 'vitest'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; const rt = roundTripFixture('intermediate-catch', { dslPrimeFrom: 'frozen', - recompile: 'errors-standalone', + recompile: 'errors', }); -type IntermediateCatchEvent = Extract< - FlowElement, - { kind: 'intermediateCatchEvent' } ->; - -function catchEvents(container: FlowContainer): IntermediateCatchEvent[] { - return container.flowElements.filter( - (fe): fe is IntermediateCatchEvent => fe.kind === 'intermediateCatchEvent', +it('prints each catch as a bare `await` on its trigger, never the synthesized id', () => { + expect(rt.dslPrime).toBe( + [ + 'process order-processing {', + ' var amount: any', + ' start OrderReceived', + ' user ReviewOrder(label: "Review the order", assignee: "demo")', + ' await message("PaymentConfirmed")', + ' await timer("PT1H")', + ' await signal("StockReplenished")', + ' await condition(amount > 100)', + ' service DispatchOrder(label: "Dispatch the order", class: "com.example.orders.DispatchDelegate")', + ' end OrderDispatched', + '}', + '', + ].join('\n'), ); -} - -// Trigger and payload only: the surface has no name slot and the id is always -// synthesized. -function triggerSignature(catchEvent: IntermediateCatchEvent): string { - const def = catchEvent.eventDefinition; - switch (def.kind) { - case 'message': - return `message ${def.messageName}`; - case 'signal': - return `signal ${def.signalName}`; - case 'link': - return `link ${def.linkName}`; - case 'timer': - return `timer ${def.timerKind} ${def.expression}`; - case 'conditional': - return `condition ${def.condition}`; - } -} - -function triggerSignatures(container: FlowContainer): string[] { - return catchEvents(container).map(triggerSignature); -} - -const EXPECTED_TRIGGERS = [ - 'message PaymentConfirmed', - 'timer duration PT1H', - 'signal StockReplenished', - 'condition ${amount > 100}', -]; - -describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { - it('every catch keeps its trigger and payload, in order, at every hop', () => { - for (const [label, ir] of rt.hops) { - expect(triggerSignatures(ir), `triggers differ in ${label}`).toEqual( - EXPECTED_TRIGGERS, - ); - } - }); - - it("the decompiled DSL' prints each catch as a bare `await` on its trigger, never the synthesized id", () => { - expect(rt.dslPrime).toBe( - [ - 'process order-processing {', - ' var amount: any', - ' start OrderReceived', - ' user ReviewOrder(label: "Review the order", assignee: "demo")', - ' await message("PaymentConfirmed")', - ' await timer("PT1H")', - ' await signal("StockReplenished")', - ' await condition(amount > 100)', - ' service DispatchOrder(label: "Dispatch the order", class: "com.example.orders.DispatchDelegate")', - ' end OrderDispatched', - '}', - '', - ].join('\n'), - ); - }); }); diff --git a/tests/invoice-receipt.import.test.ts b/tests/invoice-receipt.import.test.ts index 41f5ffe..b0214bb 100644 --- a/tests/invoice-receipt.import.test.ts +++ b/tests/invoice-receipt.import.test.ts @@ -1,16 +1,13 @@ -// The Operaton invoice example: a pool wrapping three lanes, a data store and -// its reference, an unreferenced message root, and an approve/review loop -// whose inner split names no default. It imports with warnings, which -// `describeImportFirst` refuses, so the pipeline runs by hand and pins that -// the whole file survives import, print, validation and a second import. +// The Operaton invoice example (pool, lanes, data store, unreferenced message, +// approve/review loop whose inner split names no default) imports with +// warnings, which `describeImportFirst` refuses, so this runs by hand. -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { xmlToIr, astToIr, irToDsl, irToXml } from '@bpmn-script/transform'; -import type { BpmnProcess } from '@bpmn-script/transform'; import { realNodeReachability } from './helpers/real-node-reachability.js'; import { parseToAst, validate } from './helpers/pipeline.js'; @@ -57,28 +54,13 @@ const PRINTED = [ '', ].join('\n'); -describe('the Operaton invoice example', () => { - let imported: BpmnProcess; - let importWarnings: Awaited>['warnings']; - let dsl: string; - let printWarnings: ReturnType['warnings']; - let reDesugared: BpmnProcess; - - beforeAll(async () => { - ({ ir: imported, warnings: importWarnings } = await xmlToIr(FIXTURE)); - const printed = irToDsl(imported); - dsl = printed.source; - printWarnings = printed.warnings; - reDesugared = astToIr(await parseToAst(dsl)); - }); +const categories = (ws: { elementId?: string; category: string }[]) => + ws.map((w) => ({ elementId: w.elementId, category: w.category })); - it('imports with exactly the warnings the pool, lanes and dropped extras produce', () => { - expect( - importWarnings.map((w) => ({ - elementId: w.elementId, - category: w.category, - })), - ).toEqual([ +describe('the Operaton invoice example', () => { + it('imports with the pool, lane and dropped-extra warnings, prints one clean structured script, and re-desugars to the same reachability', async () => { + const { ir: imported, warnings: importWarnings } = await xmlToIr(FIXTURE); + expect(categories(importWarnings)).toEqual([ { elementId: 'Accountant', category: 'lane' }, { elementId: 'teamAssistant', category: 'lane' }, { elementId: 'Approver', category: 'lane' }, @@ -95,33 +77,21 @@ describe('the Operaton invoice example', () => { { elementId: 'foxMessage_en', category: 'unreferencedRoot' }, { elementId: 'FinancialAccountingSystem', category: 'unmappedConstruct' }, ]); - }); - it('prints the whole process as one structured script', () => { + const { source: dsl, warnings: printWarnings } = irToDsl(imported); expect(dsl).toEqual(PRINTED); - - expect( - printWarnings.map((w) => ({ - elementId: w.elementId, - category: w.category, - })), - ).toEqual([ + expect(categories(printWarnings)).toEqual([ { elementId: 'invoice_approved', category: 'label' }, { elementId: 'reviewSuccessful_gw', category: 'label' }, { elementId: 'invoice_approved', category: 'defaultFlow' }, { elementId: 'reviewSuccessful_gw', category: 'droppedCondition' }, ]); - }); - - it('validates with no diagnostics at all', async () => { - const { diagnostics } = await validate(dsl); - expect(diagnostics).toEqual([]); - }); + expect((await validate(dsl)).diagnostics).toEqual([]); - it('re-desugars to the import reachability plus the join edges the default-less split invents, and re-imports clean', async () => { - // `invoice_approved` names no default: the else-less `if`/`else if` chain - // the printer emits for it falls through to the loop gateway on - // recompile, which gains `approveInvoice` every pair that gateway reaches. + // `invoice_approved` names no default, so the printed else-less chain + // falls through to the loop gateway on recompile, which gains + // `approveInvoice` every pair that gateway reaches. + const reDesugared = astToIr(await parseToAst(dsl)); expect(realNodeReachability(reDesugared)).toEqual( [ ...realNodeReachability(imported), @@ -129,10 +99,6 @@ describe('the Operaton invoice example', () => { 'approveInvoice->invoiceNotProcessed', ].sort(), ); - - const { warnings: secondImportWarnings } = await xmlToIr( - await irToXml(reDesugared), - ); - expect(secondImportWarnings).toEqual([]); + expect((await xmlToIr(await irToXml(reDesugared))).warnings).toEqual([]); }); }); diff --git a/tests/link-events.round-trip.test.ts b/tests/link-events.round-trip.test.ts index 07a24fd..e12311a 100644 --- a/tests/link-events.round-trip.test.ts +++ b/tests/link-events.round-trip.test.ts @@ -1,16 +1,9 @@ -// A link throw ends its path and a link catch opens one, matched by name and -// never by a flow, so every hop of the round trip must keep all five link -// events under their authored ids and names, with no flow leaving a throw or -// entering a catch, in the sub-process as at the top. +// A link throw ends its path and a catch opens one, matched by name, never by a flow. -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; import type { FlowContainer } from '@bpmn-script/transform'; -import { - describeDiContainment, - describeNoOverlappingShapes, -} from './helpers/di-bounds.js'; import { allElements, allOf } from './helpers/ir-query.js'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; @@ -45,101 +38,92 @@ function containersOf(ir: FlowContainer): FlowContainer[] { return [ir, ...allOf(ir, 'subProcess')]; } -describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { - it('both ends of every pair keep their link name at every hop, and no flow leaves a throw or enters a catch', () => { - for (const [label, ir] of rt.hops) { - const linkEvents = allElements(ir) - .filter( - (fe) => - fe.kind === 'intermediateThrowEvent' || - fe.kind === 'intermediateCatchEvent', - ) - .map((fe) => [fe.kind, fe.id, fe.eventDefinition]) - .sort(([, a], [, b]) => (a as string).localeCompare(b as string)); - expect(linkEvents, `link event set differs in ${label}`).toEqual( - LINK_EVENTS, - ); +it('both ends of every pair keep their link name at every hop, no flow leaves a throw or enters a catch, and the print keeps the chains structured', () => { + for (const [label, ir] of rt.hops) { + const linkEvents = allElements(ir) + .filter( + (fe) => + fe.kind === 'intermediateThrowEvent' || + fe.kind === 'intermediateCatchEvent', + ) + .map((fe) => [fe.kind, fe.id, fe.eventDefinition]) + .sort(([, a], [, b]) => (a as string).localeCompare(b as string)); + expect(linkEvents, `link event set differs in ${label}`).toEqual( + LINK_EVENTS, + ); - for (const container of containersOf(ir)) { - expect( - container.sequenceFlows.filter( - (flow) => - THROW_IDS.has(flow.sourceRef) || CATCH_IDS.has(flow.targetRef), - ), - `a flow touches a link event's closed side in ${container.id} of ${label}`, - ).toEqual([]); - } + for (const container of containersOf(ir)) { + expect( + container.sequenceFlows.filter( + (flow) => + THROW_IDS.has(flow.sourceRef) || CATCH_IDS.has(flow.targetRef), + ), + `a flow touches a link event's closed side in ${container.id} of ${label}`, + ).toEqual([]); } - }); - - it("the decompiled DSL' places each catch after a dead fall-through and keeps the rework chain structured", () => { - expect(rt.dslPrime).toBe( - [ - 'process claim-review {', - ' start Filed(label: "A claim is filed") {', - ' form {', - ' approved: boolean "Approved?"', - ' amount: number "Claim amount"', - ' receiptsComplete: boolean "Receipts complete?"', - ' }', - ' }', - ' user AssessClaim(label: "Assess the claim", assignee: "demo")', - ' if (!receiptsComplete) {', - ' goto ReworkMissingReceipts', - ' } else if (!approved) {', - ' goto ReworkRejected', - ' }', - ' subprocess Settlement(label: "Settle the claim") {', - ' start SettlementStart', - ' service PayClaim(label: "Pay the claim", class: "com.example.claims.PayClaimDelegate")', - ' if (amount > 1000) {', - ' goto ToAudit', - ' }', - ' end Settled', - ' emit link ToAudit("Audit")', - ' await link AtAudit("Audit")', - ' user AuditPayout(label: "Audit the payout", assignee: "manager")', - ' end Audited', - ' }', - ' end Closed(label: "Claim closed")', - ' emit link ReworkMissingReceipts("Rework")', - ' emit link ReworkRejected("Rework")', - ' await link AtRework("Rework", asyncBefore: true)', - ' user ReworkClaim(label: "Rework the claim", assignee: "demo")', - ' if (amount > 5000) {', - ' user EscalateRework(label: "Escalate the rework", assignee: "manager")', - ' } else {', - ' user NoteRework(label: "Note the rework", assignee: "demo")', - ' }', - ' goto AssessClaim', - '}', - '', - ].join('\n'), - ); - }); + } + // Each catch prints after a dead fall-through, the rework chain structured. + expect(rt.dslPrime).toBe( + [ + 'process claim-review {', + ' start Filed(label: "A claim is filed") {', + ' form {', + ' approved: boolean "Approved?"', + ' amount: number "Claim amount"', + ' receiptsComplete: boolean "Receipts complete?"', + ' }', + ' }', + ' user AssessClaim(label: "Assess the claim", assignee: "demo")', + ' if (!receiptsComplete) {', + ' goto ReworkMissingReceipts', + ' } else if (!approved) {', + ' goto ReworkRejected', + ' }', + ' subprocess Settlement(label: "Settle the claim") {', + ' start SettlementStart', + ' service PayClaim(label: "Pay the claim", class: "com.example.claims.PayClaimDelegate")', + ' if (amount > 1000) {', + ' goto ToAudit', + ' }', + ' end Settled', + ' emit link ToAudit("Audit")', + ' await link AtAudit("Audit")', + ' user AuditPayout(label: "Audit the payout", assignee: "manager")', + ' end Audited', + ' }', + ' end Closed(label: "Claim closed")', + ' emit link ReworkMissingReceipts("Rework")', + ' emit link ReworkRejected("Rework")', + ' await link AtRework("Rework", asyncBefore: true)', + ' user ReworkClaim(label: "Rework the claim", assignee: "demo")', + ' if (amount > 5000) {', + ' user EscalateRework(label: "Escalate the rework", assignee: "manager")', + ' } else {', + ' user NoteRework(label: "Note the rework", assignee: "demo")', + ' }', + ' goto AssessClaim', + '}', + '', + ].join('\n'), + ); }); -describe('the frozen .bpmn carries the link pair the engine matches on', () => { - it('names both ends of each pair with the link name and carries asyncBefore on the catch alone', () => { - const named = [ +it('names both ends of each pair with the link name and carries asyncBefore on the catch alone', () => { + const named = [ + ...rt.frozenXml.matchAll( + / [id, name]) + .sort(([a], [b]) => a!.localeCompare(b!)); + expect(named).toEqual(LINK_EVENTS.map(([, id, def]) => [id, def.linkName])); + + expect(rt.frozenXml.match(/operaton:asyncBefore="true"/g)).toHaveLength(1); + expect( + [ ...rt.frozenXml.matchAll( - /]*operaton:asyncBefore="true"/g, ), - ] - .map(([, id, name]) => [id, name]) - .sort(([a], [b]) => a!.localeCompare(b!)); - expect(named).toEqual(LINK_EVENTS.map(([, id, def]) => [id, def.linkName])); - - expect(rt.frozenXml.match(/operaton:asyncBefore="true"/g)).toHaveLength(1); - expect( - [ - ...rt.frozenXml.matchAll( - /]*operaton:asyncBefore="true"/g, - ), - ].map(([, id]) => id), - ).toEqual(['AtRework']); - }); + ].map(([, id]) => id), + ).toEqual(['AtRework']); }); - -describeNoOverlappingShapes(rt); -describeDiContainment(rt, ['Settlement', 'ToAudit', 'AtAudit']); diff --git a/tests/listeners.round-trip.test.ts b/tests/listeners.round-trip.test.ts index 4f39a58..b71e82c 100644 --- a/tests/listeners.round-trip.test.ts +++ b/tests/listeners.round-trip.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; import type { FieldInjection, @@ -9,7 +9,6 @@ import type { } from '@bpmn-script/transform'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; -import { describeDiContainment } from './helpers/di-bounds.js'; import { allElements } from './helpers/ir-query.js'; const rt = roundTripFixture('listeners', { @@ -18,11 +17,9 @@ const rt = roundTripFixture('listeners', { recompile: 'clean', }); -// Exact body of the fixture's inline-script listener, trailing newline included. const SCRIPT_BODY = 'task.setVariable("assessmentRevised", true);\n'; -// The handler header has no id slot, so this id is synthesized from the -// statement's position. +// The handler header has no id slot, so this id is positional. const WITHDRAWAL_HANDLER = 'EventSubProcess_claim-settlement_5'; // ` `, in the order a depth-first walk reaches them. @@ -40,10 +37,7 @@ const EXPECTED_LISTENERS = [ `${WITHDRAWAL_HANDLER} start class=com.example.claims.LogWithdrawal`, ]; -// ` =`, where a listener's carrier is the element and the -// event it fires on. Listed separately from the binding signatures above, -// because those format the binding's own shape and would still match with every -// field gone. +// ` =`; a listener's carrier is ` `. const EXPECTED_FIELDS = [ 'ValidateClaim policyRegister=motor-policies', 'ValidateClaim settlementCurrency=${claim.currency}', @@ -56,8 +50,6 @@ type AnyBinding = | VersionBinding | NonNullable['binding']>; -// Only the two kinds Operaton injects into declare the slot at all, which is -// what makes a field on any other binding unrepresentable rather than refused. function fieldsOf(binding: AnyBinding): FieldInjection[] { return binding.kind === 'class' || binding.kind === 'delegateExpression' ? (binding.fields ?? []) @@ -77,14 +69,10 @@ function bindingSignature(binding: ListenerBinding): string { } } -// A binding and what names it: an element's own, then its execution -// listeners', then a user task's task listeners', which is the order both the -// serializer and the printer emit. Both contracts below read this one walk, so -// a carrier reaches both or neither. +// An element's own binding, then its execution listeners', then its task +// listeners': the order both the serializer and the printer emit. type Carried = - /** `carrier` is the element's id on its own binding. */ | { on: 'element'; carrier: string; binding: AnyBinding } - /** ` `, plus the clause only a timeout listener adds. */ | { on: 'listener'; carrier: string; @@ -140,56 +128,26 @@ function fieldSignatures(container: FlowContainer): string[] { ); } -describe("idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3", () => { - it('every injected field keeps its carrier, name, and value at every hop', () => { - for (const [label, ir] of rt.hops) { - expect(fieldSignatures(ir), `fields differ in ${label}`).toEqual( - EXPECTED_FIELDS, - ); - } - }); - - it('every listener keeps its carrier, event, and binding at every hop', () => { - for (const [label, ir] of rt.hops) { - expect(listenerSignatures(ir), `listeners differ in ${label}`).toEqual( - EXPECTED_LISTENERS, - ); - } - }); - - it('the inline-script listener keeps its body and language tag through the decompile', () => { - expect(rt.dslPrime).toContain('```javascript\n' + SCRIPT_BODY + '```'); - }); +it('keeps every listener and injected field on its carrier at every hop, the script body under its language tag', () => { + for (const [label, ir] of rt.hops) { + expect(fieldSignatures(ir), label).toEqual(EXPECTED_FIELDS); + expect(listenerSignatures(ir), label).toEqual(EXPECTED_LISTENERS); + } + expect(rt.dslPrime).toContain('```javascript\n' + SCRIPT_BODY + '```'); }); -describe('golden generation: the pipeline output matches the frozen .bpmn', () => { - it.each<[title: string, fragment: string]>([ +it('writes the timeout listener with the id and timer child BpmnParse.parseTimeoutTaskListener requires, the script verbatim, bindings unprefixed', () => { + for (const fragment of [ [ - 'the timeout listener carries the id BpmnParse.parseTimeoutTaskListener requires and its timer as a bpmn:timerEventDefinition child', - [ - '', - ' ', - ' PT8H', - ' ', - '', - ].join('\n '), - ], - [ - 'the inline-script listener writes its body verbatim under its language tag', - `${SCRIPT_BODY}`, - ], - [ - 'a listener binding is written unprefixed on its already-qualified element', - '', - ], - ])('%s', (_title, fragment) => { + '', + ' ', + ' PT8H', + ' ', + '', + ].join('\n '), + `${SCRIPT_BODY}`, + '', + ]) { expect(rt.frozenXml).toContain(fragment); - }); + } }); - -describeDiContainment(rt, [ - 'AssessDamage', - 'InspectVehicle', - WITHDRAWAL_HANDLER, - 'CancelSettlement', -]); diff --git a/tests/mail-and-shell.round-trip.test.ts b/tests/mail-and-shell.round-trip.test.ts index d61b77a..fcb7b2e 100644 --- a/tests/mail-and-shell.round-trip.test.ts +++ b/tests/mail-and-shell.round-trip.test.ts @@ -1,14 +1,8 @@ -// The three tags that take a `type` binding each carry one of the two -// behaviours Operaton builds itself, with the fields the engine sets on it, so -// a field that stops travelling in one direction or a type that comes back in -// another spelling fails the whole-object comparison below. +import { it, expect } from 'vitest'; -import { describe, it, expect } from 'vitest'; - -import type { BpmnProcess, ServiceTaskBinding } from '@bpmn-script/transform'; +import type { ServiceTaskBinding } from '@bpmn-script/transform'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; -import { describeNoOverlappingShapes } from './helpers/di-bounds.js'; import { bindingOf } from './helpers/ir-query.js'; const rt = roundTripFixture('mail-and-shell', { @@ -50,79 +44,63 @@ const BUILTIN_BINDINGS: Record = { }, }; -describe('the frozen mail-and-shell binding contract', () => { - it('every field survives at each hop and through import, on all three tags', () => { - const runs: (readonly [label: string, ir: BpmnProcess])[] = [ - ...rt.hops, - ['imported', rt.irFromImport], - ]; - for (const [label, ir] of runs) { - for (const id of Object.keys(BUILTIN_BINDINGS)) { - expect( - bindingOf(ir, id), - `${id}.binding differs in ${label}`, - ).toStrictEqual(BUILTIN_BINDINGS[id]); - } +it('keeps every field on all three tags at each hop and through import, the type on the head, the fields in both value slots', () => { + for (const [label, ir] of [ + ...rt.hops, + ['imported', rt.irFromImport], + ] as const) { + for (const [id, binding] of Object.entries(BUILTIN_BINDINGS)) { + expect(bindingOf(ir, id), `${id} in ${label}`).toStrictEqual(binding); } - }); - - it('the frozen artifact writes the type on each tag and the fields in both value slots', () => { - expect(rt.frozenXml).toContain( - '\n' + - ' \n' + - ' \n' + - ' ', - ); - expect(rt.frozenXml).toContain( - '\n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' ', - ); - expect(rt.frozenXml).toContain( - '\n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' ${summary}\n' + - ' \n' + - ' \n' + - ' \n' + - ' ${details}\n' + - ' \n' + - ' ', - ); - }); - - // Every head and field line, so a field printed on a tag that carries none - // fails too; the fixture harness re-parses and validates DSL'. - it('the printed script carries the type on the head and the fields as members', () => { - const lines = rt.dslPrime - .split('\n') - .map((line) => line.trim()) - .filter((line) => /^(service|send|decide|field) /.test(line)); - expect(lines).toEqual([ - 'decide RateSeverity(label: "Rate the severity", type: "shell") {', - 'field command = "rate-severity"', - 'send PageOnCall(label: "Page the on-call engineer", type: "shell") {', - 'field command = "page"', - 'field arg1 = "oncall"', - 'field outputVariable = "pageReceipt"', - 'field errorCodeVariable = "pageExitCode"', - 'field wait = "true"', - 'service MailOnCall(label: "Mail the on-call engineer", type: "mail") {', - 'field to = "oncall@example.com"', - 'field cc = "ops-lead@example.com"', - 'field subject = "${summary}"', - 'field text = "An incident was logged. Open the HTML part for its details."', - 'field html = "${details}"', - ]); - }); + } + expect(rt.frozenXml).toContain( + '\n' + + ' \n' + + ' \n' + + ' ', + ); + expect(rt.frozenXml).toContain( + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' ', + ); + expect(rt.frozenXml).toContain( + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' ${summary}\n' + + ' \n' + + ' \n' + + ' \n' + + ' ${details}\n' + + ' \n' + + ' ', + ); + const lines = rt.dslPrime + .split('\n') + .map((line) => line.trim()) + .filter((line) => /^(service|send|decide|field) /.test(line)); + expect(lines).toEqual([ + 'decide RateSeverity(label: "Rate the severity", type: "shell") {', + 'field command = "rate-severity"', + 'send PageOnCall(label: "Page the on-call engineer", type: "shell") {', + 'field command = "page"', + 'field arg1 = "oncall"', + 'field outputVariable = "pageReceipt"', + 'field errorCodeVariable = "pageExitCode"', + 'field wait = "true"', + 'service MailOnCall(label: "Mail the on-call engineer", type: "mail") {', + 'field to = "oncall@example.com"', + 'field cc = "ops-lead@example.com"', + 'field subject = "${summary}"', + 'field text = "An incident was logged. Open the HTML part for its details."', + 'field html = "${details}"', + ]); }); - -describeNoOverlappingShapes(rt); diff --git a/tests/multiple-starts.round-trip.test.ts b/tests/multiple-starts.round-trip.test.ts index 3a726c5..8ccb7b2 100644 --- a/tests/multiple-starts.round-trip.test.ts +++ b/tests/multiple-starts.round-trip.test.ts @@ -1,10 +1,5 @@ -// A start opens its own chain and takes no incoming flow, so every hop of the -// round trip must keep all four starts and their triggers, and none of them -// as a sequence-flow target. +import { it, expect } from 'vitest'; -import { describe, it, expect } from 'vitest'; - -import { describeNoOverlappingShapes } from './helpers/di-bounds.js'; import { allOf } from './helpers/ir-query.js'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; @@ -14,29 +9,22 @@ const rt = roundTripFixture('multiple-starts', { recompile: 'clean', }); -describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { - it('every start keeps its trigger at every hop, and none takes an incoming flow', () => { - for (const [label, ir] of rt.hops) { - const starts = allOf(ir, 'startEvent') - .map((s) => [s.id, s.eventDefinition] as const) - .sort(([a], [b]) => a.localeCompare(b)); - expect(starts, `start set differs in ${label}`).toEqual([ - ['FromDesk', undefined], - [ - 'FromPartner', - { kind: 'message', messageName: 'PartnerOrderReceived' }, - ], - ['FromShop', { kind: 'message', messageName: 'OrderPlaced' }], - ['FromWarehouse', { kind: 'signal', signalName: 'StockCounted' }], - ]); +it('every start keeps its trigger at every hop, and none takes an incoming flow', () => { + for (const [label, ir] of rt.hops) { + const starts = allOf(ir, 'startEvent') + .map((s) => [s.id, s.eventDefinition] as const) + .sort(([a], [b]) => a.localeCompare(b)); + expect(starts, `start set differs in ${label}`).toEqual([ + ['FromDesk', undefined], + ['FromPartner', { kind: 'message', messageName: 'PartnerOrderReceived' }], + ['FromShop', { kind: 'message', messageName: 'OrderPlaced' }], + ['FromWarehouse', { kind: 'signal', signalName: 'StockCounted' }], + ]); - const startIds = new Set(starts.map(([id]) => id)); - expect( - ir.sequenceFlows.filter((flow) => startIds.has(flow.targetRef)), - `a sequence flow targets a start in ${label}`, - ).toEqual([]); - } - }); + const startIds = new Set(starts.map(([id]) => id)); + expect( + ir.sequenceFlows.filter((flow) => startIds.has(flow.targetRef)), + `a sequence flow targets a start in ${label}`, + ).toEqual([]); + } }); - -describeNoOverlappingShapes(rt); diff --git a/tests/nested-subprocess.round-trip.test.ts b/tests/nested-subprocess.round-trip.test.ts index 222e780..5f0053d 100644 --- a/tests/nested-subprocess.round-trip.test.ts +++ b/tests/nested-subprocess.round-trip.test.ts @@ -1,21 +1,15 @@ -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; import type { FlowContainer } from '@bpmn-script/transform'; -import { describeDiContainment } from './helpers/di-bounds.js'; import { idsOf } from './helpers/ir-query.js'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; const rt = roundTripFixture('nested-subprocess', { importPath: true, recompile: 'errors', - validatorCleanTitles: [ - 'the fixture opens validator-clean', - 'produces no diagnostics at all', - ], }); -// ` ` per container, at any depth, in element order. function containment(container: FlowContainer): Record { return Object.assign( { @@ -59,86 +53,65 @@ const CONTAINMENT: Record = { ], }; -// No sequence flow may reference an element outside its own container. That -// invariant is what lets a parent treat a sub-process as one opaque activity. +// A parent treats a sub-process as one opaque activity only while no flow +// leaves its container. function assertNoBoundaryCrossingFlows(container: FlowContainer): void { const own = idsOf(container); for (const flow of container.sequenceFlows) { expect( - own.has(flow.sourceRef), - `flow ${flow.id} source ${flow.sourceRef} escapes container ${container.id}`, - ).toBe(true); - expect( - own.has(flow.targetRef), - `flow ${flow.id} target ${flow.targetRef} escapes container ${container.id}`, - ).toBe(true); + [own.has(flow.sourceRef), own.has(flow.targetRef)], + `flow ${flow.id} escapes ${container.id}`, + ).toEqual([true, true]); } for (const fe of container.flowElements) { if (fe.kind === 'subProcess') assertNoBoundaryCrossingFlows(fe); } } -describe("idempotence: DSL -> IR1 -> XML -> IR2 -> DSL' -> IR3", () => { - it("the restructured DSL' reconstructs every sub-process as a `subprocess` block around its own steps", () => { - expect(rt.dslPrime).toBe( - [ - 'process order-fulfillment {', - ' var amount: any', - ' var retries: any', - ' start OrderReceived', - ' user RecordOrder(label: "Record order", assignee: "demo")', - ' subprocess Payment(label: "Handle payment") {', - ' if (amount > 1000) {', - ' user ManualReview(label: "Manual review", assignee: "manager")', - ' } else {', - ' service AutoCharge(label: "Auto-charge card", class: "com.example.demo.LogDelegate")', - ' }', - ' }', - ' subprocess Fulfillment(label: "Fulfill the order") {', - ' start FulfillmentStart', - ' while (retries < 3) {', - ' service ReserveStock(label: "Reserve stock", class: "com.example.demo.LogDelegate")', - ' }', - ' subprocess Shipping(label: "Ship the parcel") {', - ' user PackParcel(label: "Pack parcel", assignee: "demo")', - ' service DispatchParcel(label: "Dispatch parcel", class: "com.example.demo.LogDelegate")', - ' }', - ' end FulfillmentDone', - ' }', - ' user CloseOrder(label: "Close order", assignee: "demo")', - ' end OrderClosed', - '}', - '', - ].join('\n'), - ); - }); - - it('every container holds exactly its own elements, at every depth and every hop', () => { - for (const [label, ir] of rt.hops) { - expect(containment(ir), `containment differs in ${label}`).toEqual( - CONTAINMENT, - ); - } - }); - - it('the parent chain threads start -> RecordOrder -> Payment -> Fulfillment -> CloseOrder -> end at every hop', () => { - for (const [label, ir] of rt.hops) { - expect( - ir.sequenceFlows.map((f) => `${f.sourceRef}->${f.targetRef}`), - `top-level flows differ in ${label}`, - ).toEqual([ - 'OrderReceived->RecordOrder', - 'RecordOrder->Payment', - 'Payment->Fulfillment', - 'Fulfillment->CloseOrder', - 'CloseOrder->OrderClosed', - ]); - } - }); - - it('no sequence flow crosses a container boundary, at any depth or hop', () => { - for (const [, ir] of rt.hops) assertNoBoundaryCrossingFlows(ir); - }); +it('keeps every container to exactly its own elements and flows at every hop, printing each as a subprocess block', () => { + expect(rt.dslPrime).toBe( + [ + 'process order-fulfillment {', + ' var amount: any', + ' var retries: any', + ' start OrderReceived', + ' user RecordOrder(label: "Record order", assignee: "demo")', + ' subprocess Payment(label: "Handle payment") {', + ' if (amount > 1000) {', + ' user ManualReview(label: "Manual review", assignee: "manager")', + ' } else {', + ' service AutoCharge(label: "Auto-charge card", class: "com.example.demo.LogDelegate")', + ' }', + ' }', + ' subprocess Fulfillment(label: "Fulfill the order") {', + ' start FulfillmentStart', + ' while (retries < 3) {', + ' service ReserveStock(label: "Reserve stock", class: "com.example.demo.LogDelegate")', + ' }', + ' subprocess Shipping(label: "Ship the parcel") {', + ' user PackParcel(label: "Pack parcel", assignee: "demo")', + ' service DispatchParcel(label: "Dispatch parcel", class: "com.example.demo.LogDelegate")', + ' }', + ' end FulfillmentDone', + ' }', + ' user CloseOrder(label: "Close order", assignee: "demo")', + ' end OrderClosed', + '}', + '', + ].join('\n'), + ); + for (const [label, ir] of rt.hops) { + expect(containment(ir), label).toEqual(CONTAINMENT); + assertNoBoundaryCrossingFlows(ir); + expect( + ir.sequenceFlows.map((f) => `${f.sourceRef}->${f.targetRef}`), + label, + ).toEqual([ + 'OrderReceived->RecordOrder', + 'RecordOrder->Payment', + 'Payment->Fulfillment', + 'Fulfillment->CloseOrder', + 'CloseOrder->OrderClosed', + ]); + } }); - -describeDiContainment(rt, ['Payment', 'Fulfillment', 'Shipping', 'PackParcel']); diff --git a/tests/new-constructs.round-trip.test.ts b/tests/new-constructs.round-trip.test.ts deleted file mode 100644 index d3ecc8a..0000000 --- a/tests/new-constructs.round-trip.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -// What the single-stage transform tests cannot catch is a field-name or -// binding-kind mismatch between stages, so one minimal program per construct -// goes through the whole pipeline here. Every row pins the same value at all -// three IR hops, the whole XML tag or line that carries it, and the printed -// statement that writes it back. - -import { describe, it, expect } from 'vitest'; - -import type { BpmnProcess } from '@bpmn-script/transform'; - -import { theOnly } from './helpers/ir-query.js'; -import { roundTrip, validate } from './helpers/pipeline.js'; - -const SCRIPT_CODE = - 'var discount = amount * 0.1;\n' + - 'execution.setVariable("discount", discount);\n'; - -describe('round-trip: one minimal program per construct', () => { - it.each([ - [ - 'a service task with an `expression` binding', - 'process shipping-quote {\n' + - ' start OrderPlaced\n' + - ' service QuoteShipping(expression: "${shippingBean.quote(order)}")\n' + - ' end Done\n' + - '}\n', - (ir: BpmnProcess) => theOnly(ir, 'serviceTask').binding, - { kind: 'expression', expression: '${shippingBean.quote(order)}' }, - [ - '', - ], - [' service QuoteShipping(expression: "${shippingBean.quote(order)}")\n'], - ], - [ - 'a service task with a `delegate` binding writes the real Operaton attribute and prints the alias again', - 'process payment-charge {\n' + - ' start OrderPlaced\n' + - ' service ChargeCustomer(delegate: "${chargeService}")\n' + - ' end Done\n' + - '}\n', - (ir: BpmnProcess) => theOnly(ir, 'serviceTask').binding, - { kind: 'delegateExpression', expression: '${chargeService}' }, - [ - '', - ], - [' service ChargeCustomer(delegate: "${chargeService}")\n'], - ], - [ - 'a service task with a `topic` binding', - 'process shipment-label {\n' + - ' start OrderPlaced\n' + - ' service PrintLabel(topic: "print-label")\n' + - ' end Done\n' + - '}\n', - (ir: BpmnProcess) => theOnly(ir, 'serviceTask').binding, - { kind: 'external', topic: 'print-label' }, - [ - '', - ], - [' service PrintLabel(topic: "print-label")\n'], - ], - [ - 'a `script` task with a fenced body keeps the body verbatim and canonicalizes the `js` tag', - 'process order-discount {\n' + - ' start OrderPlaced\n' + - ' script ComputeDiscount ```js\n' + - SCRIPT_CODE + - '```\n' + - ' end Done\n' + - '}\n', - (ir: BpmnProcess) => { - const { format, code } = theOnly(ir, 'scriptTask'); - return { format, code }; - }, - { format: 'javascript', code: SCRIPT_CODE }, - [ - '', - `${SCRIPT_CODE}`, - ], - [` script ComputeDiscount \`\`\`javascript\n${SCRIPT_CODE}\`\`\`\n`], - ], - [ - 'a process start with a timer trigger', - 'process nightly-audit {\n' + - ' start AuditWindowOpens timer(at: "2099-01-01T00:00:00", label: "The audit window opens")\n' + - ' user ReviewAudit(assignee: "demo")\n' + - ' end AuditFiled\n' + - '}\n', - (ir: BpmnProcess) => theOnly(ir, 'startEvent').eventDefinition, - { kind: 'timer', timerKind: 'date', expression: '2099-01-01T00:00:00' }, - [ - '2099-01-01T00:00:00', - ], - [ - ' start AuditWindowOpens timer(at: "2099-01-01T00:00:00", label: "The audit window opens")\n', - ], - ], - [ - 'a process start with a signal trigger derives exactly one signal root', - 'process stock-watch {\n' + - ' start StockRunningLow signal("StockRunningLow")\n' + - ' user ReorderStock(assignee: "demo")\n' + - ' end Restocked\n' + - '}\n', - (ir: BpmnProcess) => theOnly(ir, 'startEvent').eventDefinition, - { kind: 'signal', signalName: 'StockRunningLow' }, - [ - '', - '\n' + - ' \n' + - ' theOnly(ir, 'startEvent').eventDefinition, - { kind: 'conditional', condition: '${stockLevel < 5}' }, - [ - '${stockLevel < 5}', - ], - [' start StockRanLow condition(stockLevel < 5) {\n'], - ], - [ - 'an `end ... terminate` keeps its trigger and its label, and is never elided', - 'process order-abandon {\n' + - ' start OrderPlaced\n' + - ' user ReviewOrder(assignee: "demo")\n' + - ' end OrderAbandoned terminate(label: "Abandon every path")\n' + - '}\n', - (ir: BpmnProcess) => { - const { eventDefinition, name } = theOnly(ir, 'endEvent'); - return { eventDefinition, name }; - }, - { eventDefinition: { kind: 'terminate' }, name: 'Abandon every path' }, - [ - '', - '', - ], - [' end OrderAbandoned terminate(label: "Abandon every path")\n'], - ], - [ - // The literal takes the attribute slot and the `${...}` an expression - // child. Written the other way round the engine injects the text of the - // expression instead of what it evaluates to. - 'a `field` on a class binding and on a listener binding keeps its carrier, name and value', - 'process shipment-dispatch {\n' + - ' start OrderPacked\n' + - ' service PrintLabel(class: "com.acme.PrintLabelDelegate") {\n' + - ' field printer = "warehouse-north"\n' + - ' field copies = "${order.parcelCount}"\n' + - ' on start(delegate: "${dispatchAudit}") {\n' + - ' field stage = "label-printing"\n' + - ' }\n' + - ' }\n' + - ' end LabelPrinted\n' + - '}\n', - (ir: BpmnProcess) => { - const { binding, executionListeners } = theOnly(ir, 'serviceTask'); - return { binding, executionListeners }; - }, - { - binding: { - kind: 'class', - className: 'com.acme.PrintLabelDelegate', - fields: [ - { name: 'printer', value: 'warehouse-north' }, - { name: 'copies', value: '${order.parcelCount}' }, - ], - }, - executionListeners: [ - { - event: 'start', - binding: { - kind: 'delegateExpression', - expression: '${dispatchAudit}', - fields: [{ name: 'stage', value: 'label-printing' }], - }, - }, - ], - }, - [ - '', - '${order.parcelCount}', - ], - [ - ' field printer = "warehouse-north"\n' + - ' field copies = "${order.parcelCount}"\n' + - ' on start(delegate: "${dispatchAudit}") {\n' + - ' field stage = "label-printing"\n', - ], - ], - [ - // Operaton refuses to deploy a form reference carrying no binding, so - // the binding attribute is written even where it names the engine's own - // default, and the version attribute only where a version was pinned. - 'a `formRef` on a user task keeps its key and binding', - 'process refund-approval {\n' + - ' start RefundRequested\n' + - ' user ApproveRefund(assignee: "demo", formRef: "refund-form", binding: latest)\n' + - ' end RefundApproved\n' + - '}\n', - (ir: BpmnProcess) => theOnly(ir, 'userTask').formRef, - { key: 'refund-form', binding: { kind: 'latest' } }, - [ - '', - ], - [ - ' user ApproveRefund(assignee: "demo", formRef: "refund-form", binding: latest)\n', - ], - ], - ])( - '%s', - async (_title, source, pick, expected, xmlFragments, dslFragments) => { - const run = await roundTrip(source); - - for (const [hop, ir] of run.hops) { - expect(pick(ir), `the value differs in ${hop}`).toEqual(expected); - } - for (const fragment of xmlFragments) { - expect(run.xml).toContain(fragment); - } - expect(run.warnings, 'import warnings').toEqual([]); - for (const fragment of dslFragments) { - expect(run.dsl).toContain(fragment); - } - const { diagnostics } = await validate(run.dsl); - expect(diagnostics).toEqual([]); - }, - ); -}); diff --git a/tests/parse-performance.test.ts b/tests/parse-performance.test.ts index 79b2223..f654023 100644 --- a/tests/parse-performance.test.ts +++ b/tests/parse-performance.test.ts @@ -1,8 +1,6 @@ -// `tryWhile` and `tryDoWhileEntry` in `ir-to-dsl.ts` ask -// `CfgAnalysis.backEdges()` once per emitted node, so a straight chain of n -// tasks costs O(n^3) unless `backEdges()` is computed once and `dominates` -// answers in two map lookups. Reverting either guard alone leaves O(n^2), -// which still finishes inside the budget; only reverting both turns this red. +// The printer asks `CfgAnalysis.backEdges()` once per emitted node, so a chain +// of n tasks is O(n^3) unless `backEdges()` is cached and `dominates` is two +// map lookups. Only reverting both guards turns this red. import { describe, expect, it } from 'vitest'; import { xmlToIr } from '@bpmn-script/transform'; import { parseToAst, printDsl } from './helpers/pipeline.js'; @@ -19,7 +17,7 @@ function chainBpmn(taskCount: number): string { for (let i = 1; i <= taskCount; i++) { const taskId = `Task_${i}`; const flowId = `Flow_${i - 1}`; - // `name` matches `humanize(taskId)`, so the printer drops the label. + // `name` is `humanize(taskId)`, so the printer drops it. flowElements.push( `${flowId}`, ); diff --git a/tests/readme-fences.test.ts b/tests/readme-fences.test.ts index 3070298..ec14a56 100644 --- a/tests/readme-fences.test.ts +++ b/tests/readme-fences.test.ts @@ -1,6 +1,4 @@ -// Every `bpmnscript` fence in a Markdown file is a program a reader copies out -// and runs, so each one is parsed, validated and compiled here, directory-driven -// so a new document is covered the moment it lands. +// Every `bpmnscript` fence in any Markdown file must parse, validate and compile. import { describe, it, expect } from 'vitest'; import { readdirSync, readFileSync } from 'node:fs'; @@ -17,13 +15,12 @@ const ROOT = resolve(__dirname, '..'); const UNVISITED = new Set(['node_modules', 'out', '.git', '.claude', 'target']); -// The opening run's length is captured so a sample carrying a fenced script -// body of its own, three backticks indented inside a four-backtick block, does -// not close the block it sits in. +// The opening run is back-referenced so a ``` inside a ```` block does not +// close it. const FENCE = /^(`{3,})bpmnscript[ \t]*\r?\n([\s\S]*?)^\1`*[ \t]*$/gm; interface Sample { - where: string; // `packages/language/README.md:144` + where: string; // file:line source: string; } diff --git a/tests/repetition.round-trip.test.ts b/tests/repetition.round-trip.test.ts index ec6131b..b25da89 100644 --- a/tests/repetition.round-trip.test.ts +++ b/tests/repetition.round-trip.test.ts @@ -1,9 +1,5 @@ -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; -import { - describeDiContainment, - describeNoOverlappingShapes, -} from './helpers/di-bounds.js'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; const rt = roundTripFixture('repetition', { @@ -12,10 +8,8 @@ const rt = roundTripFixture('repetition', { recompile: 'clean', }); -// Every multi-instance element of the frozen artifact in document order, each -// under the tag and id it sits in: a loop is always written ahead of any nested -// child, so the last opening tag seen is its host. Whitespace inside the loop -// element is collapsed so a row reads as one line. +// Each loop element with its host's tag and id, whitespace collapsed. A loop is +// written ahead of any nested child, so the last opening tag seen is its host. function loops(): [tag: string, id: string, loop: string][] { const found: [string, string, string][] = []; let host: [tag: string, id: string] | undefined; @@ -31,104 +25,93 @@ function loops(): [tag: string, id: string, loop: string][] { return found; } -describe('multi-instance placement on the frozen .bpmn', () => { - it('every repeated statement carries its whole loop element under its own tag, and no other tag carries one', () => { - expect(loops()).toEqual([ - [ - 'userTask', - 'ApproveLines', - '', - ], - [ - 'serviceTask', - 'ReserveStock', - ' ' + - '${nrOfCompletedInstances >= 2} ' + - '', - ], - [ - 'serviceTask', - 'WarmPricing', - ' ' + - ' ' + - 'R2/PT1M ' + - ' ' + - '3 ' + - '', - ], - [ - 'task', - 'RecordLine', - ' ' + - '2 ' + - '', - ], - [ - 'receiveTask', - 'AwaitBatch', - '', - ], - [ - 'callActivity', - 'RegionalReport', - '', - ], - [ - 'scriptTask', - 'LabelParcel', - '', - ], - [ - 'subProcess', - 'DispatchParcels', - '', - ], - ]); - }); - - // The step's own settings sit on the task tag and make one job around the - // repetition; the `run*` keys sit on the loop element and make one per run. - it("WarmPricing carries the step's job around the repetition and one per run", () => { - expect( - rt.frozenXml.match( - //, - )?.[0], - ).toBe( - '\n' + - ' \n' + - ' R3/PT10M\n' + - ' \n' + - ' Flow_ReserveStock_WarmPricing\n' + - ' Flow_WarmPricing_RecordLine\n' + - ' \n' + - ' \n' + - ' R2/PT1M\n' + - ' \n' + - ' 3\n' + - ' \n' + - ' ', - ); - }); +it('writes each whole loop element under its own tag only, the step job around the repetition and one per run', () => { + expect(loops()).toEqual([ + [ + 'userTask', + 'ApproveLines', + '', + ], + [ + 'serviceTask', + 'ReserveStock', + ' ' + + '${nrOfCompletedInstances >= 2} ' + + '', + ], + [ + 'serviceTask', + 'WarmPricing', + ' ' + + ' ' + + 'R2/PT1M ' + + ' ' + + '3 ' + + '', + ], + [ + 'task', + 'RecordLine', + ' ' + + '2 ' + + '', + ], + [ + 'receiveTask', + 'AwaitBatch', + '', + ], + [ + 'callActivity', + 'RegionalReport', + '', + ], + [ + 'scriptTask', + 'LabelParcel', + '', + ], + [ + 'subProcess', + 'DispatchParcels', + '', + ], + ]); + // The step's settings sit on the task tag, the `run*` keys on the loop. + expect( + rt.frozenXml.match( + //, + )?.[0], + ).toBe( + '\n' + + ' \n' + + ' R3/PT10M\n' + + ' \n' + + ' Flow_ReserveStock_WarmPricing\n' + + ' Flow_WarmPricing_RecordLine\n' + + ' \n' + + ' \n' + + ' R2/PT1M\n' + + ' \n' + + ' 3\n' + + ' \n' + + ' ', + ); }); -describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { - it("the decompiled DSL' writes every repeat clause back, and no other statement gains one", () => { - const repeated = rt.dslPrime - .split('\n') - .map((line) => line.trim()) - .filter((line) => /^\w+ \w+ for /.test(line)); - expect(repeated).toEqual([ - 'user ApproveLines for each approver in approvers(label: "Approve the order lines", assignee: "demo")', - 'service ReserveStock for each line in "${order.lines}" sequentially until (nrOfCompletedInstances >= 2)(label: "Reserve the stock", class: "com.example.orders.ReserveStockDelegate")', - 'service WarmPricing for 3(label: "Warm the pricing cache", class: "com.example.orders.WarmPricingDelegate", asyncBefore: true, retryCycle: "R3/PT10M", runAsyncBefore: true, runAsyncAfter: true, runExclusive: false, runRetryCycle: "R2/PT1M")', - 'step RecordLine for 2 each line in lines(label: "Record each line")', - 'receive AwaitBatch for each in batches(label: "Wait for each batch")', - 'call RegionalReport for each region in regions(label: "Run the regional report", process: "regional-report")', - 'script LabelParcel for each parcel in parcels(label: "Label each parcel", resultVariable: "parcelLabel") ```javascript', - 'subprocess DispatchParcels for each parcel in parcels sequentially(label: "Dispatch each parcel", asyncBefore: true) {', - ]); - }); +it("the decompiled DSL' writes every repeat clause back, and no other statement gains one", () => { + const repeated = rt.dslPrime + .split('\n') + .map((line) => line.trim()) + .filter((line) => /^\w+ \w+ for /.test(line)); + expect(repeated).toEqual([ + 'user ApproveLines for each approver in approvers(label: "Approve the order lines", assignee: "demo")', + 'service ReserveStock for each line in "${order.lines}" sequentially until (nrOfCompletedInstances >= 2)(label: "Reserve the stock", class: "com.example.orders.ReserveStockDelegate")', + 'service WarmPricing for 3(label: "Warm the pricing cache", class: "com.example.orders.WarmPricingDelegate", asyncBefore: true, retryCycle: "R3/PT10M", runAsyncBefore: true, runAsyncAfter: true, runExclusive: false, runRetryCycle: "R2/PT1M")', + 'step RecordLine for 2 each line in lines(label: "Record each line")', + 'receive AwaitBatch for each in batches(label: "Wait for each batch")', + 'call RegionalReport for each region in regions(label: "Run the regional report", process: "regional-report")', + 'script LabelParcel for each parcel in parcels(label: "Label each parcel", resultVariable: "parcelLabel") ```javascript', + 'subprocess DispatchParcels for each parcel in parcels sequentially(label: "Dispatch each parcel", asyncBefore: true) {', + ]); }); - -describeNoOverlappingShapes(rt); -describeDiContainment(rt, ['DispatchParcels', 'HandToCarrier']); diff --git a/tests/review-loop.round-trip.test.ts b/tests/review-loop.round-trip.test.ts index 61902ad..5c95c43 100644 --- a/tests/review-loop.round-trip.test.ts +++ b/tests/review-loop.round-trip.test.ts @@ -1,8 +1,6 @@ -// A `do ... while` whose body splits on every route, one route leaving the -// loop. The pin below catches the printer hoisting the staying route out of -// the loop, which the IR comparison would only report as a lost edge. +// A `do ... while` whose body splits on every route, one leaving the loop. -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; @@ -12,23 +10,21 @@ const rt = roundTripFixture('review-loop', { recompile: 'clean', }); -describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { - it('prints the split inside the loop body, the leaving route as a jump and the staying route inline', () => { - expect(rt.dslPrime).toContain( - [ - ' do {', - ' user ApproveInvoice(assignee: "demo")', - ' if (approved) {', - ' goto PrepareBankTransfer', - ' } else if (!approved) {', - ' call ReviewInvoice(process: "ReviewInvoice") {', - ' in invoiceDocument', - ' out clarified', - ' }', - ' }', - ' } while (clarified)', - '', - ].join('\n'), - ); - }); +it('prints the split inside the loop body, the leaving route as a jump and the staying route inline', () => { + expect(rt.dslPrime).toContain( + [ + ' do {', + ' user ApproveInvoice(assignee: "demo")', + ' if (approved) {', + ' goto PrepareBankTransfer', + ' } else if (!approved) {', + ' call ReviewInvoice(process: "ReviewInvoice") {', + ' in invoiceDocument', + ' out clarified', + ' }', + ' }', + ' } while (clarified)', + '', + ].join('\n'), + ); }); diff --git a/tests/round-trip-constructs.test.ts b/tests/round-trip-constructs.test.ts index bb53601..1eb7205 100644 --- a/tests/round-trip-constructs.test.ts +++ b/tests/round-trip-constructs.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -6,41 +6,23 @@ import { fileURLToPath } from 'node:url'; import { xmlToIr, astToIr, isGateway } from '@bpmn-script/transform'; import type { BpmnProcess } from '@bpmn-script/transform'; -import { normalizeIr } from './helpers/normalize-ir.js'; import { realNodeReachability } from './helpers/real-node-reachability.js'; import { parseToAst, printDsl, - roundTripOf, + roundTrip, roundTripTwice, validate, } from './helpers/pipeline.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); +const read = (path: string) => readFileSync(resolve(__dirname, path), 'utf-8'); -const INVOICE_DSL_PATH = resolve( - __dirname, - '../examples/spring-boot/processes/invoice-approval.bpmnscript', -); - -const STRUCTURED_DSL_PATH = resolve( - __dirname, - 'golden/structured-control-flow.bpmnscript', -); - -const UNSTRUCTURED_BPMN_PATH = resolve( - __dirname, - 'golden/unstructured-goto.bpmn', -); - -describe('structured idempotence (invoice-approval, if/else)', () => { - const run = roundTripOf(readFileSync(INVOICE_DSL_PATH, 'utf-8')); - - it('final IR equals initial IR up to documented id normalization', () => { - expect(normalizeIr(run.ir3)).toEqual(normalizeIr(run.ir1)); - }); - - it('the emitted DSL is structured syntax, and a second emission reproduces it byte for byte', () => { +describe('structured control flow survives the round trip as written', () => { + it('invoice-approval prints its if/else back, and a second print reproduces it byte for byte', async () => { + const run = await roundTrip( + read('../examples/spring-boot/processes/invoice-approval.bpmnscript'), + ); expect(run.dsl).toBe( [ 'process invoice-approval {', @@ -62,17 +44,13 @@ describe('structured idempotence (invoice-approval, if/else)', () => { ); expect(printDsl(run.ir3)).toBe(run.dsl); }); -}); - -describe('while and parallel round-trip (structured-control-flow)', () => { - const run = roundTripOf(readFileSync(STRUCTURED_DSL_PATH, 'utf-8')); - it('lowers `while` to a conditioned back-edge and `parallel` to one fork and one join', () => { + it('`while` lowers to a conditioned back-edge and `parallel` to one fork and join, and both print back', async () => { + const run = await roundTrip( + read('golden/structured-control-flow.bpmnscript'), + ); expect(run.xml).not.toContain('standardLoopCharacteristics'); expect(run.xml.match(/ { expect(run.dsl).toBe( [ 'process structured-control-flow {', @@ -104,404 +82,146 @@ describe('while and parallel round-trip (structured-control-flow)', () => { }); }); -// Every edge in this fixture has a `goto` form, so the whole set of connections -// between authored nodes survives, over a second round trip too. Edges with no -// form at all belong to the goto-fallback suite. -describe('goto-degradation preserves the edges that have a goto form', () => { - let irImport: BpmnProcess; - let degradedDsl: string; - let irReDesugared: BpmnProcess; - let irSecondRound: BpmnProcess; - - beforeAll(async () => { - const xml = readFileSync(UNSTRUCTURED_BPMN_PATH, 'utf-8'); - - ({ ir: irImport } = await xmlToIr(xml)); - degradedDsl = printDsl(irImport); - irReDesugared = astToIr(await parseToAst(degradedDsl)); - irSecondRound = astToIr(await parseToAst(printDsl(irReDesugared))); - }); - - it('the degraded DSL prints unstructured routing as `goto`, except a chain only this branch enters, which prints inline in its branch', () => { - expect(degradedDsl).toBe( - [ - 'process unstructured-goto {', - ' var retry: any', - ' var route: any', - ' start Start', - ' user Intake(assignee: "demo")', - ' if (route == "A") {', - ' user Alpha(assignee: "demo")', - ' if (retry == true) {', - ' } else {', - ' end Done', - ' }', - ' }', - ' user Beta(assignee: "manager")', - ' end DoneBeta', - '}', - '', - ].join('\n'), - ); - }); - - it('the real-node reachability is identical after the round-trip', () => { - // Raw flow endpoints cannot match: the import has hand-named gateways - // (RouteA/RouteB) while re-desugaring synthesizes fresh ids and grows XOR - // joins for the `if`s whose branches are pure `goto`s. Compare authored-node - // connectivity with gateway routing contracted away instead. - expect(realNodeReachability(irReDesugared)).toEqual( - realNodeReachability(irImport), - ); - }); - - it('a SECOND round-trip preserves the same real-node reachability (idempotent totality)', () => { - expect(realNodeReachability(irSecondRound)).toEqual( - realNodeReachability(irReDesugared), - ); - }); +// Every edge in this fixture has a `goto` form; edges with none belong to the +// goto-fallback suite. +it('goto degradation keeps every authored node, edge and condition over two round trips', async () => { + const { ir: irImport } = await xmlToIr(read('golden/unstructured-goto.bpmn')); + const degradedDsl = printDsl(irImport); + const irReDesugared = astToIr(await parseToAst(degradedDsl)); + const irSecondRound = astToIr(await parseToAst(printDsl(irReDesugared))); - it('the fixture conditions survive the goto-degradation round-trip', () => { - // Reachability is condition-agnostic: a `conditionExpression` stripped off a - // surviving edge passes every check above, so pin the conditions too. The - // round trip canonicalizes the fixture's single-quoted literal to double - // quotes, hence the shifted spelling in the expected set. - const reConditions = irReDesugared.sequenceFlows - .map((f) => f.conditionExpression) - .filter((c): c is string => c !== undefined) - .sort(); - expect(reConditions).toEqual( - ['${retry == true}', '${route == "A"}'].sort(), - ); - }); - - it('every authored node from the import is still present after re-desugaring', () => { - const realIds = (ir: BpmnProcess) => - ir.flowElements - .filter((fe) => !isGateway(fe)) - .map((fe) => fe.id) - .sort(); - expect(realIds(irImport)).toEqual([ - 'Alpha', - 'Beta', - 'Done', - 'DoneBeta', - 'Intake', - 'Start', - ]); - expect(realIds(irReDesugared)).toEqual(realIds(irImport)); - }); - - it('the meaningfulness guard: a dropped edge would make reachability differ', () => { - const corrupt: BpmnProcess = { - ...irImport, - sequenceFlows: irImport.sequenceFlows.slice(1), - }; - expect(realNodeReachability(corrupt)).not.toEqual( - realNodeReachability(irImport), - ); - }); -}); - -// A bean method call is outside the JUEL native subset: the trailing `()` -// leaves tokens unconsumed, so it takes the raw fallback and has to survive the -// round trip as the same quoted raw form. -describe('bean-call condition stays quoted-raw end-to-end', () => { - const run = roundTripOf( + // A chain only one branch enters prints inline in that branch. + expect(degradedDsl).toBe( [ - 'process bean-cond(label: "Bean Cond") {', - ' start S', - ' if ("${myBean.check()}") {', - ' user Approve(label: "Approve", assignee: "demo")', - ' } else {', - ' user Reject(label: "Reject", assignee: "demo")', + 'process unstructured-goto {', + ' var retry: any', + ' var route: any', + ' start Start', + ' user Intake(assignee: "demo")', + ' if (route == "A") {', + ' user Alpha(assignee: "demo")', + ' if (retry == true) {', + ' } else {', + ' end Done', + ' }', ' }', - ' end E', + ' user Beta(assignee: "manager")', + ' end DoneBeta', '}', '', ].join('\n'), ); - it('the bean call is preserved verbatim at every hop and re-emitted as the quoted raw form', () => { - const condition = (ir: BpmnProcess) => - ir.sequenceFlows.find((sf) => sf.conditionExpression !== undefined) - ?.conditionExpression; - for (const [hop, ir] of run.hops) { - expect(condition(ir), `differs in ${hop}`).toBe('${myBean.check()}'); - } - expect(run.dsl).toContain(' if ("${myBean.check()}") {\n'); - }); + // Raw flows cannot match: re-desugaring synthesizes fresh gateway ids and + // grows joins, so compare connectivity with gateways contracted away. + expect(realNodeReachability(irReDesugared)).toEqual( + realNodeReachability(irImport), + ); + expect(realNodeReachability(irSecondRound)).toEqual( + realNodeReachability(irReDesugared), + ); + expect( + realNodeReachability({ + ...irImport, + sequenceFlows: irImport.sequenceFlows.slice(1), + }), + 'a dropped edge must change the reachability', + ).not.toEqual(realNodeReachability(irImport)); + + // Reachability ignores conditions. The fixture's single-quoted literal comes + // back double-quoted. + expect( + irReDesugared.sequenceFlows + .map((f) => f.conditionExpression) + .filter((c): c is string => c !== undefined) + .sort(), + ).toEqual(['${retry == true}', '${route == "A"}'].sort()); + + const realIds = (ir: BpmnProcess) => + ir.flowElements + .filter((fe) => !isGateway(fe)) + .map((fe) => fe.id) + .sort(); + expect(realIds(irImport)).toEqual([ + 'Alpha', + 'Beta', + 'Done', + 'DoneBeta', + 'Intake', + 'Start', + ]); + expect(realIds(irReDesugared)).toEqual(realIds(irImport)); }); -// Two attribute values in the table carry a literal `"`, which this pipeline's -// XML writer numeric-escapes (`"`, ` `); decoding them first lets a -// fragment read the same whether it landed in an attribute or an element body. +// The XML writer numeric-escapes `"` and line feeds in attribute values. function decodeXmlEntities(xml: string): string { return xml.replace(/"/g, '"').replace(/ /g, '\n'); } -const RAW_CONDITION_QUOTE_SRC = [ - 'process p {', - ' if (\'${execution.getVariable("x")}\') {', - ' user A', - ' }', - '}', - '', -].join('\n'); - -const RAW_UNDER_UNARY_SRC = [ - 'process p {', - " if (!'${x}') {", - ' user A', - ' }', - '}', - '', -].join('\n'); - -const QUOTED_RAW_BINDINGS_SRC = [ - 'process p {', - ' service S(delegate: \'${fn("a")}\')', - ' service T(expression: "${bean.m(\\"a\\")}")', - ' user U(assignee: "${who(\\"a\\")}")', - '}', - '', -].join('\n'); - -const BARE_VARIABLE_IO_SRC = [ - 'process p {', - ' var notify_sync: any', - ' service A(class: "x") {', - ' output a = notify_sync', - ' input b = notify_sync.lines', - ' input c = [notify_sync, 1, -644, "s"]', - ' input d = { k: notify_sync, k2: null, k3: true }', - ' }', - ' call C(process: "x") {', - ' in y = notify_sync', - ' }', - '}', - '', -].join('\n'); - -const HASH_TEMPLATE_POSITIONS_SRC = [ - 'process p {', - ' user Review(assignee: "#{initiator}", priority: "#{prio}") {', - ' on end(delegate: "#{auditListener}")', - ' on create(expression: "#{notifier.created(task)}")', - ' }', - ' service Notify(delegate: "#{emailAdapter}")', - ' service Compute(expression: "#{calc.run(order)}", resultVariable: "result")', - ' call Sub(process: "#{subKey}", businessKey: "#{execution.processBusinessKey}") {', - ' in orderId = "#{order.id}"', - ' out subResult = "#{result}"', - ' }', - '}', - '', -].join('\n'); - -const CONTROL_CHAR_BODIES_SRC = [ - 'process p {', - ' service A(class: "x") {', - ' input m = { "a\\nb": 1 }', - ' input s = "l1\\nl2"', - ' field f = "f1\\nf2"', - ' }', - '}', - '', -].join('\n'); - -const HYPHENATED_COLLECTION_SRC = [ - 'process p {', - ' var check-close: any', - ' user A for each in check-close', - '}', - '', -].join('\n'); - -const LITERAL_BUSINESS_KEY_SRC = [ - 'process p {', - ' call C(process: "x", businessKey: "abc") {', - ' in y = "u"', - ' }', - '}', - '', -].join('\n'); - -const CONDITION_VARIABLE_SRC = [ - 'process p {', - ' var shipPlan: any', - ' await condition SendLoad(419 >= shipPlan)', - '}', - '', -].join('\n'); - -const SHARED_END_STARTS_SRC = [ - 'process claim-review {', - ' var urgent: boolean', - ' start FromDesk', - ' start WhenUrgent condition(urgent)', - '}', - '', -].join('\n'); - -const RACE_INTO_SELF_LOOPING_STEP_SRC = [ - 'process claim-review {', - ' await {', - ' message("OrderReceived") {', - ' user Log', - ' }', - ' condition("${fn(\\"a\\")}" * 0.91 == (247)) {', - ' user Nudge', - ' }', - ' }', - ' decide Ship6(decision: "approve-claim")', - ' goto Ship6', - '}', - '', -].join('\n'); - -const ENDING_BRANCH_BESIDE_ELSE_CHAIN_SRC = [ - 'process onboarding {', - ' var a: boolean', - ' var b: boolean', - ' if (a) {', - ' end Done7', - ' } else if (b) {', - ' user Log', - ' } else {', - ' service Approve10(class: "org.acme.Audit")', - ' }', - '}', - '', -].join('\n'); - -const LOOP_INSIDE_BRANCH_SRC = [ - 'process claim-review {', - ' if (true) {', - ' do {', - ' await message Wait3("PaymentDone")', - ' } while (true)', - ' } else if (true) {', - ' throw message("Quote Received")', - ' } else {', - ' service Escalate(class: "com.example.Delegate")', - ' }', - ' end Done9', - '}', - '', -].join('\n'); - -const ENDING_CHAINS_IN_BRANCHES_SRC = [ - 'process p {', - ' var a: any', - ' var b: any', - ' if (a) {', - ' user A', - ' throw message("Quote Received")', - ' } else if (b) {', - ' user B', - ' await {', - ' message("OrderReceived") {', - ' end Done', - ' }', - ' message("Quote Received") {', - ' user C', - ' }', - ' }', - ' } else {', - ' user D', - ' }', - '}', - '', -].join('\n'); - -const EXPRESSION_MAP_KEY_SRC = [ - 'process p {', - ' service A(class: "x") {', - ' input m = { "\\${dyn}": 1, "\\#{other}": 2, plain: 3 }', - ' }', - '}', - '', -].join('\n'); - -const BACKSLASH_IN_JUEL_STRING_SRC = [ - 'process order-fulfilment {', - ' if ("${region == \\"a\\\\\\\\b\\"}") {', - ' user A', - ' }', - '}', - '', -].join('\n'); - -const HANDLER_JUMPING_TO_ITS_HOST_SRC = [ - 'process invoice_batch {', - ' emit link("Skip")', - ' await link("Skip")', - ' receive Hold14', - ' on Hold14: message("Cancelled") {', - ' goto Hold14', - ' }', - '}', - '', -].join('\n'); - -const HANDLER_JUMPING_TO_A_LINKED_STEP_SRC = [ - 'process invoice_batch {', - ' var order: any', - ' var items: any', - ' step Check6', - ' emit link("Retry")', - ' await link("Retry")', - ' call Call(process: "payment-run")', - ' on Check6: condition(order.name != items[0]) {', - ' goto Call', - ' }', - '}', - '', -].join('\n'); - -const TWO_JUMPS_INTO_ONE_STEP_SRC = [ - 'process p {', - ' var a: any', - ' var b: any', - ' if (a) {', - ' goto X', - ' }', - ' user B', - ' if (b) {', - ' goto X', - ' }', - ' user C', - ' end Done', - ' user X', - ' throw message("M")', - '}', - '', -].join('\n'); - describe('two-pass round trip: printed expression text stays engine-runnable and stable', () => { it.each([ + [ + // A bean method call is outside the native JUEL subset and takes the raw fallback. + 'a bean-call condition stays the same quoted raw form', + [ + 'process bean-cond(label: "Bean Cond") {', + ' start S', + ' if ("${myBean.check()}") {', + ' user Approve(label: "Approve", assignee: "demo")', + ' } else {', + ' user Reject(label: "Reject", assignee: "demo")', + ' }', + ' end E', + '}', + '', + ].join('\n'), + ['>${myBean.check()}'], + [' if ("${myBean.check()}") {\n'], + ], [ 'a backslash inside a JUEL string literal prints as the doubled escape and compiles back to the same three characters', - BACKSLASH_IN_JUEL_STRING_SRC, + [ + 'process order-fulfilment {', + ' if ("${region == \\"a\\\\\\\\b\\"}") {', + ' user A', + ' }', + '}', + '', + ].join('\n'), ['${region == "a\\\\b"}'], ['if (region == "a\\\\b") {'], ], [ 'an out-of-subset raw condition with a quoted method call keeps its inner quotes, not doubled escapes', - RAW_CONDITION_QUOTE_SRC, + [ + 'process p {', + ' if (\'${execution.getVariable("x")}\') {', + ' user A', + ' }', + '}', + '', + ].join('\n'), ['${execution.getVariable("x")}'], [], ], [ 'a raw operand under a unary operator splices its body instead of nesting a second ${ }', - RAW_UNDER_UNARY_SRC, + ['process p {', " if (!'${x}') {", ' user A', ' }', '}', ''].join( + '\n', + ), ['${!(x)}'], [], ], [ 'quoted-raw method-call bindings on delegate, expression and assignee keep their inner quotes', - QUOTED_RAW_BINDINGS_SRC, + [ + 'process p {', + ' service S(delegate: \'${fn("a")}\')', + ' service T(expression: "${bean.m(\\"a\\")}")', + ' user U(assignee: "${who(\\"a\\")}")', + '}', + '', + ].join('\n'), [ 'operaton:delegateExpression="${fn("a")}"', 'operaton:expression="${bean.m("a")}"', @@ -511,7 +231,21 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ], [ 'a bare variable in an io value, a list item or a map entry renders as ${var}, not as its name in text', - BARE_VARIABLE_IO_SRC, + [ + 'process p {', + ' var notify_sync: any', + ' service A(class: "x") {', + ' output a = notify_sync', + ' input b = notify_sync.lines', + ' input c = [notify_sync, 1, -644, "s"]', + ' input d = { k: notify_sync, k2: null, k3: true }', + ' }', + ' call C(process: "x") {', + ' in y = notify_sync', + ' }', + '}', + '', + ].join('\n'), [ '${notify_sync}', '${notify_sync.lines}', @@ -528,7 +262,21 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ], [ '#{ } bindings across a listener, tasks and a call activity print verbatim with the # opener kept', - HASH_TEMPLATE_POSITIONS_SRC, + [ + 'process p {', + ' user Review(assignee: "#{initiator}", priority: "#{prio}") {', + ' on end(delegate: "#{auditListener}")', + ' on create(expression: "#{notifier.created(task)}")', + ' }', + ' service Notify(delegate: "#{emailAdapter}")', + ' service Compute(expression: "#{calc.run(order)}", resultVariable: "result")', + ' call Sub(process: "#{subKey}", businessKey: "#{execution.processBusinessKey}") {', + ' in orderId = "#{order.id}"', + ' out subResult = "#{result}"', + ' }', + '}', + '', + ].join('\n'), [ 'operaton:assignee="#{initiator}"', 'operaton:priority="#{prio}"', @@ -544,12 +292,18 @@ describe('two-pass round trip: printed expression text stays engine-runnable and [], ], [ - // No tab sub-case: moddle-xml's attribute escaper does not escape a - // tab, so a tab in an attribute value reaches a strict XML parser as a - // space (XML attribute-value normalization). Pinning it here would only - // pin this tool's own reader, not byte fidelity through a strict parser. + // No tab case: moddle-xml leaves a tab in an attribute unescaped, so a strict parser reads it as a space. 'a real line feed in an io value, a map key or a field value prints as its two-character escape', - CONTROL_CHAR_BODIES_SRC, + [ + 'process p {', + ' service A(class: "x") {', + ' input m = { "a\\nb": 1 }', + ' input s = "l1\\nl2"', + ' field f = "f1\\nf2"', + ' }', + '}', + '', + ].join('\n'), [ 'stringValue="f1\nf2"', 'key="a\nb"', @@ -559,7 +313,14 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ], [ 'a map key that is itself a template opener prints with a backslash so it re-parses as a literal key, not a raw template', - EXPRESSION_MAP_KEY_SRC, + [ + 'process p {', + ' service A(class: "x") {', + ' input m = { "\\${dyn}": 1, "\\#{other}": 2, plain: 3 }', + ' }', + '}', + '', + ].join('\n'), [ '${1}', '${2}', @@ -569,13 +330,26 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ], [ 'a hyphenated collection variable prints as a bare operaton:collection reference, not a quoted string', - HYPHENATED_COLLECTION_SRC, + [ + 'process p {', + ' var check-close: any', + ' user A for each in check-close', + '}', + '', + ].join('\n'), ['operaton:collection="check-close"'], ['for each in check-close'], ], [ 'a literal businessKey and a literal in-mapping source print as bare EL text, not a wrapped literal', - LITERAL_BUSINESS_KEY_SRC, + [ + 'process p {', + ' call C(process: "x", businessKey: "abc") {', + ' in y = "u"', + ' }', + '}', + '', + ].join('\n'), [ '', '', @@ -584,7 +358,13 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ], [ 'a variable a condition reads comes back declared, so the printed script validates clean', - CONDITION_VARIABLE_SRC, + [ + 'process p {', + ' var shipPlan: any', + ' await condition SendLoad(419 >= shipPlan)', + '}', + '', + ].join('\n'), [ '${419 >= shipPlan}', ], @@ -595,7 +375,14 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ], [ 'two starts sharing the implicit end print back to back, and the end stays unwritten', - SHARED_END_STARTS_SRC, + [ + 'process claim-review {', + ' var urgent: boolean', + ' start FromDesk', + ' start WhenUrgent condition(urgent)', + '}', + '', + ].join('\n'), [ '', '', @@ -603,10 +390,36 @@ describe('two-pass round trip: printed expression text stays engine-runnable and [' start FromDesk\n start WhenUrgent condition(urgent)\n}\n'], ], [ - // The step after the race loops on itself, so no node reaches the exit - // and the container has no post-dominators to find the merge with. + 'a process-level timer start keeps its date through the round trip', + [ + 'process audit {', + ' start AuditWindowOpens timer(at: "2099-01-01T00:00:00")', + '}', + '', + ].join('\n'), + [ + '2099-01-01T00:00:00', + ], + [' start AuditWindowOpens timer(at: "2099-01-01T00:00:00")\n'], + ], + [ + // The looping step reaches no exit, so the container has no post-dominators. 'a race with empty branches into a step that loops on itself keeps the step after the block', - RACE_INTO_SELF_LOOPING_STEP_SRC, + [ + 'process claim-review {', + ' await {', + ' message("OrderReceived") {', + ' user Log', + ' }', + ' condition("${fn(\\"a\\")}" * 0.91 == (247)) {', + ' user Nudge', + ' }', + ' }', + ' decide Ship6(decision: "approve-claim")', + ' goto Ship6', + '}', + '', + ].join('\n'), [ '', '', @@ -616,12 +429,22 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ], ], [ - // The ending branch puts the split's post-dominator at the exit, so - // the join is read off the routes: the middle branch and the else - // chain come back together at it, and neither walks on to the - // implicit end. + // The ending branch puts the split's post-dominator at the exit, so the join is read off the routes. 'an if chain with an ending branch beside an else chain keeps the else, and the implicit end stays unwritten', - ENDING_BRANCH_BESIDE_ELSE_CHAIN_SRC, + [ + 'process onboarding {', + ' var a: boolean', + ' var b: boolean', + ' if (a) {', + ' end Done7', + ' } else if (b) {', + ' user Log', + ' } else {', + ' service Approve10(class: "org.acme.Audit")', + ' }', + '}', + '', + ].join('\n'), [ '', ], @@ -630,11 +453,22 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ], ], [ - // The branch's entry is the loop body, which the block's join - // post-dominates, so the walk stays inside the branch and prints the - // loop where it was written. 'a loop inside a branch prints inside it, not as a jump with the loop hoisted behind the end', - LOOP_INSIDE_BRANCH_SRC, + [ + 'process claim-review {', + ' if (true) {', + ' do {', + ' await message Wait3("PaymentDone")', + ' } while (true)', + ' } else if (true) {', + ' throw message("Quote Received")', + ' } else {', + ' service Escalate(class: "com.example.Delegate")', + ' }', + ' end Done9', + '}', + '', + ].join('\n'), [ '', '', @@ -644,11 +478,31 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ], ], [ - // Neither chain reaches the join, and the block's tail is the implicit - // end; hoisted behind it as jump targets, the chains would push that - // end off its tail and print it under its reserved id. + // Hoisted behind the implicit end as jump targets, the chains would push it off the tail. 'two branches whose chains end print them inside the branches, and the implicit end stays unwritten', - ENDING_CHAINS_IN_BRANCHES_SRC, + [ + 'process p {', + ' var a: any', + ' var b: any', + ' if (a) {', + ' user A', + ' throw message("Quote Received")', + ' } else if (b) {', + ' user B', + ' await {', + ' message("OrderReceived") {', + ' end Done', + ' }', + ' message("Quote Received") {', + ' user C', + ' }', + ' }', + ' } else {', + ' user D', + ' }', + '}', + '', + ].join('\n'), [ '', '', @@ -660,12 +514,26 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ], ], [ - // Both routes of the first split reach `X`, and the else route can - // also end at `Done` without passing it, so `X` is no merge of the - // block: taken as one, the else chain prints nested inside the branch - // and the next pass prints it hoisted. + // The else route can end at `Done` without passing `X`, so `X` is no merge of the block. 'two jumps from sibling branches into one step keep the step after the chain, not as the join', - TWO_JUMPS_INTO_ONE_STEP_SRC, + [ + 'process p {', + ' var a: any', + ' var b: any', + ' if (a) {', + ' goto X', + ' }', + ' user B', + ' if (b) {', + ' goto X', + ' }', + ' user C', + ' end Done', + ' user X', + ' throw message("M")', + '}', + '', + ].join('\n'), [ '', '', @@ -675,10 +543,19 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ], ], [ - // The host is reached only through the link catch; a handler walked - // before that chain would pull the host into its own body. + // A handler walked before the link-catch chain would pull the host into its own body. 'a handler jumping back to its host keeps the host where the link catch reaches it', - HANDLER_JUMPING_TO_ITS_HOST_SRC, + [ + 'process invoice_batch {', + ' emit link("Skip")', + ' await link("Skip")', + ' receive Hold14', + ' on Hold14: message("Cancelled") {', + ' goto Hold14', + ' }', + '}', + '', + ].join('\n'), [ '', ], @@ -688,7 +565,20 @@ describe('two-pass round trip: printed expression text stays engine-runnable and ], [ 'a handler jumping to a step the link catch reaches keeps the step, and the process end, outside the handler', - HANDLER_JUMPING_TO_A_LINKED_STEP_SRC, + [ + 'process invoice_batch {', + ' var order: any', + ' var items: any', + ' step Check6', + ' emit link("Retry")', + ' await link("Retry")', + ' call Call(process: "payment-run")', + ' on Check6: condition(order.name != items[0]) {', + ' goto Call', + ' }', + '}', + '', + ].join('\n'), [ '', '', @@ -715,11 +605,8 @@ describe('two-pass round trip: printed expression text stays engine-runnable and expect(dsl2).toBe(dsl1); }); - // The pass that walks a node first owns it in the print, and the chains - // the container's entries reach are walked before the handler blocks. The - // owned chain then ends at the process end rather than the handler's, so - // the model changes where the print is stable; what it keeps is which - // steps reach which. + // Entry chains are walked before handler blocks and own what they reach, so + // the model changes (the chain ends at the process end) but reachability holds. it("an entry chain jumping into a handler body owns the body's chain, and the handler degrades to a goto", async () => { const source = [ 'process p {', diff --git a/tests/round-trip.test.ts b/tests/round-trip.test.ts index b6172f6..a474613 100644 --- a/tests/round-trip.test.ts +++ b/tests/round-trip.test.ts @@ -1,8 +1,7 @@ -// invoice-approval-handwritten.bpmn -> IR1 -> DSL -> AST -> IR2 -> XML2 -> IR3. -// IR3 has to be semantically equal to IR1, but hand-named ids meet synthesized -// ones, so both go through helpers/normalize-ir.ts first. +// A hand-named import meets synthesized ids on the way back, so IR1 and IR3 +// are compared through normalizeIr. -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -20,32 +19,62 @@ import { normalizeIr } from './helpers/normalize-ir.js'; import { theOnly } from './helpers/ir-query.js'; import { parseToAst, printDsl } from './helpers/pipeline.js'; -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const HANDWRITTEN_BPMN_PATH = resolve( - __dirname, - 'golden/invoice-approval-handwritten.bpmn', +const HANDWRITTEN_BPMN = readFileSync( + resolve( + dirname(fileURLToPath(import.meta.url)), + 'golden/invoice-approval-handwritten.bpmn', + ), + 'utf-8', ); -let ir1: BpmnProcess; -let ir3: BpmnProcess; -let dslSource: string; +// Each corruption must still show after normalization: the re-key rules +// canonicalize generated ids only, never structure. +const CORRUPTIONS: [string, (ir: BpmnProcess) => BpmnProcess][] = [ + [ + 'dropping a sequence flow', + (ir) => ({ ...ir, sequenceFlows: ir.sequenceFlows.slice(1) }), + ], + [ + 'removing the real split gateway', + (ir) => ({ + ...ir, + flowElements: ir.flowElements.filter( + (fe) => !(fe.kind === 'exclusiveGateway' && fe.id.endsWith('_split')), + ), + }), + ], + [ + 're-targeting a branch flow', + (ir) => ({ + ...ir, + sequenceFlows: ir.sequenceFlows.map((sf) => + sf.targetRef === 'SeniorApproval' + ? { ...sf, targetRef: 'AutoApprove' } + : sf, + ), + }), + ], + [ + 'stripping the split gateway default flow', + (ir) => ({ + ...ir, + flowElements: ir.flowElements.map((fe) => + isGateway(fe) && gatewayDefaultFlowId(fe) !== undefined + ? { kind: fe.kind, id: fe.id, name: fe.name } + : fe, + ), + }), + ], +]; -beforeAll(async () => { - ({ ir: ir1 } = await xmlToIr(readFileSync(HANDWRITTEN_BPMN_PATH, 'utf-8'))); - dslSource = printDsl(ir1); - const xml2 = await irToXml(astToIr(await parseToAst(dslSource))); - ({ ir: ir3 } = await xmlToIr(xml2)); -}); +describe('round trip: BPMN -> IR -> DSL -> IR -> XML -> IR', () => { + it('the hand-named gateway prints as if/else, comes back with a synthesized default flow, and IR3 equals IR1 but for any structural corruption', async () => { + const { ir: ir1 } = await xmlToIr(HANDWRITTEN_BPMN); + const dslSource = printDsl(ir1); + const xml2 = await irToXml(astToIr(await parseToAst(dslSource))); + const { ir: ir3 } = await xmlToIr(xml2); -describe('Round-trip equivalence: BPMN -> IR -> DSL -> IR -> XML -> IR', () => { - it('ir1 and ir3 are semantically equivalent after normalization', () => { expect(normalizeIr(ir3)).toEqual(normalizeIr(ir1)); - }); - - it('the hand-named gateway prints as if/else and comes back with a synthesized default flow', () => { - // The language has no edge-id syntax, so the hand-named `AutoApprovePath` - // comes back as `Flow__default`. expect(dslSource).toBe( [ 'process invoice-approval {', @@ -63,6 +92,7 @@ describe('Round-trip equivalence: BPMN -> IR -> DSL -> IR -> XML -> IR', () => { ].join('\n'), ); + // No edge-id syntax, so `AutoApprovePath` comes back as `Flow__default`. const gw = theOnly( ir3, 'exclusiveGateway', @@ -72,54 +102,11 @@ describe('Round-trip equivalence: BPMN -> IR -> DSL -> IR -> XML -> IR', () => { expect( ir3.sequenceFlows.find((sf) => sf.id === gw.defaultFlowId)?.targetRef, ).toBe('AutoApprove'); - }); -}); -// Each row corrupts ir3 and asserts normalizeIr still reports a difference: -// the re-key rules canonicalize generated ids only, never structure. -describe('normalizeIr preserves structural differences', () => { - it.each([ - [ - 'dropping a sequence flow', - (ir: BpmnProcess): BpmnProcess => ({ - ...ir, - sequenceFlows: ir.sequenceFlows.slice(1), - }), - ], - [ - 'removing the real split gateway', - (ir: BpmnProcess): BpmnProcess => ({ - ...ir, - flowElements: ir.flowElements.filter( - (fe) => !(fe.kind === 'exclusiveGateway' && fe.id.endsWith('_split')), - ), - }), - ], - [ - 're-targeting a branch flow', - (ir: BpmnProcess): BpmnProcess => ({ - ...ir, - sequenceFlows: ir.sequenceFlows.map((sf) => - sf.targetRef === 'SeniorApproval' - ? { ...sf, targetRef: 'AutoApprove' } - : sf, - ), - }), - ], - [ - 'stripping the split gateway default flow', - (ir: BpmnProcess): BpmnProcess => ({ - ...ir, - flowElements: ir.flowElements.map((fe) => - isGateway(fe) && gatewayDefaultFlowId(fe) !== undefined - ? { kind: fe.kind, id: fe.id, name: fe.name } - : fe, - ), - }), - ], - ])('%s from ir3 makes the comparison fail', (_title, corrupt) => { - const corrupted = corrupt(ir3); - expect(corrupted).not.toEqual(ir3); - expect(normalizeIr(corrupted)).not.toEqual(normalizeIr(ir1)); + for (const [title, corrupt] of CORRUPTIONS) { + const corrupted = corrupt(ir3); + expect(corrupted, title).not.toEqual(ir3); + expect(normalizeIr(corrupted), title).not.toEqual(normalizeIr(ir1)); + } }); }); diff --git a/tests/task-kinds.round-trip.test.ts b/tests/task-kinds.round-trip.test.ts index b933844..91c03c1 100644 --- a/tests/task-kinds.round-trip.test.ts +++ b/tests/task-kinds.round-trip.test.ts @@ -1,6 +1,5 @@ -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; -import { describeNoOverlappingShapes } from './helpers/di-bounds.js'; import { definitionRefOf, idsOfTag, @@ -23,62 +22,48 @@ function openingTag(tag: string, id: string): string { return found![0]; } -describe('tag choice on the frozen .bpmn', () => { - it('every statement kind is written under its own tag', () => { - expect( - Object.fromEntries( - ['task', 'sendTask', 'receiveTask', 'businessRuleTask'].map((tag) => [ - tag, - idsOfTag(rt.frozenXml, tag), - ]), - ), - ).toEqual({ - task: ['RecordOrder'], - sendTask: ['NotifyWarehouse'], - receiveTask: ['AwaitSettlement', 'AwaitPickingSlot'], - businessRuleTask: ['RateRisk', 'ChooseCarrier', 'PriceShipping'], - }); - }); -}); - -describe('root derivation on the frozen .bpmn', () => { - it('carries one bpmn:Message per distinct name, and every carrier that names it references that root', () => { - expect(messageRoots(rt.frozenXml)).toEqual([ - { id: 'Message_PaymentSettled', name: 'PaymentSettled' }, - { id: 'Message_PickingSlotReady', name: 'PickingSlotReady' }, - ]); - expect(openingTag('receiveTask', 'AwaitSettlement')).toBe( - '', - ); - expect(definitionRefOf(rt.frozenXml, 'ForwardSettlement', 'message')).toBe( - 'Message_PaymentSettled', - ); - expect(openingTag('receiveTask', 'AwaitPickingSlot')).toBe( - '', - ); - }); -}); - -describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { - it("the decompiled DSL' writes each kind back on its own statement", () => { - expect(rt.dslPrime).toBe( - [ - 'process order-settlement {', - ' start OrderReceived', - ' step RecordOrder(label: "Record the order")', - ' send NotifyWarehouse(label: "Tell the warehouse", class: "com.example.orders.NotifyWarehouseDelegate")', - ' receive AwaitSettlement(label: "Wait for the payment", message: "PaymentSettled")', - ' receive AwaitPickingSlot(label: "Wait for a picking slot")', - ' emit message ConfirmPicking("PickingSlotReady", class: "com.example.orders.ConfirmPickingDelegate")', - ' decide RateRisk(label: "Rate the order risk", decision: "riskRating", binding: latest, mapDecisionResult: singleEntry, resultVariable: "risk")', - ' decide ChooseCarrier(label: "Choose a carrier", class: "com.example.orders.ChooseCarrierDelegate")', - ' decide PriceShipping(label: "Price the shipping", decision: "shippingTariff", version: 3)', - ' throw message ForwardSettlement("PaymentSettled", class: "com.example.orders.PublishSettlementDelegate")', - '}', - '', - ].join('\n'), - ); +it('writes every kind under its own tag, one bpmn:Message per name referenced by its carriers, and prints each kind back', () => { + expect( + Object.fromEntries( + ['task', 'sendTask', 'receiveTask', 'businessRuleTask'].map((tag) => [ + tag, + idsOfTag(rt.frozenXml, tag), + ]), + ), + ).toEqual({ + task: ['RecordOrder'], + sendTask: ['NotifyWarehouse'], + receiveTask: ['AwaitSettlement', 'AwaitPickingSlot'], + businessRuleTask: ['RateRisk', 'ChooseCarrier', 'PriceShipping'], }); + expect(messageRoots(rt.frozenXml)).toEqual([ + { id: 'Message_PaymentSettled', name: 'PaymentSettled' }, + { id: 'Message_PickingSlotReady', name: 'PickingSlotReady' }, + ]); + expect(openingTag('receiveTask', 'AwaitSettlement')).toBe( + '', + ); + expect(definitionRefOf(rt.frozenXml, 'ForwardSettlement', 'message')).toBe( + 'Message_PaymentSettled', + ); + expect(openingTag('receiveTask', 'AwaitPickingSlot')).toBe( + '', + ); + expect(rt.dslPrime).toBe( + [ + 'process order-settlement {', + ' start OrderReceived', + ' step RecordOrder(label: "Record the order")', + ' send NotifyWarehouse(label: "Tell the warehouse", class: "com.example.orders.NotifyWarehouseDelegate")', + ' receive AwaitSettlement(label: "Wait for the payment", message: "PaymentSettled")', + ' receive AwaitPickingSlot(label: "Wait for a picking slot")', + ' emit message ConfirmPicking("PickingSlotReady", class: "com.example.orders.ConfirmPickingDelegate")', + ' decide RateRisk(label: "Rate the order risk", decision: "riskRating", binding: latest, mapDecisionResult: singleEntry, resultVariable: "risk")', + ' decide ChooseCarrier(label: "Choose a carrier", class: "com.example.orders.ChooseCarrierDelegate")', + ' decide PriceShipping(label: "Price the shipping", decision: "shippingTariff", version: 3)', + ' throw message ForwardSettlement("PaymentSettled", class: "com.example.orders.PublishSettlementDelegate")', + '}', + '', + ].join('\n'), + ); }); - -describeNoOverlappingShapes(rt); diff --git a/tests/transactions.round-trip.test.ts b/tests/transactions.round-trip.test.ts index 31adc14..9e04082 100644 --- a/tests/transactions.round-trip.test.ts +++ b/tests/transactions.round-trip.test.ts @@ -1,11 +1,6 @@ -import { describe, it, expect } from 'vitest'; +import { it, expect } from 'vitest'; -import { - boundsOf, - describeDiContainment, - describeNoOverlappingShapes, - parseShapeBounds, -} from './helpers/di-bounds.js'; +import { boundsOf, parseShapeBounds } from './helpers/di-bounds.js'; import { endEvent, subProcess, theOnly } from './helpers/ir-query.js'; import { idsOfTag } from './helpers/xml-query.js'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; @@ -17,8 +12,7 @@ const rt = roundTripFixture('transactions', { recompile: 'clean', }); -// Neither block nests another of its own tag, so a lazy match to the first -// closing tag reads exactly one block. +// No block nests another of its own tag, so the lazy match reads exactly one. function blockOf(xml: string, tag: string, id: string): string { const found = new RegExp( ``, @@ -30,8 +24,8 @@ function blockOf(xml: string, tag: string, id: string): string { return found![0]; } -describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { - it.each([ +it('keeps both kinds of block, the cancel pair and the repetition at every hop, written back in the surface spelling', () => { + const picks = [ [ 'the outer block that can be given up keeps its element', (ir: BpmnProcess) => subProcess(ir, 'BookAndPay').element, @@ -93,123 +87,86 @@ describe("idempotence: golden .bpmn -> IR2 -> DSL' -> IR3", () => { asyncBefore: true, }, ], - ] as const)('%s at every hop', (_title, pick, expected) => { - for (const [label, ir] of rt.hops) { - expect(pick(ir), `differs in ${label}`).toEqual(expected); + ] as const; + for (const [label, ir] of rt.hops) { + for (const [title, pick, expected] of picks) { + expect(pick(ir), `${title} in ${label}`).toEqual(expected); } - }); - - it("the decompiled DSL' writes every head and trigger back in the surface spelling", () => { - expect(rt.dslPrime).toContain( - 'attempt BookAndPay(label: "Try to book and pay for the seats") {', - ); - expect(rt.dslPrime).toContain( - 'attempt AssignSeatRows for each row in seatRows sequentially(' + - 'label: "Spread the party across rows", asyncBefore: true) {', - ); - expect(rt.dslPrime).toContain( - 'subprocess HoldSeats(label: "Hold the seats") {', - ); - expect(rt.dslPrime).toContain( - 'end BookingAbandoned cancel(label: "Give up the booking")', - ); - expect(rt.dslPrime).toContain('on BookAndPay: cancel {'); - expect(rt.dslPrime).toContain( - 'on BookAndPay: error(PAYMENT_UNAVAILABLE, code: c, message: m) {', - ); - }); + } + for (const line of [ + 'attempt BookAndPay(label: "Try to book and pay for the seats") {', + 'attempt AssignSeatRows for each row in seatRows sequentially(' + + 'label: "Spread the party across rows", asyncBefore: true) {', + 'subprocess HoldSeats(label: "Hold the seats") {', + 'end BookingAbandoned cancel(label: "Give up the booking")', + 'on BookAndPay: cancel {', + 'on BookAndPay: error(PAYMENT_UNAVAILABLE, code: c, message: m) {', + ]) { + expect(rt.dslPrime).toContain(line); + } }); -describe('block shape pins on the frozen .bpmn', () => { - it('freezes the two heads as two tags, side by side', () => { - expect(idsOfTag(rt.frozenXml, 'transaction')).toEqual([ - 'BookAndPay', - 'AssignSeatRows', - ]); - expect(idsOfTag(rt.frozenXml, 'subProcess')).toEqual([ - 'HoldSeats', - 'EventSubProcess_seat-booking_2_1_3', - 'PrepareDeparture', - ]); - }); - - it('the cancel end sits inside the block it gives up and carries a bare definition', () => { - expect(rt.frozenXml.match(/]*>/g)).toEqual([ - '', - '', - ]); - - const block = blockOf(rt.frozenXml, 'transaction', 'BookAndPay'); - expect(blockOf(block, 'endEvent', 'BookingAbandoned')).toBe( - '\n' + - ' Flow_Gateway_seat-booking_2_3_split_BookingAbandoned\n' + - ' \n' + - ' ', - ); - expect(block.match(/ { - expect( - blockOf(rt.frozenXml, 'boundaryEvent', 'Boundary_BookAndPay_cancel'), - ).toBe( - '\n' + - ' Flow_Boundary_BookAndPay_cancel_ApologizeToTraveler\n' + - ' \n' + - ' ', - ); - expect( - blockOf(rt.frozenXml, 'boundaryEvent', 'Boundary_BookAndPay_error'), - ).toBe( - '\n' + - ' Flow_Boundary_BookAndPay_error_RecordProviderOutage\n' + - ' \n' + - ' ', - ); - }); - - it('the undo block inside the block is a triggeredByEvent block on a compensate start', () => { - const undo = blockOf( - rt.frozenXml, - 'subProcess', - 'EventSubProcess_seat-booking_2_1_3', - ); - expect(undo).toContain('triggeredByEvent="true"'); - expect(undo).toContain(''); - }); +it('writes the two heads as two tags, the cancel end inside its block, both handlers as boundaries centered on it, the undo block and the repetition', () => { + expect(idsOfTag(rt.frozenXml, 'transaction')).toEqual([ + 'BookAndPay', + 'AssignSeatRows', + ]); + expect(idsOfTag(rt.frozenXml, 'subProcess')).toEqual([ + 'HoldSeats', + 'EventSubProcess_seat-booking_2_1_3', + 'PrepareDeparture', + ]); + expect(rt.frozenXml.match(/]*>/g)).toEqual([ + '', + '', + ]); - it('the repeated block writes its repetition and its setting on the transaction tag', () => { - const block = blockOf(rt.frozenXml, 'transaction', 'AssignSeatRows'); - expect(block).toContain('operaton:asyncBefore="true"'); - expect(block).toContain( - '', + const block = blockOf(rt.frozenXml, 'transaction', 'BookAndPay'); + expect(blockOf(block, 'endEvent', 'BookingAbandoned')).toBe( + '\n' + + ' Flow_Gateway_seat-booking_2_3_split_BookingAbandoned\n' + + ' \n' + + ' ', + ); + expect(block.match(/\n' + + ' Flow_Boundary_BookAndPay_cancel_ApologizeToTraveler\n' + + ' \n' + + ' ', + ); + expect( + blockOf(rt.frozenXml, 'boundaryEvent', 'Boundary_BookAndPay_error'), + ).toBe( + '\n' + + ' Flow_Boundary_BookAndPay_error_RecordProviderOutage\n' + + ' \n' + + ' ', + ); + const undo = blockOf( + rt.frozenXml, + 'subProcess', + 'EventSubProcess_seat-booking_2_1_3', + ); + expect(undo).toContain('triggeredByEvent="true"'); + expect(undo).toContain(''); + const repeated = blockOf(rt.frozenXml, 'transaction', 'AssignSeatRows'); + expect(repeated).toContain('operaton:asyncBefore="true"'); + expect(repeated).toContain( + '', + ); + const bounds = parseShapeBounds(rt.frozenXml); + const host = boundsOf(bounds, 'BookAndPay'); + for (const id of [ + 'Boundary_BookAndPay_cancel', + 'Boundary_BookAndPay_error', + ]) { + const attacher = boundsOf(bounds, id); + expect(attacher.y + attacher.height / 2, id).toBeCloseTo( + host.y + host.height, + 3, ); - }); + } }); - -describeNoOverlappingShapes(rt); - -describe('boundary placement on the frozen .bpmn', () => { - it('both attachers sit centered on the lower edge of the block they watch', () => { - const bounds = parseShapeBounds(rt.frozenXml); - const host = boundsOf(bounds, 'BookAndPay'); - - for (const id of [ - 'Boundary_BookAndPay_cancel', - 'Boundary_BookAndPay_error', - ]) { - const attacher = boundsOf(bounds, id); - expect( - attacher.y + attacher.height / 2, - `${id} is not centered on the lower edge of BookAndPay`, - ).toBeCloseTo(host.y + host.height, 3); - } - }); -}); - -describeDiContainment(rt, [ - 'BookAndPay', - 'HoldSeats', - 'PrepareDeparture', - 'AssignSeatRows', -]); From b85718446643c986e4fc4c9f9f5e8eeefd687015 Mon Sep 17 00:00:00 2001 From: Marlon Kranz Date: Sat, 26 Sep 2026 14:17:02 +0200 Subject: [PATCH 09/11] Build the engine image once under a fixed tag and share one engine across the e2e files --- tests/e2e/awaiting-confirmation.test.ts | 11 ++-- tests/e2e/booking-attempt.test.ts | 11 ++-- tests/e2e/boundary-events.test.ts | 11 ++-- tests/e2e/branch-and-race.test.ts | 13 ++--- tests/e2e/deploy-sweep.test.ts | 21 ++++---- tests/e2e/engine-extensions.test.ts | 11 ++-- tests/e2e/event-positions.test.ts | 11 ++-- tests/e2e/forms-and-external-tasks.test.ts | 11 ++-- tests/e2e/invoice-approval.test.ts | 11 ++-- tests/e2e/job-settings-and-mail.test.ts | 13 ++--- tests/e2e/loan-approval-kopp.test.ts | 11 ++-- tests/e2e/loan-approval.test.ts | 11 ++-- tests/e2e/parallel-approval.test.ts | 11 ++-- tests/e2e/repetition.test.ts | 11 ++-- .../service-boundary-and-compensation.test.ts | 11 ++-- tests/e2e/task-kinds.test.ts | 13 ++--- tests/fixtures/adapters/spring-boot.ts | 51 ++----------------- tests/fixtures/engine.ts | 48 +++++++++++++++++ tests/fixtures/index.ts | 2 +- tests/fixtures/types.ts | 5 -- tests/helpers/e2e-fixture.ts | 10 ++-- tests/helpers/engine-rest.ts | 15 ++++-- tests/vitest.config.ts | 25 ++++++--- 23 files changed, 144 insertions(+), 204 deletions(-) create mode 100644 tests/fixtures/engine.ts diff --git a/tests/e2e/awaiting-confirmation.test.ts b/tests/e2e/awaiting-confirmation.test.ts index 6c9b0fa..b4e4a59 100644 --- a/tests/e2e/awaiting-confirmation.test.ts +++ b/tests/e2e/awaiting-confirmation.test.ts @@ -4,13 +4,12 @@ // catch, still running with an active message subscription, before correlating // the message and watching it finish. -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import type { FixtureAdapter } from '../fixtures/index.js'; import { deployExamples, - ENGINE_BOOT_TIMEOUT_MS, - ENGINE_STOP_TIMEOUT_MS, + DEPLOY_TIMEOUT_MS, SKIP_DOCKER as SKIP, } from '../helpers/e2e-fixture.js'; import { @@ -31,11 +30,7 @@ describe.skipIf(SKIP)('E2E: await message on Spring Boot Operaton', () => { beforeAll(async () => { fixture = await deployExamples('awaiting-confirmation'); - }, ENGINE_BOOT_TIMEOUT_MS); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); it('blocks at the message catch until the message is correlated, then completes', async () => { const { processInstanceId } = await fixture.startProcess(PROCESS_KEY, {}); diff --git a/tests/e2e/booking-attempt.test.ts b/tests/e2e/booking-attempt.test.ts index 60c98bd..525e9fa 100644 --- a/tests/e2e/booking-attempt.test.ts +++ b/tests/e2e/booking-attempt.test.ts @@ -1,10 +1,9 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import type { FixtureAdapter } from '../fixtures/index.js'; import { deployExamples, - ENGINE_BOOT_TIMEOUT_MS, - ENGINE_STOP_TIMEOUT_MS, + DEPLOY_TIMEOUT_MS, SKIP_DOCKER as SKIP, } from '../helpers/e2e-fixture.js'; import type { HistoricActivityInstance } from '../helpers/engine-rest.js'; @@ -30,11 +29,7 @@ describe.skipIf(SKIP)( beforeAll(async () => { fixture = await deployExamples(PROCESS_KEY); - }, ENGINE_BOOT_TIMEOUT_MS); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); // Starts one booking and reads its history once `marker` has been recorded, // so every assertion below reads a run that reached at least that far. diff --git a/tests/e2e/boundary-events.test.ts b/tests/e2e/boundary-events.test.ts index 758e903..ebe8830 100644 --- a/tests/e2e/boundary-events.test.ts +++ b/tests/e2e/boundary-events.test.ts @@ -4,13 +4,12 @@ // is driven by correlating a message over REST, never by waiting on a clock. // The process also carries a PT4H timer boundary that stays dormant throughout. -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import type { FixtureAdapter } from '../fixtures/index.js'; import { deployExamples, - ENGINE_BOOT_TIMEOUT_MS, - ENGINE_STOP_TIMEOUT_MS, + DEPLOY_TIMEOUT_MS, SKIP_DOCKER as SKIP, } from '../helpers/e2e-fixture.js'; import { @@ -30,11 +29,7 @@ describe.skipIf(SKIP)('E2E: boundary events on Spring Boot Operaton', () => { beforeAll(async () => { fixture = await deployExamples('order-handling'); - }, ENGINE_BOOT_TIMEOUT_MS); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); // The escalation thrown inside the payment sub-process is part of the main // narrative: its non-interrupting boundary opens the supervisory review task diff --git a/tests/e2e/branch-and-race.test.ts b/tests/e2e/branch-and-race.test.ts index 063de7c..9d41fe8 100644 --- a/tests/e2e/branch-and-race.test.ts +++ b/tests/e2e/branch-and-race.test.ts @@ -1,10 +1,9 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import type { FixtureAdapter } from '../fixtures/index.js'; import { deployExamples, - ENGINE_BOOT_TIMEOUT_MS, - ENGINE_STOP_TIMEOUT_MS, + DEPLOY_TIMEOUT_MS, SKIP_DOCKER as SKIP, } from '../helpers/e2e-fixture.js'; import { @@ -36,11 +35,7 @@ describe.skipIf(SKIP)( beforeAll(async () => { fixture = await deployExamples(PROCESS_KEY); - }, ENGINE_BOOT_TIMEOUT_MS); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); async function reachRace(): Promise { const { processInstanceId } = await fixture.startProcess(PROCESS_KEY, { @@ -59,7 +54,7 @@ describe.skipIf(SKIP)( it('deploys, so the engine parser accepts both gateway tags as they are written', async () => { const definitions = await engineGet>( fixture, - `/engine-rest/process-definition?key=${encodeURIComponent(PROCESS_KEY)}`, + `/engine-rest/process-definition?withoutTenantId=true&key=${encodeURIComponent(PROCESS_KEY)}`, `processDefinitions(${PROCESS_KEY})`, ); expect(definitions.map((definition) => definition.key)).toContain( diff --git a/tests/e2e/deploy-sweep.test.ts b/tests/e2e/deploy-sweep.test.ts index 4f7cc0e..48f1b30 100644 --- a/tests/e2e/deploy-sweep.test.ts +++ b/tests/e2e/deploy-sweep.test.ts @@ -1,4 +1,4 @@ -// Every golden, fixture and example on the engine, in one boot: once as the +// Every golden, fixture and example on the engine: once as the // file is written, and once rebuilt from the tool's own print of it, where a // deployable file whose print compiles back to one the engine refuses would // hide. @@ -6,10 +6,12 @@ // Each row deploys in a tenant of its own, because // BpmnDeployer.addMessageStartEventSubscription refuses a second message start // of one name across process definitions in one tenant, and two independent -// files here start on the same message. The same process key deployed twice -// is only a new version to the engine and needs nothing. +// files here start on the same message. The tenants also keep the sweep out of +// the other e2e files, which share this engine and read only the default +// tenant. The same process key deployed twice is only a new version to the +// engine and needs nothing. -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { basename, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -19,8 +21,7 @@ import { astToIr, irToXml, xmlToIr } from '@bpmn-script/transform'; import { buildExample, startFixture } from '../fixtures/index.js'; import type { FixtureAdapter } from '../fixtures/index.js'; import { - ENGINE_BOOT_TIMEOUT_MS, - ENGINE_STOP_TIMEOUT_MS, + DEPLOY_TIMEOUT_MS, SKIP_DOCKER as SKIP, } from '../helpers/e2e-fixture.js'; import { parseToAst, printDsl } from '../helpers/pipeline.js'; @@ -92,12 +93,8 @@ describe.skipIf(SKIP)('E2E: every golden, fixture and example deploys', () => { buildExample(row.dslPath, row.xmlPath); } } - fixture = await startFixture(); - }, ENGINE_BOOT_TIMEOUT_MS); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + fixture = startFixture(); + }, DEPLOY_TIMEOUT_MS); it.each(ROWS)( '$key deploys as written', diff --git a/tests/e2e/engine-extensions.test.ts b/tests/e2e/engine-extensions.test.ts index db99e56..9b5fdc7 100644 --- a/tests/e2e/engine-extensions.test.ts +++ b/tests/e2e/engine-extensions.test.ts @@ -3,13 +3,12 @@ // does not break the transaction all compile to the same well-formed file, so // only a real Operaton can tell them apart. -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import type { FixtureAdapter } from '../fixtures/index.js'; import { deployExamples, - ENGINE_BOOT_TIMEOUT_MS, - ENGINE_STOP_TIMEOUT_MS, + DEPLOY_TIMEOUT_MS, SKIP_DOCKER as SKIP, } from '../helpers/e2e-fixture.js'; import { @@ -116,11 +115,7 @@ describe.skipIf(SKIP)( beforeAll(async () => { fixture = await deployExamples('engine-extensions'); - }, ENGINE_BOOT_TIMEOUT_MS); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); it('fires both execution listeners of a service task, its delegate, and the task listener, in that order', async () => { const { processInstanceId } = await startAndReachUserTask(); diff --git a/tests/e2e/event-positions.test.ts b/tests/e2e/event-positions.test.ts index cb29576..4f5a689 100644 --- a/tests/e2e/event-positions.test.ts +++ b/tests/e2e/event-positions.test.ts @@ -7,7 +7,7 @@ // because what separates it from a plain end is that it stops a sibling branch // still parked on its own task. -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -18,8 +18,7 @@ import type { FixtureAdapter } from '../fixtures/index.js'; import { deployExamples, dslPath, - ENGINE_BOOT_TIMEOUT_MS, - ENGINE_STOP_TIMEOUT_MS, + DEPLOY_TIMEOUT_MS, SKIP_DOCKER as SKIP, } from '../helpers/e2e-fixture.js'; import { @@ -69,11 +68,7 @@ describe.skipIf(SKIP)('E2E: start, end, throw and link on Operaton', () => { 'support-ticket', 'order-rework', ); - }, ENGINE_BOOT_TIMEOUT_MS); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); it('message start: a correlated message with no instance to aim at starts one', async () => { const processInstanceId = await startByMessage(fixture, 'OrderReceived'); diff --git a/tests/e2e/forms-and-external-tasks.test.ts b/tests/e2e/forms-and-external-tasks.test.ts index 3f702ea..ad8a7fd 100644 --- a/tests/e2e/forms-and-external-tasks.test.ts +++ b/tests/e2e/forms-and-external-tasks.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import { randomUUID } from 'node:crypto'; import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; @@ -9,8 +9,7 @@ import { irToXml, xmlToIr } from '@bpmn-script/transform'; import type { FixtureAdapter } from '../fixtures/index.js'; import { deployExamples, - ENGINE_BOOT_TIMEOUT_MS, - ENGINE_STOP_TIMEOUT_MS, + DEPLOY_TIMEOUT_MS, SKIP_DOCKER as SKIP, } from '../helpers/e2e-fixture.js'; import { @@ -67,11 +66,7 @@ describe.skipIf(SKIP)('E2E: forms and external tasks on Operaton', () => { beforeAll(async () => { fixture = await deployExamples(CHARGE_KEY, PLAN_KEY); - }, ENGINE_BOOT_TIMEOUT_MS); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); async function startAndFetchCharge() { const businessKey = randomUUID(); diff --git a/tests/e2e/invoice-approval.test.ts b/tests/e2e/invoice-approval.test.ts index 93c76ef..8189294 100644 --- a/tests/e2e/invoice-approval.test.ts +++ b/tests/e2e/invoice-approval.test.ts @@ -2,7 +2,7 @@ // so only a real instance shows which branch an exclusive gateway takes. Both // amounts run here. -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import { execFileSync } from 'node:child_process'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -10,8 +10,7 @@ import { fileURLToPath } from 'node:url'; import type { FixtureAdapter } from '../fixtures/index.js'; import { deployExamples, - ENGINE_BOOT_TIMEOUT_MS, - ENGINE_STOP_TIMEOUT_MS, + DEPLOY_TIMEOUT_MS, SKIP_DOCKER as SKIP, } from '../helpers/e2e-fixture.js'; import { waitForTasks } from '../helpers/engine-rest.js'; @@ -28,11 +27,7 @@ describe.skipIf(SKIP)('E2E: invoice-approval on Spring Boot Operaton', () => { beforeAll(async () => { fixture = await deployExamples('invoice-approval'); - }, ENGINE_BOOT_TIMEOUT_MS); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); // ReviewStart -> ReviewInvoice -> AmountCheck -> SeniorApproval. it('happy path: senior approval branch', async () => { diff --git a/tests/e2e/job-settings-and-mail.test.ts b/tests/e2e/job-settings-and-mail.test.ts index e9ca08a..239fa74 100644 --- a/tests/e2e/job-settings-and-mail.test.ts +++ b/tests/e2e/job-settings-and-mail.test.ts @@ -2,7 +2,7 @@ // runs itself, compile to well-formed XML that shows nothing of what the // engine does with them; this suite reads back what a real Operaton did. -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,8 +13,7 @@ import type { BpmnProcess } from '@bpmn-script/transform'; import type { FixtureAdapter } from '../fixtures/index.js'; import { deployExamples, - ENGINE_BOOT_TIMEOUT_MS, - ENGINE_STOP_TIMEOUT_MS, + DEPLOY_TIMEOUT_MS, irOfExample, SKIP_DOCKER as SKIP, } from '../helpers/e2e-fixture.js'; @@ -72,11 +71,7 @@ describe.skipIf(SKIP)('E2E: job settings, shell and mail tasks', () => { beforeAll(async () => { fixture = await deployExamples(REPORT_KEY, NOTICE_KEY); - }, ENGINE_BOOT_TIMEOUT_MS); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); async function parkedJobs( processInstanceId: string, @@ -217,7 +212,7 @@ describe.skipIf(SKIP)('E2E: job settings, shell and mail tasks', () => { it("a mail task deploys, and one the engine's parse refuses is refused with its message", async () => { const deployed = await engineGet>( fixture, - `/engine-rest/process-definition?key=${NOTICE_KEY}`, + `/engine-rest/process-definition?withoutTenantId=true&key=${NOTICE_KEY}`, `definitionsOf(${NOTICE_KEY})`, ); expect(deployed.map((d) => d.key)).toEqual([NOTICE_KEY]); diff --git a/tests/e2e/loan-approval-kopp.test.ts b/tests/e2e/loan-approval-kopp.test.ts index 4819cfc..7534882 100644 --- a/tests/e2e/loan-approval-kopp.test.ts +++ b/tests/e2e/loan-approval-kopp.test.ts @@ -2,14 +2,13 @@ // short-circuits are engine decisions, so both halves run for real. The // always-on guard below holds the structural facts when Docker is skipped. -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import type { FixtureAdapter } from '../fixtures/index.js'; import { deployExamples, irOfExample, SKIP_DOCKER as SKIP, - ENGINE_BOOT_TIMEOUT_MS, - ENGINE_STOP_TIMEOUT_MS, + DEPLOY_TIMEOUT_MS, } from '../helpers/e2e-fixture.js'; import { waitForTasks } from '../helpers/engine-rest.js'; @@ -47,11 +46,7 @@ describe.skipIf(SKIP)('E2E: loan-approval-kopp on Spring Boot Operaton', () => { beforeAll(async () => { fixture = await deployExamples('loan-approval-kopp'); - }, ENGINE_BOOT_TIMEOUT_MS); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); // creditScore = 750 gives intRes = "low", which opens the conditional // `AssessRisk` task inside the parallel branch while the external branches diff --git a/tests/e2e/loan-approval.test.ts b/tests/e2e/loan-approval.test.ts index 294559e..2bda690 100644 --- a/tests/e2e/loan-approval.test.ts +++ b/tests/e2e/loan-approval.test.ts @@ -2,14 +2,13 @@ // the one a submitted form field decides. The always-on guard below holds the // structural facts when Docker is skipped. -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import type { FixtureAdapter } from '../fixtures/index.js'; import { deployExamples, irOfExample, SKIP_DOCKER as SKIP, - ENGINE_BOOT_TIMEOUT_MS, - ENGINE_STOP_TIMEOUT_MS, + DEPLOY_TIMEOUT_MS, } from '../helpers/e2e-fixture.js'; import { waitForTasks } from '../helpers/engine-rest.js'; @@ -47,11 +46,7 @@ describe.skipIf(SKIP)('E2E: loan-approval on Spring Boot Operaton', () => { beforeAll(async () => { fixture = await deployExamples('loan-approval'); - }, ENGINE_BOOT_TIMEOUT_MS); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); // amount = 5000, creditScore = 800 runs RequestReceived -> AssessRisk // (risk="low") -> AutoApprove (approved=true) -> NotifyAccepted -> Done with diff --git a/tests/e2e/parallel-approval.test.ts b/tests/e2e/parallel-approval.test.ts index cebe883..6963069 100644 --- a/tests/e2e/parallel-approval.test.ts +++ b/tests/e2e/parallel-approval.test.ts @@ -3,14 +3,13 @@ // the token until both complete. The always-on guard below checks the fork/join // pair in the IR when Docker is skipped. -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import type { FixtureAdapter } from '../fixtures/index.js'; import { deployExamples, irOfExample, SKIP_DOCKER as SKIP, - ENGINE_BOOT_TIMEOUT_MS, - ENGINE_STOP_TIMEOUT_MS, + DEPLOY_TIMEOUT_MS, } from '../helpers/e2e-fixture.js'; import { waitForTasks } from '../helpers/engine-rest.js'; @@ -40,11 +39,7 @@ describe.skipIf(SKIP)('E2E: parallel-approval on Spring Boot Operaton', () => { beforeAll(async () => { fixture = await deployExamples('parallel-approval'); - }, ENGINE_BOOT_TIMEOUT_MS); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); // A parallel gateway fires every outgoing branch unconditionally, where an // exclusive gateway fires exactly one. diff --git a/tests/e2e/repetition.test.ts b/tests/e2e/repetition.test.ts index 7a42d2e..079a006 100644 --- a/tests/e2e/repetition.test.ts +++ b/tests/e2e/repetition.test.ts @@ -1,10 +1,9 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import type { ActiveTask, FixtureAdapter } from '../fixtures/index.js'; import { deployExamples, - ENGINE_BOOT_TIMEOUT_MS, - ENGINE_STOP_TIMEOUT_MS, + DEPLOY_TIMEOUT_MS, SKIP_DOCKER as SKIP, } from '../helpers/e2e-fixture.js'; import { @@ -24,11 +23,7 @@ describe.skipIf(SKIP)('E2E: repetition on Spring Boot Operaton', () => { beforeAll(async () => { fixture = await deployExamples(PROCESS_KEY, EMPTY_KEY); - }, ENGINE_BOOT_TIMEOUT_MS); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); async function tasksOf( instanceId: string, diff --git a/tests/e2e/service-boundary-and-compensation.test.ts b/tests/e2e/service-boundary-and-compensation.test.ts index 25bf6b2..3dea324 100644 --- a/tests/e2e/service-boundary-and-compensation.test.ts +++ b/tests/e2e/service-boundary-and-compensation.test.ts @@ -8,13 +8,12 @@ // `emit compensation` raised by the process-level error handler has to reach // the undo block of a subprocess that already completed. -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import type { FixtureAdapter } from '../fixtures/index.js'; import { deployExamples, - ENGINE_BOOT_TIMEOUT_MS, - ENGINE_STOP_TIMEOUT_MS, + DEPLOY_TIMEOUT_MS, SKIP_DOCKER as SKIP, } from '../helpers/e2e-fixture.js'; import { @@ -39,11 +38,7 @@ describe.skipIf(SKIP)( CHARGE_PROCESS_KEY, COMPENSATION_PROCESS_KEY, ); - }, ENGINE_BOOT_TIMEOUT_MS); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); describe('error boundary on the charge service task', () => { // A misconfigured boundary surfaces as an HTTP 500 from startProcess, so diff --git a/tests/e2e/task-kinds.test.ts b/tests/e2e/task-kinds.test.ts index 39a9ec5..aacc2f8 100644 --- a/tests/e2e/task-kinds.test.ts +++ b/tests/e2e/task-kinds.test.ts @@ -1,10 +1,9 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import type { FixtureAdapter } from '../fixtures/index.js'; import { deployExamples, - ENGINE_BOOT_TIMEOUT_MS, - ENGINE_STOP_TIMEOUT_MS, + DEPLOY_TIMEOUT_MS, SKIP_DOCKER as SKIP, } from '../helpers/e2e-fixture.js'; import { @@ -30,16 +29,12 @@ describe.skipIf(SKIP)('E2E: task kinds on Spring Boot Operaton', () => { beforeAll(async () => { fixture = await deployExamples(PROCESS_KEY); - }, ENGINE_BOOT_TIMEOUT_MS); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); it('deploys, so the engine parser accepts every tag as it is written', async () => { const definitions = await engineGet>( fixture, - `/engine-rest/process-definition?key=${encodeURIComponent(PROCESS_KEY)}`, + `/engine-rest/process-definition?withoutTenantId=true&key=${encodeURIComponent(PROCESS_KEY)}`, `processDefinitions(${PROCESS_KEY})`, ); expect(definitions.map((definition) => definition.key)).toContain( diff --git a/tests/fixtures/adapters/spring-boot.ts b/tests/fixtures/adapters/spring-boot.ts index e9ab14e..9099c04 100644 --- a/tests/fixtures/adapters/spring-boot.ts +++ b/tests/fixtures/adapters/spring-boot.ts @@ -1,19 +1,8 @@ import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { GenericContainer, Wait } from 'testcontainers'; -import type { StartedTestContainer } from 'testcontainers'; +import { inject } from 'vitest'; import { assertOk } from '../../helpers/engine-rest.js'; import type { ActiveTask, FixtureAdapter } from '../types.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Vitest transforms TS in place, so import.meta.url resolves to this source file. -const SPRING_BOOT_DIR = path.resolve( - __dirname, - '../../../examples/spring-boot', -); - type OperatonVariableType = 'Long' | 'String' | 'Boolean' | 'Double'; interface OperatonVariable { @@ -42,40 +31,13 @@ function toOperatonVariables( } class SpringBootAdapter implements FixtureAdapter { - private container: StartedTestContainer | null = null; - private _restBaseUrl = ''; - - async start(): Promise { - const container = - await GenericContainer.fromDockerfile(SPRING_BOOT_DIR).build(); - - this.container = await container - .withExposedPorts(8080) - .withWaitStrategy( - Wait.forHttp('/engine-rest/engine', 8080) - .forStatusCode(200) - .withReadTimeout(120_000), - ) - .withStartupTimeout(120_000) - .start(); - - const host = this.container.getHost(); - const port = this.container.getMappedPort(8080); - this._restBaseUrl = `http://${host}:${port}`; - } + // Started once per run by the e2e global setup and shared by every file. + private readonly _restBaseUrl = inject('engineRestUrl'); restBaseUrl(): string { return this._restBaseUrl; } - async stop(): Promise { - if (this.container) { - await this.container.stop(); - this.container = null; - this._restBaseUrl = ''; - } - } - async deploy( xmlPath: string, deploymentName = path.basename(xmlPath, '.bpmn'), @@ -174,9 +136,6 @@ class SpringBootAdapter implements FixtureAdapter { } } -// A cold Docker build takes up to 120 seconds. -export async function start(): Promise { - const adapter = new SpringBootAdapter(); - await adapter.start(); - return adapter; +export function start(): FixtureAdapter { + return new SpringBootAdapter(); } diff --git a/tests/fixtures/engine.ts b/tests/fixtures/engine.ts new file mode 100644 index 0000000..725d3e5 --- /dev/null +++ b/tests/fixtures/engine.ts @@ -0,0 +1,48 @@ +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { GenericContainer, Wait } from 'testcontainers'; +import type { TestProject } from 'vitest/node'; + +declare module 'vitest' { + export interface ProvidedContext { + engineRestUrl: string; + } +} + +const ENGINE_IMAGE = 'bpmnscript-engine:e2e'; + +const SPRING_BOOT_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../examples/spring-boot', +); + +// Global setup of the e2e project: one engine for every e2e file. A fixed tag +// makes a rebuild move the tag instead of leaving one more anonymous image +// behind per run. +export async function setup( + project: TestProject, +): Promise<(() => Promise) | undefined> { + if (process.env.SKIP_DOCKER_TESTS === 'true') { + return undefined; + } + await GenericContainer.fromDockerfile(SPRING_BOOT_DIR).build(ENGINE_IMAGE, { + deleteOnExit: false, + }); + const container = await new GenericContainer(ENGINE_IMAGE) + .withExposedPorts(8080) + // Keep the default short read timeout: a probe sent before Spring Boot + // listens can hang for the whole timeout instead of failing fast, which + // with a two-minute timeout held every boot for two minutes. + .withWaitStrategy( + Wait.forHttp('/engine-rest/engine', 8080).forStatusCode(200), + ) + .withStartupTimeout(120_000) + .start(); + project.provide( + 'engineRestUrl', + `http://${container.getHost()}:${container.getMappedPort(8080)}`, + ); + return async () => { + await container.stop(); + }; +} diff --git a/tests/fixtures/index.ts b/tests/fixtures/index.ts index 2599a36..82c121a 100644 --- a/tests/fixtures/index.ts +++ b/tests/fixtures/index.ts @@ -28,6 +28,6 @@ export function buildExample(dslPath: string, xmlOutPath: string): void { ); } -export async function startFixture(): Promise { +export function startFixture(): FixtureAdapter { return springBootAdapter.start(); } diff --git a/tests/fixtures/types.ts b/tests/fixtures/types.ts index a43b9d3..824e19b 100644 --- a/tests/fixtures/types.ts +++ b/tests/fixtures/types.ts @@ -8,9 +8,6 @@ export interface ActiveTask { } export interface FixtureAdapter { - // Returns when the runtime accepts deployments. - start(): Promise; - // A tenant id isolates the deployment's message-start subscriptions from // every other tenant's; without one the deployment is shared. deploy( @@ -32,6 +29,4 @@ export interface FixtureAdapter { ): Promise; restBaseUrl(): string; - - stop(): Promise; } diff --git a/tests/helpers/e2e-fixture.ts b/tests/helpers/e2e-fixture.ts index f575bd3..672f704 100644 --- a/tests/helpers/e2e-fixture.ts +++ b/tests/helpers/e2e-fixture.ts @@ -14,11 +14,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); export const SKIP_DOCKER = process.env.SKIP_DOCKER_TESTS === 'true'; -// A cold image build plus Spring Boot startup needs this much. -export const ENGINE_BOOT_TIMEOUT_MS = 300_000; - -// Bounded by the Docker stop grace period, not by the engine. -export const ENGINE_STOP_TIMEOUT_MS = 120_000; +// Compiling a file's examples and deploying them while every other e2e file +// deploys to the same engine. +export const DEPLOY_TIMEOUT_MS = 60_000; export function dslPath(name: string): string { return resolve( @@ -46,7 +44,7 @@ export async function deployExamples( return { name, xmlPath }; }); - const fixture = await startFixture(); + const fixture = startFixture(); for (const { name, xmlPath } of built) { const { deploymentId } = await fixture.deploy(xmlPath, `${name}-test`); expect(deploymentId).toBeTruthy(); diff --git a/tests/helpers/engine-rest.ts b/tests/helpers/engine-rest.ts index ac21b3e..6f5fd1e 100644 --- a/tests/helpers/engine-rest.ts +++ b/tests/helpers/engine-rest.ts @@ -1,5 +1,10 @@ // The fixture adapter covers deploy, start, task list, and task completion. // Everything else here goes straight at restBaseUrl(). +// +// Every e2e file shares one engine. The deploy sweep puts each of its rows, +// every example among them, in a tenant of its own, and every other file +// deploys into the default tenant, so each query, message and signal below +// that is not already pinned to an instance says `withoutTenantId`. import type { ActiveTask, FixtureAdapter } from '../fixtures/index.js'; @@ -64,7 +69,7 @@ export async function startByMessage( const response = await engineSend( fixture, '/engine-rest/message', - { messageName, resultEnabled: true }, + { messageName, resultEnabled: true, withoutTenantId: true }, `startByMessage(${messageName})`, ); const results = (await response.json()) as Array<{ @@ -86,7 +91,7 @@ export async function broadcastSignal( await engineSend( fixture, '/engine-rest/signal', - { name }, + { name, withoutTenantId: true }, `broadcastSignal(${name})`, ); } @@ -105,7 +110,7 @@ export async function jobsOf( ): Promise { return engineGet( fixture, - `/engine-rest/job?processDefinitionKey=${encodeURIComponent(processDefinitionKey)}`, + `/engine-rest/job?withoutTenantId=true&processDefinitionKey=${encodeURIComponent(processDefinitionKey)}`, `jobsOf(${processDefinitionKey})`, ); } @@ -138,7 +143,7 @@ export async function jobDefinitionsFor( ): Promise { return engineGet( fixture, - `/engine-rest/job-definition?processDefinitionKey=${encodeURIComponent(processDefinitionKey)}&activityIdIn=${encodeURIComponent(activityId)}`, + `/engine-rest/job-definition?withoutTenantId=true&processDefinitionKey=${encodeURIComponent(processDefinitionKey)}&activityIdIn=${encodeURIComponent(activityId)}`, `jobDefinitionsFor(${activityId})`, ); } @@ -186,7 +191,7 @@ export async function historicInstances( ): Promise { return engineGet( fixture, - `/engine-rest/history/process-instance?processDefinitionKey=${encodeURIComponent(processDefinitionKey)}`, + `/engine-rest/history/process-instance?withoutTenantId=true&processDefinitionKey=${encodeURIComponent(processDefinitionKey)}`, `historicInstances(${processDefinitionKey})`, ); } diff --git a/tests/vitest.config.ts b/tests/vitest.config.ts index a52638a..ed9dafb 100644 --- a/tests/vitest.config.ts +++ b/tests/vitest.config.ts @@ -5,11 +5,24 @@ export default defineConfig({ deps: { interopDefault: true, }, - include: ['**/*.test.ts'], - exclude: ['out/**', 'node_modules/**'], - // Each end-to-end file boots its own Operaton container, and Vitest runs - // one file per worker, so on a hosted runner the containers have to take - // turns. - maxWorkers: process.env.CI ? 2 : undefined, + // Split so the engine setup runs only when an e2e file is selected. + projects: [ + { + extends: true, + test: { + name: 'unit', + include: ['**/*.test.ts'], + exclude: ['out/**', 'node_modules/**', 'e2e/**'], + }, + }, + { + extends: true, + test: { + name: 'e2e', + include: ['e2e/**/*.test.ts'], + globalSetup: ['fixtures/engine.ts'], + }, + }, + ], }, }); From 57feeec89aa71ca496013a0174feef2f7b72901a Mon Sep 17 00:00:00 2001 From: Marlon Kranz Date: Sat, 26 Sep 2026 23:01:05 +0200 Subject: [PATCH 10/11] Follow the engine more closely on import, export and print Import warns where the rebuilt document would deploy or run differently, sorts its warnings into dropped content, respellings, behaviour changes and content carried as written, mints ids for elements the engine deploys without one and refuses the ones it cannot deploy or run, and never names a minted id in a refusal. Export escapes tabs and carriage returns in attribute values and carries isStartableInTasklist as a process header setting. The printer keeps an exclusive split's routes in the order the engine tries them, folds a loop only where the engine tests its back edge in the printed order, and prints Operaton's invoice example the same on every pass. The model comparator keeps the joins and condition order that change execution, and the parser words a mistyped step keyword and a missing ) in plain language. --- ...e-ir-into-a-dsl-with-dominator-analysis.md | 23 +- packages/cli/test/build-parse.smoke.test.ts | 6 +- .../extension/test/conversion-core.test.ts | 4 +- packages/language/README.md | 7 +- .../language/src/bpmn-script-completion.ts | 1 + ...mn-script-parser-error-message-provider.ts | 50 +- .../language/src/bpmn-script-validator.ts | 18 +- packages/language/src/bpmn-script.langium | 4 +- packages/language/src/vocabulary.ts | 17 +- packages/language/test/completion.test.ts | 1 + packages/language/test/parsing.test.ts | 38 ++ packages/language/test/validating.test.ts | 17 +- .../language/test/vocabulary-sync.test.ts | 13 +- packages/transform/README.md | 67 +- packages/transform/src/ast-to-ir.ts | 6 + packages/transform/src/cfg-analysis.ts | 2 +- packages/transform/src/errors.ts | 2 +- packages/transform/src/ir-to-dsl.ts | 107 +++- packages/transform/src/ir-to-xml.ts | 33 +- packages/transform/src/ir/types.ts | 2 + packages/transform/src/operaton-moddle.json | 5 + packages/transform/src/synthesize-ids.ts | 12 +- packages/transform/src/xml-to-ir.ts | 320 ++++++++-- packages/transform/test/helpers/bpmn-doc.ts | 10 +- packages/transform/test/ir-to-dsl.test.ts | 501 ++++++++++++++- packages/transform/test/xml-to-ir.test.ts | 571 ++++++++++++++---- tests/boundary-events.round-trip.test.ts | 2 +- tests/call-activity.round-trip.test.ts | 2 +- tests/compensation.round-trip.test.ts | 2 +- tests/e2e/forms-and-external-tasks.test.ts | 6 +- tests/engine-attributes.round-trip.test.ts | 3 + tests/event-handlers.round-trip.test.ts | 2 +- tests/event-triggers.round-trip.test.ts | 2 +- .../fixtures/invoice-receipt-no-fallback.bpmn | 277 +++++++++ tests/fuzz-regressions.test.ts | 24 +- tests/fuzz.test.ts | 15 +- tests/golden/README.md | 4 +- tests/golden/engine-attributes.bpmn | 2 +- tests/golden/engine-attributes.bpmnscript | 3 +- tests/goto-fallback.round-trip.test.ts | 4 +- tests/helpers/bpmn-doc.ts | 2 +- tests/helpers/import-first.ts | 2 +- tests/helpers/model-equivalence.test.ts | 173 +++++- tests/helpers/model-equivalence.ts | 22 +- tests/helpers/normalize-ir.ts | 117 ++-- tests/import-contract.round-trip.test.ts | 2 +- tests/invoice-receipt.import.test.ts | 27 +- tests/round-trip-constructs.test.ts | 23 +- 48 files changed, 2204 insertions(+), 349 deletions(-) create mode 100644 tests/fixtures/invoice-receipt-no-fallback.bpmn diff --git a/docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md b/docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md index 8f5c3d6..4e403f4 100644 --- a/docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md +++ b/docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md @@ -29,7 +29,7 @@ How should `irToDsl` decide which subgraphs can be phrased as `if`, `while`, `pa ## Considered Options - Dominator and post-dominator analysis with a fixed pattern catalog and a `goto` fallback: each construct gets a criterion that reads the graph rather than a heuristic, and the fallback is a form the language already has, so totality costs no new syntax. -- RPST (Refined Program Structure Tree) decomposition: a region tree would recover more structured patterns and would answer the continuation question for every construct at once, but the catalog with a `goto` fallback already covers the current scope. +- RPST (Refined Process Structure Tree) decomposition: a region tree would recover more structured patterns and would answer the continuation question for every construct at once, but the catalog with a `goto` fallback already covers the current scope. - Ad-hoc recursive pattern matching without formal control-flow analysis: there is no criterion to check a match against, so an unstructured graph has no defined outcome and the decompiler is free to loop or to throw. - An `if` chain for a step with more than one route out, and jumps carrying no conditions where a split degrades: the print of a common modeler shape, a task with two plain flows, then runs one route where the model runs both, and a degraded split's conditions are discarded with no marker for the reader to find. - Refusing a step with more than one outgoing flow on import: the printer never sees the shape, but the engine deploys and runs it, and the import contract refuses only what the engine refuses or what changes a stored value. @@ -47,8 +47,11 @@ The pattern catalog: - XOR split with a post-dominating join -> `if`/`else if`/`else` - Statement with more than one route out -> the fork block the engine runs it as, inclusive or parallel -- Unconditioned back-edge from a body exit to the XOR head dominating it -> `while`, the loop condition read from the head's edge into the body -- Conditioned back-edge from an XOR head to the body entry dominating it -> `do...while`, the loop condition read from the back-edge itself +- Unconditioned back-edge from a body exit to the XOR head dominating it -> `while`, the loop condition read from the head's edge into the body, when that edge is the head's first route other than its default + Operaton tests the routes in document order, so an exit listed before the body route is tested first, which `while` cannot say, and the head then prints as an `if` chain with the back edge as a dropped-edge marker. + The body's end is where a split's other routes fall through, so a back edge that leaves an exclusive split ahead of another route other than the default would print with that route tested first, and it prints as a dropped-edge marker instead. +- Conditioned back-edge from an XOR head to the body entry dominating it -> `do...while`, the loop condition read from the back-edge itself, when that back-edge is the head's first route other than its default + A route listed before it keeps its place as the head of an `if` chain, with the back-edge as a `goto` into the body. - AND fork with a matching AND join -> `parallel { { } { } }` - OR fork with a matching OR join -> the same `parallel` block with each conditioned branch headed by its condition, and the fallback flow heading a branch as `else`. A fallback that runs straight into the join is left out where enough branches remain to fill the block, and prints as an empty `else` where dropping it would leave too few. @@ -57,13 +60,21 @@ The pattern catalog: A gateway a pattern folds is never printed, which is what keeps the round trip idempotent. -`emitRoutes` asks where a split's routes come back together in four steps. +`emitRoutes` asks where a split's routes come back together in six steps. +The split's own merge comes first: `pairMerges` walks up the dominator tree from each merge, a split passed on the way being closed by a merge passed before it, and the first split left open owns the merge. +`ownJoin` takes that merge while it is unprinted, at least one route reaches it before the stop node, and no route runs through a gateway the split does not dominate on the way. The clean join is the post-dominator the split dominates, refused when the enclosing construct's stop node does not post-dominate it, since inside a `do` body the body dominates every node behind the loop, so a join behind the loop would otherwise pass the dominance checks whenever a leaving route and the loop exit share an end. `convergence` reads where the split's live routes come back together off the model's routes by forward reachability bounded by the stop node, and so answers a split whose branch can end and whose post-dominator therefore lies at the exit. The guard clause answers when exactly one route is unconditioned, that route being the continuation; it admits a bare authored terminal as a branch entry when the split's route is the terminal's only incoming flow, so the terminal prints inside the `if` rather than as a `goto` with a trailing statement, and it admits a gateway the split dominates, which a jump could not name anyway. +Operaton takes the first route in document order that carries no condition or one that holds, so the chain keeps that order: a route without a condition listed before a conditioned one, the guard clause's continuation included, heads its branch as `true` in its place, and the routes after it stay in the chain although neither the engine nor the rebuilt split ever takes them. The enclosing continuation is the stop node the enclosing construct handed down, and answers only while that node is defined, no route is unconditioned, and at least one route's target is dominated by the split and post-dominated by the stop node; the chain then walks that route inline, prints the routes that leave the region as jumps, and stops at the stop node where the enclosing construct carries on. The same rule serves a split inside a `while` body, a `do` body and a branch, including a split inside a guarded branch. -Outside any enclosing construct the jumps stay, so a split at process level whose every route is conditioned prints as jumps, closing with no `else`, so the recompiled split gains a fallback into the join that the model never named, and the print reports it. +Outside any enclosing construct, a split whose every route is conditioned and that has no default continues at a route whose target has no other incoming flow, which leaves that target unprinted and outside any loop the split is not in: its last route in document order when that one qualifies, else its first that does. +The recompiled split gains a fallback the model never named, which the print reports, and that fallback runs into the statement the print put after the `if`, so a second print reads the same. +The last route prints as the chain's fall-through rather than as an empty branch, so the print builds without the validator's empty-branch warning. +Its condition gives way to the fallback, and since Operaton tries the routes in document order and so tries that route last anyway, only the no-match case changes, which the print reports. +Any other route keeps its condition over an empty branch, since dropping it would make the route the engine tries first the one the rebuilt split tries last. +With no such route the jumps stay, closing with no `else`. A loop gateway's own exit condition prints as the fall-through after the `} while (...)` line and is reported as dropped. Every route of a folded exclusive split gets a form: a condition heads its branch, and a route carrying none heads a branch as `true`, takes the chain's `else`, or runs straight into the join as the chain's fall-through, since heading that last route instead would put a `true` over an empty branch and leave the rest of the chain unreachable. @@ -104,7 +115,7 @@ Either way the fix it names is a rename in the diagram. - Good, because a step with two plain routes or with a weighed route beside a plain one round-trips to the routing the engine runs, with no report. - Good, because the conditions of a degraded split survive on its jumps, and the marker line puts the reader at the split rather than leaving the jumps to explain themselves. - Good, because the fork printer, the invented-fallback report and the fallback-condition report each have one home, and a gateway and a step share it. -- Good, because the CFG analysis is a pure, stateless utility with its own test suite, auditable independently of the emitter, and the same dominator queries answer the clean join, the guard clause and the enclosing continuation. +- Good, because the CFG analysis is a pure, stateless utility with its own test suite, auditable independently of the emitter, and the same dominator queries answer the own merge, the clean join and the enclosing continuation, while `convergence` reads forward reachability and the guard clause only which routes carry a condition. - Good, because a split whose every route is conditioned inside a loop body prints its staying routes inline, so the Operaton invoice example prints its review call inside the loop and the rebuilt process keeps the loop. A cascade of such splits inside one body prints as nested chains, each continuing at the same stop node, and the rule adds no state to the walk. - Good, because the restructurer reports what it drops instead of leaving the caller to find it, including where the drop changes what a recompiled document runs. diff --git a/packages/cli/test/build-parse.smoke.test.ts b/packages/cli/test/build-parse.smoke.test.ts index 19640de..a4b4a95 100644 --- a/packages/cli/test/build-parse.smoke.test.ts +++ b/packages/cli/test/build-parse.smoke.test.ts @@ -64,7 +64,7 @@ beforeAll(() => { const UNSTRUCTURED_FORK_BPMN = ` - + @@ -84,7 +84,7 @@ const TWO_DROPPED_CONDITIONS_BPMN = ` - + @@ -107,7 +107,7 @@ const DECIMAL_DEFAULT_BPMN = ` - + diff --git a/packages/extension/test/conversion-core.test.ts b/packages/extension/test/conversion-core.test.ts index 72465b8..1ab55fb 100644 --- a/packages/extension/test/conversion-core.test.ts +++ b/packages/extension/test/conversion-core.test.ts @@ -153,7 +153,7 @@ const LANE_AND_ASYNC_ATTR_BPMN = ` - + S @@ -175,7 +175,7 @@ const CONDITIONAL_START_BPMN = ` - + \${stockLevel < 5} diff --git a/packages/language/README.md b/packages/language/README.md index 26a20d2..8c95cb7 100644 --- a/packages/language/README.md +++ b/packages/language/README.md @@ -16,7 +16,7 @@ The package also owns the base VS Code TextMate grammar (`syntaxes/bpmn-script.t ## The language Steps run top to bottom, so sequence flow is never written out, and control flow uses structured statements instead. -Optional `var` declarations go in the process header, before the body, alongside the header's own settings: `versionTag`, which labels the deployed process definition, `historyTimeToLive`, and `candidateStarterUsers` and `candidateStarterGroups`, whose entries are listed as candidate starters (Tasklist's process list; `BpmnDeployer.addAuthorizations` stores each entry as written). +Optional `var` declarations go in the process header, before the body, alongside the header's own settings: `versionTag`, which labels the deployed process definition, `historyTimeToLive`, `candidateStarterUsers` and `candidateStarterGroups`, whose entries are listed as candidate starters (Tasklist's process list; `BpmnDeployer.addAuthorizations` stores each entry as written), and `isStartableInTasklist`, an unquoted boolean that, set to `false`, hides the process from Tasklist's start list. `historyTimeToLive` is the number of days the engine keeps a finished instance's history, written `PD` or ``, and an absent value compiles to `P30D`, since `HistoryTimeToLiveParser.parseAndValidate` refuses a null value under the engine's default `enforceHistoryTimeToLive`; the cleanup itself runs only once its batch window is configured. Every header value is stored as written, never evaluated: `label`, `documentation` and `versionTag` are quoted strings, the tag at most 64 characters, and no header setting takes a `"${...}"` expression. @@ -100,7 +100,7 @@ Five engine execution settings are legal on every element that takes settings, ` | `decide` | `class`, `expression`, `delegate`, `topic`, `type`, `decision`, `binding`, `version`, `mapDecisionResult`, `resultVariable`, `taskPriority` | | `call` | `process`, `binding`, `version`, `businessKey`, `mapper`, `mapperDelegate` | | `throw`, `emit` | `class`, `expression`, `delegate`, `topic` (on a `message` trigger only) | -| process header | `label`, `documentation`, `versionTag`, `historyTimeToLive`, `candidateStarterUsers`, `candidateStarterGroups` | +| process header | `label`, `documentation`, `versionTag`, `historyTimeToLive`, `candidateStarterUsers`, `candidateStarterGroups`, `isStartableInTasklist` | The engine settings are `asyncBefore` and `asyncAfter`, which put a transaction boundary before or after the step, `exclusive`, which says whether the engine may run the step's jobs beside other jobs of the same instance, `jobPriority`, which orders those jobs in the queue, and `retryCycle`, the ISO cycle a failed job is retried on. `exclusive`, `jobPriority`, or `retryCycle` written without `asyncBefore` or `asyncAfter` beside it configures no job and draws a warning, since Operaton creates the job only from the async flags (BpmnParse.parseAsynchronousContinuation, DefaultFailedJobParseListener.parseActivity); a timer carrier is exempt, since the timer itself creates the job. @@ -337,6 +337,7 @@ An `on` inside an element's braces is a listener; an `on` at statement position #### Script tasks A `script` task's body is a markdown-style fenced block whose opening tag names the language: `juel`, `javascript`/`js` (or `ecmascript`), `groovy`, `python`/`py`, `ruby`/`rb`, and `feel`. +The tag runs to the first whitespace or line break, so on a one-line fence a space separates the tag from the code, and an imported `scriptFormat` such as `http://www.java.com/java` or `python3` prints as a tag the validator names whole. `juel` is the only one every Operaton deployment can run out of the box; the others need a JSR-223 engine on the deployment's classpath, which `ScriptingEngines.getScriptEngineForLanguage` resolves the first time the script runs rather than at deployment. #### Sub-processes @@ -844,7 +845,7 @@ The categories it covers: An external task's extras add their own: a `taskPriority` or an `error ... when` mapping on a step not bound with `topic`, a mapping headed by a word other than `error` or missing `when`, a mapping naming an undeclared code, and a `taskPriority` or `jobPriority` that is neither an integer nor a `"${...}"` expression. A `service`, `send`, or `decide` task bound with `type` adds its own: a required field its type's own parse requires missing, a field name its behaviour class does not declare, and, on a shell task, a field carried as an expression or a flag other than `true`/`false`. - Settings: a key the element does not own, a value in a shape its lowering cannot read (a quoted `asyncBefore`, an unquoted `retryCycle`), a `retryCycle` the engine's retry parser cannot read, which is dropped rather than refused (warning), a `for` clause's count that is not a non-negative whole number, a variable, or an expression yielding one, a `form` block on an element that renders none, a process header carrying a key it does not own, and an unkeyed value in the parens of an element with no payload to read it as (a task, a call, a subprocess, a listener, the process header, or a `start` or `end` with no trigger, where `start S("PT30M")` is a timer payload missing its `timer` word). - A process header adds its own, since the engine stores every header value as written: a `historyTimeToLive` outside `PD` or ``, a `versionTag` that is not a quoted string or runs past 64 characters, a `"${...}"` template anywhere in `candidateStarterUsers` or `candidateStarterGroups`, and a `label` or `documentation` that is not a quoted string. + A process header adds its own, since the engine stores every header value as written: a `historyTimeToLive` outside `PD` or ``, a `versionTag` that is not a quoted string or runs past 64 characters, a `"${...}"` template anywhere in `candidateStarterUsers` or `candidateStarterGroups`, an `isStartableInTasklist` that is not an unquoted boolean, and a `label` or `documentation` that is not a quoted string. A gateway statement's parens add their own: a bare value rather than a `key: value` setting, a `join`-prefixed key on a `while` or `do...while` loop, and `asyncAfter` on a multi-branch `await` head. A `join`-prefixed key on a statement whose branches all terminate warns, since the join it would set is pruned. A `run*` key on a step with no `for` clause, and `runJobPriority`, which does not exist, since the engine reads a job priority off the step alone. diff --git a/packages/language/src/bpmn-script-completion.ts b/packages/language/src/bpmn-script-completion.ts index 8b01995..6660ea5 100644 --- a/packages/language/src/bpmn-script-completion.ts +++ b/packages/language/src/bpmn-script-completion.ts @@ -287,6 +287,7 @@ const SETTING_SNIPPETS: Readonly> = { historyTimeToLive: 'historyTimeToLive: "${1:P30D}"', candidateStarterUsers: 'candidateStarterUsers: "${1:demo,manager}"', candidateStarterGroups: 'candidateStarterGroups: "${1:adjusters}"', + isStartableInTasklist: 'isStartableInTasklist: ${1|false,true|}', initiator: 'initiator: "${1:starter}"', // The form flags are on while written, so neither scaffolds a choice. required: 'required: true', diff --git a/packages/language/src/bpmn-script-parser-error-message-provider.ts b/packages/language/src/bpmn-script-parser-error-message-provider.ts index c017123..d9de744 100644 --- a/packages/language/src/bpmn-script-parser-error-message-provider.ts +++ b/packages/language/src/bpmn-script-parser-error-message-provider.ts @@ -12,6 +12,7 @@ import { import { DECLARED_CODE_TRIGGERS, formatWordList, + IO_DIRECTIONS, reservedWordsOf, } from './vocabulary.js'; @@ -32,6 +33,20 @@ const EOF_TOKEN_NAME = 'EOF'; */ const CODE_DECL_RULE_NAME = 'CodeDecl'; +/** + * After the first step a plain word ends the process body early. In a braced + * block the same word opens a parameter (`direction=ID name=ID '='`) and fails + * at its name slot or at its `=`, where only the name is left to quote. + */ +const PROCESS_RULE_NAME = 'Process'; +const IO_PARAMETER_RULE_NAME = 'IoParameter'; +const EQUALS_TOKEN_NAME = '='; +const CLOSE_PAREN_TOKEN_NAME = ')'; + +const STEP_KEYWORD_HINT = + 'every step starts with a keyword such as ' + + "'start', 'user', 'service', 'if', 'on', 'throw', 'emit', ..."; + const WORD_SHAPED = /^[A-Za-z_]/; /** Langium suffixes every Chevrotain rule name with a zero-width space. */ @@ -57,6 +72,24 @@ export class BpmnScriptParserErrorMessageProvider extends LangiumParserErrorMess override buildMismatchTokenMessage(options: MismatchTokenOptions): string { const { expected, actual } = options; + const rule = bareRuleName(options.ruleName); + if (expected.name === CLOSE_PAREN_TOKEN_NAME) { + const where = + actual.tokenType.name === EOF_TOKEN_NAME + ? 'the end of the file' + : `'${actual.image}'`; + return `Expected ')' before ${where}: a '(' is still open.`; + } + if ( + expected.name === EQUALS_TOKEN_NAME && + rule === IO_PARAMETER_RULE_NAME + ) { + return ( + `Expected '=' after '${options.previous.image}': inside a block, two ` + + "plain words start a parameter such as 'input name = value'; " + + STEP_KEYWORD_HINT + ); + } if (expected.name === CLOSE_BRACE_TOKEN_NAME) { if (actual.tokenType.name === VAR_KEYWORD_TOKEN_NAME) { return this.varPlacementMessage(); @@ -67,18 +100,24 @@ export class BpmnScriptParserErrorMessageProvider extends LangiumParserErrorMess if (actual.tokenType.name === EOF_TOKEN_NAME) { return this.unclosedBlockMessage(); } + if ( + actual.tokenType.name === ID_TOKEN_NAME && + rule === PROCESS_RULE_NAME + ) { + return `'${actual.image}' is not a step keyword; ${STEP_KEYWORD_HINT}`; + } } if (expected.name === ID_TOKEN_NAME) { if (this.isReservedWord(actual.tokenType.name)) { return this.reservedWordMessage(actual.image); } const word = options.previous.image; - if ( - bareRuleName(options.ruleName) === CODE_DECL_RULE_NAME && - !DECLARED_CODE_TRIGGERS.has(word) - ) { + if (rule === CODE_DECL_RULE_NAME && !DECLARED_CODE_TRIGGERS.has(word)) { return this.declarationOrStepMessage(word); } + if (rule === IO_PARAMETER_RULE_NAME && !IO_DIRECTIONS.includes(word)) { + return `'${word}' is not a step keyword; ${STEP_KEYWORD_HINT}`; + } } return super.buildMismatchTokenMessage(options); } @@ -108,8 +147,7 @@ export class BpmnScriptParserErrorMessageProvider extends LangiumParserErrorMess `'${word}' is neither a known declaration nor a step keyword. ` + 'A declaration starting with a plain word is ' + `${formatWordList([...DECLARED_CODE_TRIGGERS])} followed by the name it ` + - 'declares; every step starts with a keyword such as ' + - "'start', 'user', 'service', 'if', 'on', 'throw', 'emit', ..." + `declares; ${STEP_KEYWORD_HINT}` ); } diff --git a/packages/language/src/bpmn-script-validator.ts b/packages/language/src/bpmn-script-validator.ts index 580500c..b7674fa 100644 --- a/packages/language/src/bpmn-script-validator.ts +++ b/packages/language/src/bpmn-script-validator.ts @@ -184,6 +184,7 @@ import { OUTPUT_DIRECTION, parameterDirectionsFor, PROCESS_HEADER_KEYS, + STARTABLE_KEY, PROPERTY_DIRECTION, RUN_ENGINE_KEYS, runSettingKey, @@ -776,6 +777,9 @@ export const patternMisfitMessage = (field: FormField) => export const flagFalseMessage = (key: string) => `A field is ${key} only while the setting is written, so '${key}: false' says nothing; leave the setting out.`; +export const booleanShapeMessage = (key: string) => + `Setting '${key}' takes an unquoted boolean; write '${key}: true' or '${key}: false'.`; + export const flagNotTrueMessage = (key: string) => `Setting '${key}' takes the literal true; write '${key}: true'.`; @@ -855,6 +859,9 @@ const literalTrue: ValueShapeRule = (key, value) => : flagFalseMessage(key) : flagNotTrueMessage(key); +const unquotedBoolean: ValueShapeRule = (key, value) => + isLiteralBool(value) ? undefined : booleanShapeMessage(key); + const integer: ValueShapeRule = (key, value) => isIntegerValue(value) || isQuotedMatching(value, FORM_BOUND_TEXT) ? undefined @@ -920,6 +927,7 @@ const PROCESS_HEADER_VALUE_RULES: Readonly> = { historyTimeToLive, candidateStarterUsers: candidateStarters, candidateStarterGroups: candidateStarters, + [STARTABLE_KEY]: unquotedBoolean, }; function particleOnlyMessage(subject: string): string { @@ -2766,12 +2774,10 @@ export class BpmnScriptValidator { continue; } if (BOOLEAN_SETTING_KEYS.has(attr.key) && !isLiteralBool(attr.value)) { - accept( - 'error', - `Setting '${attr.key}' takes an unquoted boolean; ` + - `write '${attr.key}: true' or '${attr.key}: false'.`, - { node: attr, property: 'value' }, - ); + accept('error', booleanShapeMessage(attr.key), { + node: attr, + property: 'value', + }); } else if ( TEXT_ATTR_KEYS.has(attr.key) && !isLiteralString(attr.value) && diff --git a/packages/language/src/bpmn-script.langium b/packages/language/src/bpmn-script.langium index 7491fe5..a7f602d 100644 --- a/packages/language/src/bpmn-script.langium +++ b/packages/language/src/bpmn-script.langium @@ -410,8 +410,8 @@ terminal INT returns number: /[0-9]+/; // One token, so code-like text such as `if (a) { }` inside the fence is never // lexed as DSL; `[\s\S]*?` matches across newlines up to the first closing -// fence. Nothing delimits the tag from the body, so the downstream split takes -// the leading letter run as the tag. +// fence. The downstream split takes everything up to the first whitespace as +// the tag. terminal FENCED_SCRIPT: /```[a-zA-Z]+[\s\S]*?```/; // Before STRING: the two regexes overlap on a body opening with `${` or `#{`, diff --git a/packages/language/src/vocabulary.ts b/packages/language/src/vocabulary.ts index ed92dcf..a7b131d 100644 --- a/packages/language/src/vocabulary.ts +++ b/packages/language/src/vocabulary.ts @@ -274,14 +274,17 @@ export const PROCESS_ENGINE_HEADER_KEYS = [ 'candidateStarterGroups', ] as const; +/** The one header setting that takes a boolean rather than text. */ +export const STARTABLE_KEY = 'isStartableInTasklist'; + /** - * In offer and print order. The `jobPriority`, `taskPriority`, - * `isStartableInTasklist`, listeners and `potentialStarter` Operaton also reads - * off a process are left out on purpose. + * In offer and print order. The `jobPriority`, `taskPriority`, listeners and + * `potentialStarter` Operaton also reads off a process are left out on purpose. */ export const PROCESS_HEADER_KEYS: readonly string[] = [ ...LABEL_KEYS, ...PROCESS_ENGINE_HEADER_KEYS, + STARTABLE_KEY, ]; export const INPUT_DIRECTION = 'input'; @@ -684,12 +687,14 @@ export function scriptFormatOf(tag: string): string | undefined { } /** - * One line terminator after the tag is dropped and `\r\n` becomes `\n`, so a - * CRLF checkout writes the same `` as an LF one. + * The tag runs to the first whitespace, so a scriptFormat such as + * `http://www.java.com/java` reads whole. One line terminator after the tag is + * dropped and `\r\n` becomes `\n`, so a CRLF checkout writes the same + * `` as an LF one. */ export function splitFencedScript(raw: string): { tag: string; code: string } { const inner = raw.slice(3, -3); - const tag = /^[a-zA-Z]+/.exec(inner)?.[0] ?? ''; + const tag = /^\S*/.exec(inner)?.[0] ?? ''; const rest = inner.slice(tag.length); const afterOpeningLine = rest.startsWith('\r\n') ? rest.slice(2) diff --git a/packages/language/test/completion.test.ts b/packages/language/test/completion.test.ts index f343047..fef9463 100644 --- a/packages/language/test/completion.test.ts +++ b/packages/language/test/completion.test.ts @@ -493,6 +493,7 @@ const PROCESS_PARENS: Item[] = [ SETTING, 'candidateStarterGroups: "${1:adjusters}"', ], + ['isStartableInTasklist', SETTING, 'isStartableInTasklist: ${1|false,true|}'], ]; const START_PARENS: Item[] = [ diff --git a/packages/language/test/parsing.test.ts b/packages/language/test/parsing.test.ts index bd8458a..3fae659 100644 --- a/packages/language/test/parsing.test.ts +++ b/packages/language/test/parsing.test.ts @@ -803,6 +803,10 @@ const notAStepKeyword = (word: string) => 'followed by the name it declares; every step starts ' + "with a keyword such as 'start', 'user', 'service', 'if', 'on', 'throw', 'emit', ..."; +const stepKeywordOnly = (word: string) => + `'${word}' is not a step keyword; every step starts with a keyword such as ` + + "'start', 'user', 'service', 'if', 'on', 'throw', 'emit', ..."; + const notATypeWord = (word: string) => `'${word}' is not a word this position takes; write 'string', 'number', ` + `'boolean', 'date', 'json', or 'any'.`; @@ -899,6 +903,40 @@ describe('Parsing - sources the parser rejects', () => { `process p { usr }`, [notAStepKeyword('usr')], ], + [ + 'a mistyped statement keyword after the first step gets the step guidance', + `process p {\n user B\n usr A\n}`, + [stepKeywordOnly('usr')], + ], + [ + 'a mistyped step keyword with a name in a braced block names the missing = and the step guidance', + `process p { if (a) { user B usr A } }`, + [ + "Expected '=' after 'A': inside a block, two plain words start a " + + "parameter such as 'input name = value'; every step starts with a " + + "keyword such as 'start', 'user', 'service', 'if', 'on', 'throw', 'emit', ...", + ], + ], + [ + 'a mistyped step keyword alone in a braced block gets the step guidance', + `process p { parallel { { user B usr } { user C } } }`, + [stepKeywordOnly('usr')], + ], + [ + 'a parameter direction missing its name keeps the stock message, since the word is not a mistyped step', + `process p { if (a) { user B input } }`, + ["Expecting token of type 'ID' but found `}`."], + ], + [ + 'a missing ) names the token it stopped at', + `process p { if (a { user A } }`, + ["Expected ')' before '{': a '(' is still open."], + ], + [ + 'a missing ) at the end of the file says so', + `process p { user A (asyncBefore: true`, + ["Expected ')' before the end of the file: a '(' is still open."], + ], [ 'a declaration kind followed by text blames the name slot, not the kind word', `process p { error "PF" }`, diff --git a/packages/language/test/validating.test.ts b/packages/language/test/validating.test.ts index dff046b..79ecb5d 100644 --- a/packages/language/test/validating.test.ts +++ b/packages/language/test/validating.test.ts @@ -70,6 +70,7 @@ import { HANDLER_DUPLICATE_RULE, headerLiteralMessage, HISTORY_TIME_TO_LIVE_MESSAGE, + booleanShapeMessage, hostedHandlerStartMessage, hyphenNameMessage, INITIATOR_SHADOWED_MESSAGE, @@ -1195,6 +1196,11 @@ checks('Validation - script tasks and fenced scripts', [ `process p { script total ${FENCE}php\nx = 1\n${FENCE} }`, [unsupportedScriptTag(`Script task 'total'`, 'php')], ], + [ + 'the tag runs to the first whitespace, so a tag glued to its code is one unsupported tag', + `process p { script total ${FENCE}groovy1 + 1${FENCE} }`, + [unsupportedScriptTag(`Script task 'total'`, 'groovy1')], + ], [ 'an empty script body is one error naming the task', `process p { script total ${FENCE}js\n${FENCE} }`, @@ -3213,7 +3219,9 @@ checks('Validation - awaited events', [ [ "Expecting token of type 'ID' but found `(`.", "Expecting token of type '{' but found `message`.", - "Expecting token of type '=' but found `}`.", + "Expected '=' after 'M': inside a block, two plain words start a " + + "parameter such as 'input name = value'; every step starts with a " + + "keyword such as 'start', 'user', 'service', 'if', 'on', 'throw', 'emit', ...", "Expecting: expecting at least one iteration which starts with one of these possible Token sequences::\n <[ID]>\nbut found: '}'", notValidOn('label', 'a branch of an await block'), warn(emptyNumberedBranch(1, 'await')), @@ -4146,7 +4154,7 @@ checks('Validation - process header attributes', [ ], [ 'the header and the start take every key their own element carries', - `process p(label: "P", documentation: "D", versionTag: "1.0.0", historyTimeToLive: "P90D", candidateStarterUsers: "demo,manager", candidateStarterGroups: "adjusters") { + `process p(label: "P", documentation: "D", versionTag: "1.0.0", historyTimeToLive: "P90D", candidateStarterUsers: "demo,manager", candidateStarterGroups: "adjusters", isStartableInTasklist: false) { start S(initiator: "claimant") }`, [], @@ -4208,6 +4216,11 @@ checks('Validation - process header value shapes', [ candidateStarterMessage('candidateStarterGroups'), ], ], + [ + 'a quoted isStartableInTasklist is refused, since it would lower to nothing', + header('isStartableInTasklist: "false"'), + [booleanShapeMessage('isStartableInTasklist')], + ], [ 'a header label or documentation is a quoted string, never a number or a template', header('label: 3, documentation: "${d}"'), diff --git a/packages/language/test/vocabulary-sync.test.ts b/packages/language/test/vocabulary-sync.test.ts index 979758c..5eb4f89 100644 --- a/packages/language/test/vocabulary-sync.test.ts +++ b/packages/language/test/vocabulary-sync.test.ts @@ -147,10 +147,13 @@ describe('splitFencedScript', () => { ).toEqual({ tag: 'groovy', code: 'def a = 1\ndef b = 2\n' }); }); - test('a one-line fence with no newline after the tag still splits it from the body', () => { - expect(splitFencedScript('```groovy1 + 1```')).toEqual({ - tag: 'groovy', - code: '1 + 1', - }); + test('the tag runs to the first whitespace', () => { + expect([ + splitFencedScript('```groovy 1 + 1```'), + splitFencedScript('```http://www.java.com/java\nx```'), + ]).toEqual([ + { tag: 'groovy', code: ' 1 + 1' }, + { tag: 'http://www.java.com/java', code: 'x' }, + ]); }); }); diff --git a/packages/transform/README.md b/packages/transform/README.md index 4b93983..6195bc1 100644 --- a/packages/transform/README.md +++ b/packages/transform/README.md @@ -42,6 +42,7 @@ interface BpmnProcess { historyTimeToLive?: string; // operaton:historyTimeToLive, absent means the exporter's default (P30D) candidateStarterUsers?: string; // operaton:candidateStarterUsers, comma-separated user ids stored as written candidateStarterGroups?: string; // operaton:candidateStarterGroups, comma-separated group ids + isStartableInTasklist?: boolean; // operaton:isStartableInTasklist, absent means the engine default (true) flowElements: FlowElement[]; sequenceFlows: SequenceFlow[]; errorDecls?: { name: string; code: string; message?: string }[]; // every error code the process raises, catches, or declares @@ -131,11 +132,11 @@ const { source, warnings: printWarnings } = irToDsl(ir); // sync; warns for what ## The import contract `xmlToIr` never discards an element without saying so. -What it cannot represent falls into two buckets; the reasoning is in [ADR-0012](../../docs/decisions/0012-honest-bpmn-import.md), and the rule that draws the line where the engine's own parse draws it is in [ADR-0012](../../docs/decisions/0012-honest-bpmn-import.md). +What it cannot represent falls into two buckets, split where the engine's own parse splits them ([ADR-0012](../../docs/decisions/0012-honest-bpmn-import.md)). Content the IR cannot express at all is refused: `xmlToIr` throws a subclass of `UnsupportedConstructError` before producing any IR, so there is no partial output. -Content Operaton refuses to deploy is refused the same way, since there is nothing that runs to write back, and the message names the `BpmnParse` method that fails the deployment. -A document whose root is not `bpmn:definitions`, a process or flow element without an `id`, or a sequence flow whose `sourceRef` or `targetRef` resolves to nothing is malformed rather than unsupported and throws a plain `Error`, which the CLI reports as a parse failure and exits `2`. +Content Operaton refuses to deploy is refused the same way, since there is nothing that runs to write back, and the message names the engine method that fails the deployment. +A document whose root is not `bpmn:definitions` or a sequence flow whose `sourceRef` or `targetRef` resolves to nothing is malformed rather than unsupported and throws a plain `Error`, which the CLI reports as a parse failure and exits `2`. Content the IR does not carry (an extra Operaton extension attribute, a lane, a text annotation) comes back through the `warnings` array instead. Every drop of a setting the engine reads names the method that reads it and says the document written back runs without it, so a reader can tell a cosmetic drop from one that changes what runs. @@ -156,6 +157,7 @@ The other is a BPMN attribute this surface neither reads nor reports: a process' Each of those is content left out. A lone `operaton:resource` on a sequence flow's condition expression or a conditional event definition's condition is the one exception that is reported rather than dropped: Operaton reads it only alongside a `language`, so on its own it reaches nothing, and the condition still imports as the expression its body writes. `isExecutable="false"`, whose import changes what the document says, is reported instead of left for the reader to notice, and so is an absent `isExecutable`, which `BpmnParse.parseProcessDefinitions` defaults to `!deployment.isNew()` and therefore reads as false in every deployment of a new resource, skipping the process. +An absent `operaton:historyTimeToLive` is reported for the same reason: `HistoryTimeToLiveParser.parseAndValidate` refuses it under the engine's default `enforceHistoryTimeToLive`, and the export writes `P30D` in its place, so the rebuilt process deploys where the source did not. ### Refusals @@ -167,6 +169,10 @@ That is a `` declaration, which `Parser.setXxeProcessing` disallows, a It is an `isSequential` or `triggeredByEvent` value outside `true` and `false`, which `Parse.execute`, the validating parse against `BPMN20.xsd`, refuses. It is an id written on two elements, which the same validation refuses as a duplicate `xs:ID`, and an id outside the ASCII letters, digits, `_`, `-`, and `.` this tool reads, where the schema admits any letter. It is a `bpmn:import`, which `BpmnParse.parseImports` fails on for every type but WSDL. +It is a process without an id, and a start event, a timer event, an event-based gateway, or a message or signal boundary event without one, which Operaton fails the deployment on. +It is also an error, escalation, cancel or conditional boundary event without an id, which Operaton deploys and then fails the run on, and the message names the method that fails it; an id minted for it would make the rebuilt document run where the source does not. +Any other flow element without an id imports under a minted one, and a compensation boundary event among them is then refused like every compensation boundary event ([ADR-0010](../../docs/decisions/0010-deterministic-synthesized-ids.md)). +A refusal names such an element by its tag and container, as in `(a bpmn:userTask without an id in 'p')`, since the minted id appears nowhere in the file. And it is no `bpmn:process` at all, or several processes of which none is marked `isExecutable="true"`, which `parseProcessDefinitions` deploys none of. The first three of those are refused by `refuseDocumentShapes` on the raw document text, before it is parsed, on a copy with the comments and CDATA sections cut out, so a ` 0 ? { errorDecls } : {}), diff --git a/packages/transform/src/cfg-analysis.ts b/packages/transform/src/cfg-analysis.ts index cd6d8ae..2ac7691 100644 --- a/packages/transform/src/cfg-analysis.ts +++ b/packages/transform/src/cfg-analysis.ts @@ -1,6 +1,6 @@ /** * Dominators, post-dominators and back-edges over one {@link FlowContainer}, - * with no DSL knowledge (ADR 0009). + * with no DSL knowledge (ADR 0014). */ import { isGateway } from './ir/types.js'; import type { FlowContainer, SequenceFlow } from './ir/types.js'; diff --git a/packages/transform/src/errors.ts b/packages/transform/src/errors.ts index 63b1f1e..6f73ca5 100644 --- a/packages/transform/src/errors.ts +++ b/packages/transform/src/errors.ts @@ -196,7 +196,7 @@ export class UnsupportedEventFeatureError extends UnsupportedConstructError { remedy: string = EVENT_SURFACE_NOTE, ) { super( - `The event construct at '${elementId}' cannot be imported: ${detail}. ${remedy}`, + `The element '${elementId}' cannot be imported: ${detail}. ${remedy}`, { elementId, detail }, ); } diff --git a/packages/transform/src/ir-to-dsl.ts b/packages/transform/src/ir-to-dsl.ts index 9f9df7e..8f1352c 100644 --- a/packages/transform/src/ir-to-dsl.ts +++ b/packages/transform/src/ir-to-dsl.ts @@ -1,5 +1,5 @@ /** - * IR -> DSL, the inverse of `astToIr`. Dominator patterns (ADR 0009) fold what + * IR -> DSL, the inverse of `astToIr`. Dominator patterns (ADR 0014) fold what * they can; the rest degrades to `goto`, else an {@link UNSTRUCTURED_MARKER}. * Matched gateways are elided: the desugarer derives gateway ids from * structural coordinates, so re-parsing re-synthesizes the same ids. @@ -24,6 +24,7 @@ import { LOOP_VARIABLES, OUTPUT_DIRECTION, PROCESS_ENGINE_HEADER_KEYS, + STARTABLE_KEY, PROPERTY_DIRECTION, runSettingKey, TASK_PRIORITY_KEY, @@ -1220,16 +1221,24 @@ class Emitter { ); this.warnInventedFallback(splitId); - const join = + const found = this.ownJoin(splitId, outs, stop) ?? this.cleanJoin(splitId, outs, stop) ?? this.convergence(splitId, outs, stop) ?? this.guardClauseContinuation(unconditioned) ?? this.enclosingContinuation(splitId, outs, stop); + const noMatch = + found === undefined + ? this.noMatchContinuation(splitId, outs, stop) + : undefined; + // Operaton tries the routes in document order, so only the last one can + // lose its condition without changing which route a holding condition takes. + const fallThrough = noMatch === outs.at(-1) ? noMatch : undefined; + const join = found ?? noMatch?.targetRef; for (const f of outs) this.consumedFlows.add(f.id); this.emitIfChain( - conditioned, + conditioned.filter((f) => f !== fallThrough), unconditioned, fallback, join, @@ -1279,16 +1288,21 @@ class Emitter { lines: string[], depth: number, ): void { + // Operaton takes the first route in document order that has no condition + // or one that holds, so the heads keep that order and only a route listed + // after every condition can be the `else`. + const order = this.routesOut(splitId); + const lastWeighed = Math.max(-1, ...weighed.map((f) => order.indexOf(f))); + const trailing = unweighed.filter((f) => order.indexOf(f) > lastWeighed); const elseFlow = - fallback ?? - unweighed.find((f) => f.targetRef === join) ?? - unweighed.at(-1); - const heads: [condition: string, flow: SequenceFlow][] = [ - ...weighed.map((f): [string, SequenceFlow] => [renderCondition(f), f]), - ...unweighed - .filter((f) => f !== elseFlow) - .map((f): [string, SequenceFlow] => ['true', f]), - ]; + fallback ?? trailing.find((f) => f.targetRef === join) ?? trailing.at(-1); + const heads = [...weighed, ...unweighed] + .filter((f) => f !== elseFlow) + .sort((a, b) => order.indexOf(a) - order.indexOf(b)) + .map((f): [condition: string, flow: SequenceFlow] => [ + weighed.includes(f) ? renderCondition(f) : 'true', + f, + ]); heads.forEach(([condition, f], i) => { lines.push( @@ -1585,6 +1599,34 @@ class Emitter { return stays ? stop : undefined; } + /** + * With no fallback, the compiler sends the no-match case to the statement + * after the `if`. Left to the jump passes, that statement is whichever chain + * prints next, and the rebuilt default then makes the next print pick a + * different structure. Continuing at a route's own entry keeps the two + * prints the same. The last route is preferred, since only it can print as + * the fall-through rather than as an empty branch. + */ + private noMatchContinuation( + splitId: string, + outs: SequenceFlow[], + stop: string | undefined, + ): SequenceFlow | undefined { + if ( + stop !== undefined || + this.splitFallbackFlowId(splitId) !== undefined || + // Reached with two unconditioned routes: an empty condition body passes + // the import's one-unconditioned-route check and imports as none. + outs.some((f) => f.conditionExpression === undefined) + ) { + return undefined; + } + const resumable = (f: SequenceFlow): boolean => + this.cfg.incoming(f.targetRef).length === 1; + const last = outs.at(-1)!; + return resumable(last) ? last : outs.find(resumable); + } + /** * Walk or `goto` is decided per branch. `this.branch` is installed because a * route can dip past its join into a sibling's step, which only @@ -1829,13 +1871,22 @@ class Emitter { (f) => f.targetRef === loop.id && f.sourceRef !== loop.id && - f.conditionExpression === undefined, + f.conditionExpression === undefined && + !this.testedAfter(f), ); if (backEdge === undefined) return undefined; + // Operaton tests the routes in document order and `while (c)` reads back + // with the body route first, so only a body route listed first folds. const outs = this.unconsumedOut(loop.id); - const cond = outs.find((f) => f.conditionExpression !== undefined); - if (cond === undefined) return undefined; + const fallbackId = this.splitFallbackFlowId(loop.id); + const cond = this.routesOut(loop.id).find((f) => f.id !== fallbackId); + if ( + cond?.conditionExpression === undefined || + !this.reachesUnconsumed(cond.targetRef, loop.id, backEdge.sourceRef) + ) { + return undefined; + } this.emittedNodes.add(loop.id); this.consumedFlows.add(cond.id); @@ -1850,6 +1901,19 @@ class Emitter { return this.emitRoutes(loop.id, rest, stop, lines, depth); } + /** + * The body's end is where the rest of a split's routes fall through, so a + * route of an exclusive split listed after this unconditioned one would be + * tested first, while Operaton never reaches it. + */ + private testedAfter(f: SequenceFlow): boolean { + if (this.byId.get(f.sourceRef)?.kind !== 'exclusiveGateway') return false; + const fallbackId = this.splitFallbackFlowId(f.sourceRef); + if (f.id === fallbackId) return false; + const routes = this.routesOut(f.sourceRef); + return routes.slice(routes.indexOf(f) + 1).some((o) => o.id !== fallbackId); + } + /** * Nested loops at one body entry all return to `node`. The outermost test is * the candidate whose other routes never reach another candidate over @@ -1906,8 +1970,9 @@ class Emitter { /** * The condition tells a do-while from a `while`'s join-to-head edge. Operaton - * tests conditioned routes in order and `while (c)` reads back as the first, - * so only the first conditioned route qualifies. + * tests every route but the default in document order, unconditioned ones + * included, and `while (c)` reads back as the first, so only the route listed + * first qualifies. */ private isDoWhileTest(f: SequenceFlow): boolean { if (f.sourceRef === f.targetRef) return false; @@ -1915,8 +1980,9 @@ class Emitter { // A jump-cycle head closes on a `goto`, not on this gateway's test. if (this.cfg.isJumpCycleHead(f.targetRef)) return false; const head = this.byId.get(f.sourceRef); + const fallbackId = this.splitFallbackFlowId(f.sourceRef); const firstTest = this.routesOut(f.sourceRef).find( - (o) => o.conditionExpression !== undefined, + (o) => o.id !== fallbackId, ); return ( head?.kind === 'exclusiveGateway' && @@ -2724,6 +2790,11 @@ function buildProcessHeader(process: BpmnProcess, names: PrintNames): string { settings.push(setting(key, PROCESS_HEADER_RENDER[key](value))); } } + if (process.isStartableInTasklist !== undefined) { + settings.push( + setting(STARTABLE_KEY, String(process.isStartableInTasklist)), + ); + } return `process ${nameOf(names, process.id)}${parens(settings)} {`; } diff --git a/packages/transform/src/ir-to-xml.ts b/packages/transform/src/ir-to-xml.ts index fe1136a..db4eb88 100644 --- a/packages/transform/src/ir-to-xml.ts +++ b/packages/transform/src/ir-to-xml.ts @@ -1,7 +1,7 @@ /** * IR to BPMN~2.0 XML Operaton can deploy. `operaton:` attributes come from the - * vendor-free IR names (ADR 0006); `bpmn-auto-layout` regenerates `bpmndi:` - * on every export (ADR 0003). + * vendor-free IR names (ADR 0007); `bpmn-auto-layout` regenerates `bpmndi:` + * on every export (ADR 0009). */ import { existsSync, readFileSync } from 'node:fs'; @@ -132,6 +132,13 @@ export async function irToXml( ...(process.candidateStarterGroups !== undefined ? { 'operaton:candidateStarterGroups': process.candidateStarterGroups } : {}), + ...(process.isStartableInTasklist !== undefined + ? { + 'operaton:isStartableInTasklist': String( + process.isStartableInTasklist, + ), + } + : {}), flowElements: buildContainerChildren(moddle, process, roots), }; const processElement = moddle.create('bpmn:Process', processAttrs); @@ -164,7 +171,7 @@ export async function irToXml( } try { - return await layoutProcess(xml); + return escapeAttributeWhitespace(await layoutProcess(xml)); } catch (cause) { // The expansion hint has no `dc:Bounds`, and `BpmnParse.parseDIBounds` fails // deployment on a bounds-less shape, so the fallback drops the diagram. @@ -172,10 +179,22 @@ export async function irToXml( const { xml: xmlWithoutDiagram } = await moddle.toXML(definitions, { format: true, }); - throw new LayoutError(xmlWithoutDiagram, cause); + throw new LayoutError(escapeAttributeWhitespace(xmlWithoutDiagram), cause); } } +/** + * moddle-xml escapes a line feed in an attribute value but writes a tab or a + * carriage return raw, and XML attribute-value normalization turns either into + * a space in the engine's parser. The writer escapes `<` and `>` everywhere + * and emits no CDATA or comment, so every `<...>` span is a tag. + */ +function escapeAttributeWhitespace(xml: string): string { + return xml.replace(/<[^>]*>/g, (tag) => + tag.replace(/\t/g, ' ').replace(/\r/g, ' '), + ); +} + /** `taken` holds every id in the document, the `bpmn:Definitions` id included. */ interface RootElementIndex { errorByCode: Map; @@ -464,7 +483,7 @@ export const TIMER_KIND_TO_CHILD: Record< /** * Without this `bpmn-auto-layout` draws a sub-process (or transaction) * collapsed and scatters its children. It finds the shape by id, so it needs - * one; bounds are recomputed (ADR 0015). + * one; bounds are recomputed (ADR 0009). */ function buildSubProcessExpansionHint( moddle: BpmnModdleInstance, @@ -854,7 +873,7 @@ function retryCycleElement( }); } -/** For carriers with no other extension group (gateways, ADR 0010; loop elements). */ +/** For carriers with no other extension group (gateways, ADR 0021; loop elements). */ function bareJobSettingAttrs( moddle: BpmnModdleInstance, node: JobSettings, @@ -899,7 +918,7 @@ function buildExtensionElements( ): ModdleElement | undefined { const values: ModdleElement[] = []; // Fields configure the implementation, so they precede the io block. A thrown - // message's binding has no field slot (ADR 0032). + // message's binding has no field slot (ADR 0017). if (node.kind === 'serviceTask') { for (const field of codeBindingFields(node.binding)) { values.push(buildField(moddle, field)); diff --git a/packages/transform/src/ir/types.ts b/packages/transform/src/ir/types.ts index aec5878..a7b4d93 100644 --- a/packages/transform/src/ir/types.ts +++ b/packages/transform/src/ir/types.ts @@ -40,6 +40,8 @@ export interface BpmnProcess extends FlowContainer, Named { */ candidateStarterUsers?: string; candidateStarterGroups?: string; + /** `operaton:isStartableInTasklist`; absent means the engine's default, `true`. */ + isStartableInTasklist?: boolean; /** * Codes in use in first-use order, then unused declared ones. Stored, not * derived, because a declared code emits its root even when unused and usage diff --git a/packages/transform/src/operaton-moddle.json b/packages/transform/src/operaton-moddle.json index 9a092c3..2452c31 100644 --- a/packages/transform/src/operaton-moddle.json +++ b/packages/transform/src/operaton-moddle.json @@ -31,6 +31,11 @@ "name": "candidateStarterGroups", "isAttr": true, "type": "String" + }, + { + "name": "isStartableInTasklist", + "isAttr": true, + "type": "String" } ] }, diff --git a/packages/transform/src/synthesize-ids.ts b/packages/transform/src/synthesize-ids.ts index aca4507..548ce8e 100644 --- a/packages/transform/src/synthesize-ids.ts +++ b/packages/transform/src/synthesize-ids.ts @@ -54,8 +54,12 @@ export function makeStartEventId( return claimId(`${START_EVENT_PREFIX}${processId}`, taken); } +export function endIdOf(containerId: string): string { + return `${END_EVENT_PREFIX}${containerId}`; +} + export function makeEndEventId(processId: string, taken: Set): string { - return claimId(`${END_EVENT_PREFIX}${processId}`, taken); + return claimId(endIdOf(processId), taken); } /** @@ -71,9 +75,9 @@ export function isMintedEndId( containerId: string, boundaryIds: Iterable, ): boolean { - if (id === `${END_EVENT_PREFIX}${containerId}`) return true; + if (id === endIdOf(containerId)) return true; for (const boundaryId of boundaryIds) { - if (id === `${END_EVENT_PREFIX}${boundaryId}`) return true; + if (id === endIdOf(boundaryId)) return true; } return false; } @@ -103,7 +107,7 @@ export function boundaryEventIdBase(hostId: string, trigger: string): string { return `Boundary_${hostId}_${trigger}`; } -function claimId(base: string, taken: Set): string { +export function claimId(base: string, taken: Set): string { const id = resolveCollision(base, taken); taken.add(id); return id; diff --git a/packages/transform/src/xml-to-ir.ts b/packages/transform/src/xml-to-ir.ts index 32b74fb..7a7f9dd 100644 --- a/packages/transform/src/xml-to-ir.ts +++ b/packages/transform/src/xml-to-ir.ts @@ -1,6 +1,6 @@ /** * BPMN 2.0 XML to IR, the inverse of `irToXml`. Diagram interchange is dropped - * (ADR 0003); ADR 0014 sets what refuses and what warns. + * (ADR 0009); ADR 0012 sets what refuses and what warns. */ import { @@ -132,9 +132,13 @@ import { } from './ir-to-xml.js'; import { claimDeclarationName, + endIdOf, + makeEndEventId, isWritableName, makeEventSubProcessId, + makeSequenceFlowId, makeStartEventId, + claimId, } from './synthesize-ids.js'; export type ImportWarningCategory = @@ -143,9 +147,12 @@ export type ImportWarningCategory = | 'label' | 'unreferencedRoot' | 'documentation' - | 'unmappedConstruct'; + | 'unmappedConstruct' + | 'rewritten' + | 'behaviourChanged' + | 'carriedAsWritten'; -/** Dropped content; refusals throw instead. */ +/** Content the import dropped, rewrote, or carried as written; refusals throw instead. */ export interface ImportWarning { elementId: string; category: ImportWarningCategory; @@ -284,6 +291,7 @@ const CONSUMED_EXTENSION_ATTRS = consumptionTable([ ['historyTimeToLive', ['bpmn:Process']], ['candidateStarterUsers', ['bpmn:Process']], ['candidateStarterGroups', ['bpmn:Process']], + ['isStartableInTasklist', ['bpmn:Process']], ['initiator', ['bpmn:StartEvent']], ['errorCodeVariable', ['bpmn:ErrorEventDefinition']], ['errorMessageVariable', ['bpmn:ErrorEventDefinition']], @@ -438,10 +446,6 @@ const ENGINE_READS_ELSEWHERE: ReadonlyMap = new Map< `bpmn:Process/${name}`, 'reads it (BpmnParse.parseProcess through parsePriority)', ]), - [ - 'bpmn:Process/isStartableInTasklist', - 'reads it (BpmnParse.parseProcess through isStartable)', - ], [ 'bpmn:Process/operaton:ExecutionListener', 'runs it on the process instance ' + @@ -478,10 +482,12 @@ interface ModdleElement { readonly $type: string; readonly id?: string; readonly $attrs: Record; + readonly $parent?: ModdleElement; readonly $descriptor?: { readonly properties?: readonly ModdlePropertyDescriptor[]; }; get(name: string): unknown; + $instanceOf(type: string): boolean; } /** `name` is the storage key, `ns.name` the form `get()` accepts. */ @@ -534,6 +540,43 @@ export const CAMUNDA_ALIAS_MESSAGE = export async function xmlToIr( xml: string, +): Promise<{ ir: BpmnProcess; warnings: ImportWarning[] }> { + const minted = new Map(); + try { + return await importDocument(xml, minted); + } catch (e) { + throw e instanceof UnsupportedConstructError + ? withoutMintedIds(e, minted) + : e; + } +} + +/** + * A refusal leaves no script behind, so an id the import minted names nothing + * the author can find: every field and message naming one describes the + * element instead. + */ +function withoutMintedIds( + error: UnsupportedConstructError, + minted: ReadonlyMap, +): UnsupportedConstructError { + const fields = error as unknown as Record; + for (const key of Object.getOwnPropertyNames(error)) { + const value = fields[key]; + if (typeof value !== 'string') continue; + fields[key] = minted.has(value) + ? undefined + : [...minted].reduce( + (text, [id, element]) => text.replaceAll(`'${id}'`, `(${element})`), + value, + ); + } + return error; +} + +async function importDocument( + xml: string, + minted: Map, ): Promise<{ ir: BpmnProcess; warnings: ImportWarning[] }> { const moddle = createModdle(); refuseDocumentShapes(xml); @@ -546,8 +589,11 @@ export async function xmlToIr( // moddle reports "unparsable content" only for undeclared `operaton:` // elements. - const { rootElement, warnings: moddleWarnings } = - await moddle.fromXML(normalizedXml); + const { + rootElement, + warnings: moddleWarnings, + elementsById, + } = await moddle.fromXML(normalizedXml); const root = rootElement as ModdleElement; if (root.$type !== 'bpmn:Definitions') { @@ -558,6 +604,7 @@ export async function xmlToIr( refuseDroppedIds(moddleWarnings); refuseImports(root); + nameAnonymousElements(root, new Set(Object.keys(elementsById)), minted); const rootElements = root.get('rootElements') as ModdleElement[]; const warnings: ImportWarning[] = []; @@ -571,7 +618,7 @@ export async function xmlToIr( if (camundaRead) { warnings.push({ elementId: mappedProcess.id, - category: 'unmappedConstruct', + category: 'rewritten', message: CAMUNDA_ALIAS_MESSAGE, }); } @@ -1022,6 +1069,56 @@ function recordDroppedConditions( return positions; } +const MINTED_IDS = new WeakSet(); + +/** An unsupported element's refusal names no id the document does not carry. */ +function authoredId(el: ModdleElement): string | undefined { + return MINTED_IDS.has(el) ? undefined : el.id; +} + +/** + * The DSL names every element, so one without an id gets a minted one before + * anything keys it. `BpmnParse` deploys a flow without an id as an unnamed + * transition and most nodes without one as an activity nothing can flow into; + * the events it cannot run without an id are refused in + * `refuseAnonymousEvents`. + */ +function nameAnonymousElements( + root: ModdleElement, + taken: Set, + minted: Map, +): void { + // The printer leaves an end out of the script when its id is the one the + // compiler gives the implicit end of a container or a boundary escape, so a + // minted end claims past `EndEvent_` for every id in the document. + const endTaken = new Set([...taken].flatMap((id) => [id, endIdOf(id)])); + for (const el of flowElementsDeep(root)) { + if (!el.$instanceOf('bpmn:FlowElement') || (el.id ?? '') !== '') continue; + const parent = el.$parent as ModdleElement; + const end = (name: string): string => + (el.get(name) as ModdleElement | undefined)?.id ?? ''; + MINTED_IDS.add(el); + const id = + el.$type === 'bpmn:SequenceFlow' + ? makeSequenceFlowId(end('sourceRef'), end('targetRef'), taken) + : el.$type === 'bpmn:EndEvent' + ? makeEndEventId(parent.id ?? '', endTaken) + : claimId( + `${el.$type.slice('bpmn:'.length)}_${parent.id ?? ''}`, + taken, + ); + taken.add(id); + endTaken.add(id).add(endIdOf(id)); + const parentId = authoredId(parent); + minted.set( + id, + `a ${xmlTagOf(el.$type)} without an id` + + (parentId === undefined ? '' : ` in '${parentId}'`), + ); + (el as { id?: string }).id = id; + } +} + function* flowElementsDeep(container: ModdleElement): Generator { const children = [ ...((container.get('rootElements') as ModdleElement[] | undefined) ?? []), @@ -1151,7 +1248,10 @@ function mapProcess( ): BpmnProcess { const id = processEl.id; if (id === undefined) { - throw new Error(" is missing its required 'id' attribute."); + throw new UnsupportedDocumentError( + 'its process has no id; Operaton deploys a process under its id as ' + + 'the definition key, and the deployment fails without one', + ); } const named = readNamed(processEl, id, warnings); @@ -1161,7 +1261,7 @@ function mapProcess( if (isExecutable === false) { warnings.push({ elementId: id, - category: 'unmappedConstruct', + category: 'behaviourChanged', message: `The process '${id}' is marked isExecutable="false", which this ` + 'surface cannot express: it was imported as an executable process ' + @@ -1171,7 +1271,7 @@ function mapProcess( } else if (isExecutable !== true) { warnings.push({ elementId: id, - category: 'unmappedConstruct', + category: 'behaviourChanged', message: `The process '${id}' is not marked isExecutable="true", which ` + '`BpmnParse.parseProcessDefinitions` skips in a new deployment; it ' + @@ -1193,10 +1293,28 @@ function mapProcess( // The exporter stamps this default on every process; reading it back would // break the round trip. const authoredTimeToLive = readNamespacedAttr(processEl, 'historyTimeToLive'); + if (authoredTimeToLive === undefined) { + warnings.push({ + elementId: id, + category: 'behaviourChanged', + message: + `The process '${id}' sets no historyTimeToLive, which ` + + 'HistoryTimeToLiveParser.parseAndValidate refuses under the ' + + "engine's default enforceHistoryTimeToLive; it is written back with " + + `'${HISTORY_TIME_TO_LIVE}', so the rebuilt process deploys where the ` + + 'source did not.', + }); + } const historyTimeToLive = authoredTimeToLive === HISTORY_TIME_TO_LIVE ? undefined : authoredTimeToLive; + // `BpmnParse.isStartable` compares ignoring case, and anything else is false. + const startable = processEl.get('operaton:isStartableInTasklist'); + const isStartableInTasklist = + typeof startable === 'string' + ? startable.toLowerCase() === 'true' + : undefined; const candidateStarterUsers = mergeCandidates( starters.users, readNamespacedAttr(processEl, 'candidateStarterUsers'), @@ -1214,6 +1332,7 @@ function mapProcess( ...(historyTimeToLive === undefined ? {} : { historyTimeToLive }), ...(candidateStarterUsers === undefined ? {} : { candidateStarterUsers }), ...(candidateStarterGroups === undefined ? {} : { candidateStarterGroups }), + ...(isStartableInTasklist === undefined ? {} : { isStartableInTasklist }), flowElements, sequenceFlows, }; @@ -1230,8 +1349,11 @@ function readPotentialStarters( ): { users: string[]; groups: string[] } { const users: string[] = []; const groups: string[] = []; - const report = (message: string): void => { - warnings.push({ elementId: id, category: 'unmappedConstruct', message }); + const report = ( + message: string, + category: ImportWarningCategory = 'unmappedConstruct', + ): void => { + warnings.push({ elementId: id, category, message }); }; for (const starter of extensionValues(processEl)) { if (starter.$type !== 'operaton:PotentialStarter') continue; @@ -1258,6 +1380,7 @@ function readPotentialStarters( '(BpmnParse.parsePotentialStarterResourceAssignment), and this tool ' + `writes it back as ${became.map(([key]) => `operaton:${key}`).join(' and ')}, ` + 'which the engine reads the same.', + 'rewritten', ); } return { users, groups }; @@ -1280,6 +1403,81 @@ function mapContainer( ); } +/** + * Operaton keys these events by id, and `anonymousFailure` names where a + * missing one fails. Every other element, a compensation boundary included, + * imports under a minted id. + */ +function refuseAnonymousEvents(container: ModdleElement): void { + for (const child of container.get('flowElements') as ModdleElement[]) { + if (!MINTED_IDS.has(child)) continue; + const failure = anonymousFailure(child, container); + if (failure === undefined) continue; + throw new UnsupportedDocumentError( + `a ${xmlTagOf(child.$type)} in '${container.id}' has no id${failure}; ` + + 'give it one', + ); + } +} + +function anonymousFailure( + el: ModdleElement, + container: ModdleElement, +): string | undefined { + const defs = eventDefinitionsOf(el).map((def) => def.$type); + if (defs.includes('bpmn:TimerEventDefinition')) { + return ', which BpmnParse.parseTimer fails the deployment on (\'Attribute "id" is required!\')'; + } + switch (el.$type) { + case 'bpmn:StartEvent': + return container.$type === 'bpmn:Process' + ? ', which BpmnParse.parseStartFormHandlers fails the deployment on' + : ', which HistoryParseListener.parseStartEvent fails the deployment on'; + case 'bpmn:EventBasedGateway': + return ', which BpmnParse.parseEventBasedGateway fails the deployment on'; + case 'bpmn:BoundaryEvent': + return defs.length === 1 + ? ANONYMOUS_BOUNDARY_FAILURES.get(defs[0]) + : undefined; + default: + return undefined; + } +} + +const ANONYMOUS_BOUNDARY_FAILURES: ReadonlyMap = new Map([ + [ + 'bpmn:MessageEventDefinition', + ', which BpmnParse.parseBoundaryMessageEventDefinition fails the ' + + 'deployment on ("boundary event has no id")', + ], + [ + 'bpmn:SignalEventDefinition', + ', which BpmnParse.parseBoundarySignalEventDefinition fails the ' + + 'deployment on ("boundary event has no id")', + ], + [ + 'bpmn:ErrorEventDefinition', + ': Operaton deploys it and ' + + 'ErrorDeclarationForProcessInstanceFinder.isReThrowingErrorEventSubprocess ' + + 'fails the run when an error is thrown inside its step', + ], + [ + 'bpmn:EscalationEventDefinition', + ': Operaton deploys it and ExecutionEntity.generateActivityInstanceId ' + + 'fails the run when an escalation reaches it', + ], + [ + 'bpmn:CancelEventDefinition', + ': Operaton deploys it and ExecutionEntity.generateActivityInstanceId ' + + 'fails the run when the transaction is cancelled', + ], + [ + 'bpmn:ConditionalEventDefinition', + ': Operaton deploys it and ConditionalEventHandler.handleEvent fails ' + + 'the run of its step', + ], +]); + /** * `selectInitial` refuses a second plain or timer start in a process. A * transaction with no start deploys and fails on entry, so the script adds one. @@ -1330,7 +1528,7 @@ function checkStartEventCount( if (starts.length === 0) { warnings.push({ elementId: id, - category: 'unmappedConstruct', + category: 'behaviourChanged', message: `The bpmn:transaction '${id}' has no start event: Operaton deploys ` + 'it and SubProcessActivityBehavior.execute fails on entering it ' + @@ -1453,7 +1651,7 @@ function mapActivityByTag( case 'bpmn:CallActivity': return mapCallActivity(el, warnings); default: - throw new UnsupportedElementError(el.$type, el.id); + throw new UnsupportedElementError(el.$type, authoredId(el)); } } @@ -1577,6 +1775,7 @@ function mapContainerChildren( const flowElements: FlowElement[] = []; const sequenceFlows: SequenceFlow[] = []; const container = { id: requireId(el), flowElements }; + refuseAnonymousEvents(el); const children = el.get('flowElements') as ModdleElement[]; for (const child of children) { @@ -1646,7 +1845,7 @@ function mapContainerChildren( warnDataConstructDrop(child, el.id, warnings); continue; default: - throw new UnsupportedElementError(child.$type, child.id); + throw new UnsupportedElementError(child.$type, authoredId(child)); } attachRepetition(flowElements, mappedAt, child, warnings); if (child.id !== undefined) { @@ -2091,7 +2290,7 @@ function checkCancelPairing( if (givenUp && boundaryId === undefined) { warnings.push({ elementId: el.id, - category: 'unmappedConstruct', + category: 'carriedAsWritten', message: `The block '${el.id}' holds an end event that gives it up, with ` + 'no cancel boundary event attached to it: Operaton deploys the ' + @@ -2103,7 +2302,7 @@ function checkCancelPairing( if (!givenUp && boundaryId !== undefined) { warnings.push({ elementId: boundaryId, - category: 'unmappedConstruct', + category: 'carriedAsWritten', message: `The cancel boundary event on '${el.id}' was imported, but ` + 'nothing inside the block gives it up, so what follows the ' + @@ -2576,7 +2775,7 @@ function readDanglingErrorCode( if (written === undefined || written === '') return undefined; warnings.push({ elementId: ownerId, - category: 'unmappedConstruct', + category: 'rewritten', message: `The errorRef '${written}' on '${ownerId}' names no bpmn:error root ` + `and imports as the code '${written}': Operaton takes a dangling ` + @@ -3180,7 +3379,7 @@ function readVersionBinding( binding = { kind: 'latest' }; warnings.push({ elementId: id, - category: 'unmappedConstruct', + category: 'rewritten', message: `The ${prefix}Binding="${bindingValue}" on '${id}' imports as ` + 'binding: latest: BpmnParse.parseBinding sets no binding for that ' + @@ -3456,6 +3655,19 @@ function collectUnmappedBpmnDrops( for (const prop of el.$descriptor?.properties ?? []) { if (prop.isAttr === true || prop.isBody === true) continue; + if (prop.name === 'eventDefinitionRef') { + for (const ref of el.get(prop.name) as ModdleElement[]) { + warnings.push({ + elementId: ownerId, + category: 'unmappedConstruct', + message: + `The eventDefinitionRef '${ref.id}' on '${ownerId}' was not ` + + 'imported: BpmnParse reads only the event definitions nested in ' + + 'the event, so the document written back runs the same.', + }); + } + continue; + } if (prop.isReference === true) continue; if (READ_BPMN_CHILDREN.has(prop.name)) continue; // `readAssignment` reads a user task's roles; no other activity's are read. @@ -3824,7 +4036,7 @@ function noteRewrappedExpression( } warnings.push({ elementId: ownerId, - category: 'unmappedConstruct', + category: 'rewritten', message: `The ${slot} on '${ownerId}' is written with "#{...}"; the script ` + 'prints its body as bare DSL and the rebuilt document writes it ' + @@ -4112,7 +4324,7 @@ function mapIntermediateThrowEvent( const named = readNamed(el, id, warnings); warnings.push({ elementId: id, - category: 'unmappedConstruct', + category: 'rewritten', message: `The bpmn:intermediateThrowEvent '${id}' carries no event ` + 'definition and imports as a plain step: ' + @@ -4120,7 +4332,7 @@ function mapIntermediateThrowEvent( 'IntermediateThrowNoneEventActivityBehavior, which only leaves, as ' + "a task's behaviour does, so token flow, listeners, async and job " + 'configuration are unchanged, but history and Cockpit will report ' + - "its activity type as 'task' rather than 'intermediateThrowEvent'.", + "its activity type as 'task' rather than 'intermediateNoneThrowEvent'.", }); return { kind: 'task', @@ -4490,7 +4702,7 @@ function readLoopCharacteristics( ) { warnings.push({ elementId: id, - category: 'unmappedConstruct', + category: 'behaviourChanged', message: `The bpmn:completionCondition on '${id}' is the bare text ` + `${JSON.stringify(completionCondition)} with no "\${...}" or ` + @@ -4910,7 +5122,7 @@ function readAssignment( } warnings.push({ elementId: id, - category: 'unmappedConstruct', + category: 'rewritten', message: `The ${tag} on '${id}' imports as ` + `${became.map(([key, value]) => `${key}: "${value}"`).join(' and ')}: ` + @@ -5024,7 +5236,7 @@ function mapManualTask(el: ModdleElement, warnings: ImportWarning[]): Task { const task = mapTask(el, warnings); warnings.push({ elementId: task.id, - category: 'unmappedConstruct', + category: 'rewritten', message: manualTaskMessage(task.id), }); return task; @@ -5067,7 +5279,7 @@ function mapServiceTask( ) { warnings.push({ elementId: id, - category: 'extensionAttribute', + category: 'carriedAsWritten', message: `The resultVariable '${resultVariable}' on '${id}' was imported as ` + 'written, and the printed script draws a warning at the step: ' + @@ -5111,7 +5323,7 @@ function readResultVariable( } warnings.push({ elementId: id, - category: 'unmappedConstruct', + category: 'rewritten', message: `The operaton:resultVariableName="${older}" on '${id}' imports as ` + `resultVariable: "${older}": BpmnParse.parseResultVariable reads the ` + @@ -5243,7 +5455,7 @@ function refuseBuiltinShapes( } warnings.push({ elementId: id, - category: 'extensionAttribute', + category: 'carriedAsWritten', message: `The shell field '${field.name}' spelled '${field.value}' on ` + `'${id}' was imported as written, and the printed script draws an ` + @@ -5320,13 +5532,14 @@ function withExternalExtras( const properties = propertiesEl === undefined ? [] - : readPropertyEntries(propertiesEl, 'name', `'${id}'`, (message) => { - warnings.push({ - elementId: id, - category: 'extensionAttribute', - message, - }); - }); + : readPropertyEntries( + propertiesEl, + 'name', + `'${id}'`, + (message, category = 'extensionAttribute') => { + warnings.push({ elementId: id, category, message }); + }, + ); const errorMappings = definitions.flatMap( (defEl) => readErrorMapping(defEl, id, warnings) ?? [], ); @@ -5633,7 +5846,7 @@ function mapScriptTask( format = 'juel'; warnings.push({ elementId: id, - category: 'extensionAttribute', + category: 'rewritten', message: `The script on '${id}' has no scriptFormat; ` + '`BpmnParse.parseScriptTaskElement` substitutes ' + @@ -5646,7 +5859,7 @@ function mapScriptTask( format = rawFormat; warnings.push({ elementId: id, - category: 'extensionAttribute', + category: 'carriedAsWritten', message: `The script on '${id}' names the language '${rawFormat}', which ` + 'the DSL has no fence alias for; it was imported as written, and ' + @@ -5798,7 +6011,7 @@ function mapSequenceFlow( if (written && conditionExpression === undefined) { warnings.push({ elementId: id, - category: 'unmappedConstruct', + category: 'behaviourChanged', message: `The condition on '${id}' has an empty body: ` + 'UelExpressionCondition.evaluate reads it as a string and fails the ' + @@ -5811,7 +6024,7 @@ function mapSequenceFlow( ) { warnings.push({ elementId: id, - category: 'unmappedConstruct', + category: 'behaviourChanged', message: `The condition on '${id}' is the bare text ` + `${JSON.stringify(conditionExpression)} with no "\${...}" or ` + @@ -6119,7 +6332,7 @@ function readAsyncBefore( : `'${ownerId}'`; warnings.push({ elementId: ownerId, - category: 'unmappedConstruct', + category: 'rewritten', message: asyncRespellingMessage(subject), }); return true; @@ -6253,7 +6466,7 @@ function readFormFields( return fields.map((field) => readFormField(field, ownerId, warnings)); } -type Report = (message: string) => void; +type Report = (message: string, category?: ImportWarningCategory) => void; interface FieldContext { fieldId: string; @@ -6272,6 +6485,7 @@ function warnCarriedAsWritten( report( `The ${what} on ${subject} was imported as written, and the printed ` + `script draws an error at the field: ${reason}.`, + 'carriedAsWritten', ); } @@ -6307,12 +6521,8 @@ function readFormField( ownerId, type, subject: `form field '${fieldId}' of '${ownerId}'`, - report: (message) => { - warnings.push({ - elementId: ownerId, - category: 'extensionAttribute', - message, - }); + report: (message, category = 'extensionAttribute') => { + warnings.push({ elementId: ownerId, category, message }); }, }; @@ -6434,6 +6644,7 @@ function readEnumValues( `The operaton:value '${id}' of ${subject} is written twice and was ` + 'imported once, at its first position with its last name, as ' + 'FormTypes.parseFormPropertyType keeps it (LinkedHashMap.put).', + 'rewritten', ); } const label = readString(valueEl, 'name'); @@ -6565,6 +6776,7 @@ function readPropertyEntries( report( `The operaton:property '${key}' of ${subject} is written twice and ` + `was imported once${PROPERTY_REWRITE_BY_KEY[keyAttr]}.`, + 'rewritten', ); } byKey.set(key, { key, value }); @@ -6837,7 +7049,7 @@ function checkScriptBody( if (code.trim() === '') { warnings.push({ elementId: ownerId, - category: 'extensionAttribute', + category: 'carriedAsWritten', message: `The body of ${where} is empty: ScriptUtil.getScript deploys it, ` + 'since it checks the source for null and not for emptiness, and ' + @@ -6961,7 +7173,7 @@ function buildEmptyTaskListenerBindingWarning( ): ImportWarning { return { elementId: ownerId, - category: 'extensionAttribute', + category: 'carriedAsWritten', message: `An ${tag} on '${ownerId}' has ${attr}="": ` + 'BpmnParse.parseTaskListener checks no listener attribute for ' + @@ -7034,7 +7246,7 @@ function resolveListenerBinding( if (expression === '') { warnings.push({ elementId: ownerId, - category: 'extensionAttribute', + category: 'carriedAsWritten', message: `An ${tag} on '${ownerId}' has expression="": ` + `${isExecutionListener ? 'ExpressionExecutionListener' : 'ExpressionTaskListener'} ` + @@ -7289,7 +7501,7 @@ function readField( if (chosen.slot.property === 'string') { warnings.push({ elementId: ownerId, - category: 'extensionAttribute', + category: 'rewritten', message: `The injected field '${name}' on ${where} writes its value in an ` + 'operaton:string child, which this tool writes back as a stringValue ' + diff --git a/packages/transform/test/helpers/bpmn-doc.ts b/packages/transform/test/helpers/bpmn-doc.ts index ba611d9..17399d5 100644 --- a/packages/transform/test/helpers/bpmn-doc.ts +++ b/packages/transform/test/helpers/bpmn-doc.ts @@ -25,7 +25,13 @@ const HEAD_OPERATON = head(NS_BPMN, NS_OPERATON); const HEAD_CAMUNDA = head(NS_BPMN, NS_CAMUNDA); const HEAD_DUAL = head(NS_BPMN, NS_OPERATON, NS_CAMUNDA); -const PROCESS_OPEN = ' '; +/** + * Spread into a process's opening tag, it keeps the importer's warning about + * an absent time to live out of a fixture that is not about it. + */ +export const TIME_TO_LIVE = `${NS_OPERATON} operaton:historyTimeToLive="P30D"`; + +const PROCESS_OPEN = ` `; const PROCESS_CLOSE = ' '; const DEFINITIONS_CLOSE = ''; @@ -126,7 +132,7 @@ export const handlerDoc = ( defs = bpmnDefs, }: HandlerOptions = {}, ): string => - defs`${roots} + defs`${roots} diff --git a/packages/transform/test/ir-to-dsl.test.ts b/packages/transform/test/ir-to-dsl.test.ts index c3cc42f..8c6ba3f 100644 --- a/packages/transform/test/ir-to-dsl.test.ts +++ b/packages/transform/test/ir-to-dsl.test.ts @@ -1044,6 +1044,207 @@ describe('irToDsl: routes leaving a loop beside the two it is built from', () => ); }); +describe('irToDsl: a loop head whose exit is listed before its route into the body', () => { + // The engine tests the exit first, which `while (c)` cannot say, so the loop + // stays unfolded and its back edge becomes the hand-repair marker. + const marker = `${UNSTRUCTURED_MARKER} (dropped edge into L)`; + it.each<[title: string, exits: SequenceFlow[], body: string[]]>([ + [ + 'an exit listed first keeps its place ahead of the body', + [edge('L', 'A', { condition: '${x > 1}' })], + [ + ' if (x > 1) {', + ' } else if (n > 100) {', + ' user T', + ` ${marker}`, + ' }', + ' user A', + ' end EA', + ], + ], + [ + 'an exit listed first keeps its place with another exit after the body', + [ + edge('L', 'A', { condition: '${x > 1}' }), + edge('L', 'B', { condition: '${y > 1}' }), + ], + [ + ' if (x > 1) {', + ' user A', + ' end EA', + ' } else if (n > 100) {', + ' goto T', + ' }', + ' user B', + ' end EB', + ' user T', + ` ${marker}`, + ], + ], + ])('%s', async (_, exits, body) => { + const [first, ...after] = exits; + const exitSteps = exits.map((f) => f.targetRef); + const ir = minimalProcess( + [ + start('S'), + gateway('L'), + user('T'), + ...exitSteps.map(user), + ...exitSteps.map((id) => end(`E${id}`)), + ], + [ + edge('S', 'L'), + first!, + edge('L', 'T', { condition: '${n > 100}' }), + ...after, + edge('T', 'L'), + ...exitSteps.map((id) => edge(id, `E${id}`)), + ], + ); + const { source, warnings } = printDsl(ir); + + expect(bodyOf(source)).toBe( + ['process p {', ' start S', ...body, '}', ''].join('\n'), + ); + expectReports(warnings, ['inventedFallback', 'L'], ['droppedEdge', 'L']); + const second = irToDsl(await reDesugar(await printed(ir))); + const withoutMarker = bodyOf(source) + .split('\n') + .filter((line) => line.trim() !== marker) + .join('\n'); + expect(bodyOf(second)).toBe(withoutMarker); + }); +}); + +describe('irToDsl: a loop route with no condition listed before a conditioned one', () => { + // Operaton's exclusive split: the first route but the default, in document + // order, with no condition or one that holds, else the default. + const run = (ir: BpmnProcess, vars: Record): string[] => { + const holds = (c: string): boolean => + Boolean( + new Function(...Object.keys(vars), `return ${c.slice(2, -1)};`)( + ...Object.values(vars), + ), + ); + const trace: string[] = []; + let at: FlowElement = only(ir, 'startEvent'); + while (at.kind !== 'endEvent' && trace.length < 4) { + if (at.kind === 'userTask') trace.push(at.id); + const fallback = 'defaultFlowId' in at ? at.defaultFlowId : undefined; + const outs = ir.sequenceFlows.filter((f) => f.sourceRef === at.id); + const next = + outs.find( + (f) => + f.id !== fallback && + (f.conditionExpression === undefined || + holds(f.conditionExpression)), + ) ?? outs.find((f) => f.id === fallback); + if (next === undefined) return [...trace, 'stuck']; + at = byId(ir, next.targetRef); + } + return trace; + }; + const marker = `${UNSTRUCTURED_MARKER} (dropped edge into L)`; + + it.each< + [ + title: string, + elements: FlowElement[], + flows: SequenceFlow[], + body: string[], + reports: [keyof typeof REPORT, string][], + runs: Record[], + ] + >([ + [ + 'an exit listed before the back edge of a do-while stays tested first', + [start('S'), user('T'), gateway('L'), end('E')], + [...chain('S', 'T', 'L', 'E'), edge('L', 'T', { condition: '${c}' })], + [ + ' user T', + ' if (true) {', + ' } else if (c) {', + ' goto T', + ' }', + ' end E', + ], + [], + [{ c: true }, { c: false }], + ], + [ + 'an exit listed before a route back into a printed step stays tested first', + [start('S'), user('T'), gateway('G'), user('A'), end('EA')], + [ + ...chain('S', 'T', 'G', 'A', 'EA'), + edge('G', 'T', { condition: '${x > 5}' }), + ], + [ + ' user T', + ' if (true) {', + ' } else if (x > 5) {', + ' goto T', + ' }', + ' user A', + ' end EA', + ], + [], + [{ x: 0 }, { x: 10 }], + ], + [ + 'a back edge to a while head listed before a conditioned route is marked', + [ + start('S'), + gateway('L', 'Flow_L_E'), + user('T'), + gateway('G'), + user('B'), + end('EB'), + end('E'), + ], + [ + edge('S', 'L'), + edge('L', 'T', { condition: '${n > 100}' }), + edge('L', 'E'), + ...chain('T', 'G', 'L'), + edge('G', 'B', { condition: '${x > 1}' }), + edge('B', 'EB'), + ], + [ + ' if (n > 100) {', + ' user T', + ' if (true) {', + ` ${marker}`, + ' } else if (x > 1) {', + ' user B', + ' }', + ' } else {', + ' end E', + ' }', + ' end EB', + ], + [['droppedEdge', 'L']], + [], + ], + ])('%s', async (_, elements, flows, body, reports, runs) => { + const ir = minimalProcess(elements, flows); + const { source, warnings } = printDsl(ir); + + expect(bodyOf(source)).toBe( + ['process p {', ' start S', ...body, '}', ''].join('\n'), + ); + expectReports(warnings, ...reports); + const rebuilt = await reDesugar(await printed(ir)); + const withoutMarker = bodyOf(source) + .split('\n') + .filter((line) => line.trim() !== marker) + .join('\n'); + expect(bodyOf(irToDsl(rebuilt))).toBe(withoutMarker); + expect(runs.map((vars) => run(rebuilt, vars))).toEqual( + runs.map((vars) => run(ir, vars)), + ); + }); +}); + describe('irToDsl: a split inside a loop body whose every route is conditioned', () => { const reviewLoopIr = ( staying: readonly (readonly [step: string, condition: string])[], @@ -2372,16 +2573,100 @@ describe('irToDsl: a split with no route out', () => { }); }); +describe('irToDsl: a route with no condition at an exclusive split', () => { + // Operaton takes the first route in document order that carries no condition + // or one that holds, so a `true` head keeps an early one in its place. + it.each< + [title: string, split: FlowElement, routes: SequenceFlow[], body: string[]] + >([ + [ + 'listed before a conditioned route, it heads the chain as true', + gateway('G'), + [edge('G', 'A'), edge('G', 'B', { condition: '${x > 5}' })], + [ + ' if (true) {', + ' user A', + ' } else if (x > 5) {', + ' user B', + ' }', + ' end E', + ], + ], + [ + 'listed between two conditioned routes, it heads its place as true', + gateway('G'), + [ + edge('G', 'B', { condition: '${x > 5}' }), + edge('G', 'A'), + edge('G', 'C', { condition: '${x > 9}' }), + ], + [ + ' if (x > 5) {', + ' user B', + ' } else if (true) {', + ' user A', + ' } else if (x > 9) {', + ' user C', + ' }', + ' end E', + ], + ], + [ + 'listed last, it is the continuation as drawn', + gateway('G'), + [edge('G', 'B', { condition: '${x > 5}' }), edge('G', 'A')], + [ + ' if (x > 5) {', + ' user B', + ' } else {', + ' user A', + ' }', + ' end E', + ], + ], + [ + 'named as the default, it is tried last wherever it is listed', + gateway('G', 'F_A'), + [ + edge('G', 'A', { id: 'F_A' }), + edge('G', 'B', { condition: '${x > 5}' }), + ], + [ + ' if (x > 5) {', + ' user B', + ' } else {', + ' user A', + ' }', + ' end E', + ], + ], + ])('%s', async (_, split, routes, body) => { + const steps = routes.map((f) => f.targetRef); + const ir = minimalProcess( + [start('S'), split, ...steps.map(user), end('E')], + [edge('S', 'G'), ...routes, ...steps.map((id) => edge(id, 'E'))], + ); + const { source, warnings } = printDsl(ir); + + expect(bodyOf(source)).toBe( + ['process p {', ' start S', ...body, '}', ''].join('\n'), + ); + expect(warnings).toEqual([]); + expect(irToDsl(await reDesugar(await printed(ir)))).toBe(source); + }); +}); + describe('irToDsl: a split that routes back into itself', () => { it.each([ [ 'a plain route back beside a weighed way on', gateway('G'), [edge('G', 'G'), edge('G', 'A', { condition: '${go}' })], - ' if (go) {\n' + - ' } else {\n' + + ' if (true) {\n' + ` ${UNSTRUCTURED_MARKER} (dropped edge into G)\n` + + ' } else if (go) {\n' + ' }\n', + [['droppedEdge', 'G']], ], [ 'a weighed route back beside the fallback', @@ -2393,10 +2678,11 @@ describe('irToDsl: a split that routes back into itself', () => { ' if (again) {\n' + ` ${UNSTRUCTURED_MARKER} (dropped edge into G)\n` + ' }\n', + [['droppedEdge', 'G']], ], ] as const)( '%s prints the marker rather than a loop', - async (_title, split, routes, chain) => { + async (_title, split, routes, chain, reports) => { const ir = minimalProcess( [start('S'), split, user('A'), end('E')], [edge('S', 'G'), ...routes, edge('A', 'E')], @@ -2406,7 +2692,7 @@ describe('irToDsl: a split that routes back into itself', () => { expect(bodyOf(source)).toBe( 'process p {\n start S\n' + chain + ' user A\n end E\n}\n', ); - expectReports(warnings, ['droppedEdge', 'G']); + expectReports(warnings, ...reports); await printed(ir); }, ); @@ -2944,7 +3230,7 @@ describe('warnings: a condition the script has nowhere to write', () => { expectReports( warnings, ['choiceFallbackCondition', 'Split'], - ['droppedFlowCondition', 'Split'], + ['droppedEdge', 'Loop'], ); }); @@ -3177,6 +3463,160 @@ describe('irToDsl: conditioned parallel branches', () => { }); describe('irToDsl: a split left with nowhere to go when no condition holds', () => { + // Operaton tries an exclusive split's routes in document order, so only the + // last one may print as the fall-through and give up its condition. + const invoiceIr = ( + approval: [condition: string, target: string][], + ): BpmnProcess => + minimalProcess( + [ + start('S'), + user('Approve'), + gateway('G'), + user('Pay'), + user('Review'), + gateway('G2'), + end('E1'), + end('E2'), + ], + [ + ...chain('S', 'Approve', 'G'), + ...approval.map(([condition, target]) => + edge('G', target, { condition }), + ), + edge('Pay', 'E1'), + edge('Review', 'G2'), + edge('G2', 'E2', { condition: '${!clarified}' }), + edge('G2', 'Approve', { condition: '${clarified}' }), + ], + ); + it.each< + [ + title: string, + ir: BpmnProcess, + body: string[], + reports: (readonly [keyof typeof REPORT, string])[], + ] + >([ + [ + 'overlapping conditions keep their order: the first keeps its condition and the last falls through', + minimalProcess( + [start('S'), gateway('G'), user('A'), user('B'), end('EA'), end('EB')], + [ + edge('S', 'G'), + edge('G', 'A', { condition: '${x > 1}' }), + edge('G', 'B', { condition: '${x > 5}' }), + edge('A', 'EA'), + edge('B', 'EB'), + ], + ), + [ + ' start S', + ' if (x > 1) {', + ' user A', + ' end EA', + ' }', + ' user B', + ' end EB', + ], + [['inventedFallback', 'G']], + ], + [ + 'an approval listed before its review falls through to the review', + invoiceIr([ + ['${approved}', 'Pay'], + ['${!approved}', 'Review'], + ]), + [ + ' start S', + ' user Approve', + ' if (approved) {', + ' user Pay', + ' end E1', + ' }', + ' user Review', + ' if (!clarified) {', + ' end E2', + ' } else if (clarified) {', + ' goto Approve', + ' }', + ' goto E1', + ], + [ + ['inventedFallback', 'G'], + ['inventedFallback', 'G2'], + ], + ], + [ + 'an approval listed after its review falls through to the approval', + invoiceIr([ + ['${!approved}', 'Review'], + ['${approved}', 'Pay'], + ]), + [ + ' start S', + ' user Approve', + ' if (!approved) {', + ' user Review', + ' if (!clarified) {', + ' end E2', + ' } else if (clarified) {', + ' goto Approve', + ' }', + ' }', + ' user Pay', + ' end E1', + ], + [ + ['inventedFallback', 'G'], + ['inventedFallback', 'G2'], + ], + ], + [ + 'a last route into a step reached from elsewhere keeps its condition behind an empty first branch', + minimalProcess( + [ + start('S'), + gateway('G0'), + gateway('G'), + user('A'), + user('B'), + end('EA'), + end('EB'), + ], + [ + edge('S', 'G0'), + edge('G0', 'B', { condition: '${p}' }), + edge('G0', 'G'), + edge('G', 'A', { condition: '${a}' }), + edge('G', 'B', { condition: '${b}' }), + edge('A', 'EA'), + edge('B', 'EB'), + ], + ), + [ + ' start S', + ' if (p) {', + ' goto B', + ' }', + ' if (a) {', + ' } else if (b) {', + ' goto B', + ' }', + ' user A', + ' end EA', + ' user B', + ' end EB', + ], + [['inventedFallback', 'G']], + ], + ])('%s', async (_, ir, body, reports) => { + const { source, warnings } = printDsl(ir); + expect(bodyOf(source)).toBe(['process p {', ...body, '}', ''].join('\n')); + expectReports(warnings, ...reports); + await printed(ir); + }); + it('reads the fallback an imported step carries, and reports only the condition on it that is weighed nowhere', async () => { const condition = (body: string): string => `${body}`; @@ -3215,6 +3655,57 @@ describe('irToDsl: a split left with nowhere to go when no condition holds', () await printed(ir); }); + it('an empty condition body leaves two routes unconditioned, so none is resumed', async () => { + // The import refusal counts the empty body as a condition, as Operaton + // does, but the flow imports without one. + const { ir, warnings: imported } = await xmlToIr(bpmnDoc` + + + + + + + + + + + + + + + ${'${c}'} + + + + `); + + expect(imported.map((w) => w.elementId)).toEqual(['F1']); + const { source, warnings } = printDsl(ir); + expect(bodyOf(source)).toBe( + [ + 'process p {', + ' start S', + ' if (true) {', + ' goto A', + ' } else if (true) {', + ' goto B', + ' } else if (c) {', + ' goto C', + ' }', + ' user A', + ' end EA', + ' user B', + ' end EB', + ' user C', + ' end EC', + '}', + '', + ].join('\n'), + ); + expect(warnings).toEqual([]); + await printed(ir); + }); + it('says nothing about an invented fallback at a step whose route back into the loop the loop already printed', async () => { const ir = minimalProcess( [ diff --git a/packages/transform/test/xml-to-ir.test.ts b/packages/transform/test/xml-to-ir.test.ts index bf87c04..c50e85d 100644 --- a/packages/transform/test/xml-to-ir.test.ts +++ b/packages/transform/test/xml-to-ir.test.ts @@ -66,6 +66,7 @@ import { import type { XmlTag } from './helpers/bpmn-doc.js'; import { bpmnDefs, + TIME_TO_LIVE, bpmnDoc, camundaDoc, dualDefs, @@ -78,7 +79,7 @@ import { } from './helpers/bpmn-doc.js'; import { importById, importOnly } from './helpers/import-node.js'; import { byId, subProcess } from './helpers/ir-query.js'; -import { parse } from './helpers/parse.js'; +import { parse, validate } from './helpers/parse.js'; const here = dirname(fileURLToPath(import.meta.url)); const HANDWRITTEN_XML = readFileSync( @@ -130,7 +131,7 @@ const unread = ( const CAMUNDA_ALIAS_WARNING = { elementId: 'p', - category: 'unmappedConstruct', + category: 'rewritten', message: CAMUNDA_ALIAS_MESSAGE, }; @@ -157,7 +158,7 @@ const expectOneWarning = ( }; const rootedDoc = (roots: string, body: string, defs = bpmnDefs): string => - defs`${roots} + defs`${roots} ${body} `; @@ -271,6 +272,7 @@ describe('xmlToIr: script task support', () => { '', 'x = 1;', { format: 'juel' }, + 'rewritten', "The script on 'ST' has no scriptFormat; " + '`BpmnParse.parseScriptTaskElement` substitutes ' + '`ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE` (juel), and it was ' + @@ -281,6 +283,7 @@ describe('xmlToIr: script task support', () => { 'scriptFormat="cobol"', '1', { format: 'cobol' }, + 'carriedAsWritten', "The script on 'ST' names the language 'cobol', which the DSL " + 'has no fence alias for; it was imported as written, and the ' + 'printed script draws an error there.', @@ -290,6 +293,7 @@ describe('xmlToIr: script task support', () => { 'scriptFormat="javascript"', '', { code: '' }, + 'carriedAsWritten', "The body of the script on 'ST' is empty: ScriptUtil.getScript " + 'deploys it, since it checks the source for null and not for ' + 'emptiness, and the printed script draws an empty-body error there.', @@ -312,19 +316,39 @@ describe('xmlToIr: script task support', () => { }, ], }, + 'carriedAsWritten', 'The body of the operaton:script in an operaton:executionListener is ' + 'empty: ScriptUtil.getScript deploys it, since it checks the source ' + 'for null and not for emptiness, and the printed script draws an ' + 'empty-body error there.', ], - ])('%s', async (_title, attrs, children, carried, message) => { - const { node, warnings } = await importOnly( - scriptTask(attrs, children), - 'scriptTask', + ] as const)( + '%s', + async (_title, attrs, children, carried, category, message) => { + const { node, warnings } = await importOnly( + scriptTask(attrs, children), + 'scriptTask', + ); + expect(node).toMatchObject(carried); + expect(warnings).toEqual([{ elementId: 'ST', category, message }]); + }, + ); + + it('a URI scriptFormat prints a fence whose error names the whole format', async () => { + const { ir } = await xmlToIr( + scriptTask( + 'scriptFormat="http://www.java.com/java"', + 'x = 1;', + ), ); - expect(node).toMatchObject(carried); - expect(warnings).toEqual([ - { elementId: 'ST', category: 'extensionAttribute', message }, + const { diagnostics } = await validate(irToDsl(ir).source); + expect(diagnostics.map((d) => [d.severity, d.message])).toEqual([ + [ + 1, + "Script task 'ST' has an unsupported language tag " + + "'http://www.java.com/java'. Use 'juel', 'js', 'javascript', " + + "'ecmascript', 'groovy', 'py', 'python', 'rb', 'ruby', or 'feel'.", + ], ]); }); @@ -376,7 +400,7 @@ describe('xmlToIr: the document level', () => { `; const process = (attrs: string, id = 'p', content = STEPS): string => - ` \n${content}\n `; + ` \n${content}\n `; const EXECUTABLE = process(' isExecutable="true"'); const collaboration = (children: string): string => ` \n${children}\n `; @@ -456,9 +480,10 @@ ${EXECUTABLE}`, { imports: 'p', warnings: [ - unmapped( + warning( 'p', /^The process 'p' is not marked isExecutable="true", which `BpmnParse\.parseProcessDefinitions` skips in a new deployment; it was imported as an executable process and is written back as one\.$/, + 'behaviourChanged', ), ], }, @@ -550,6 +575,21 @@ ${EXECUTABLE}`, /^signal roots 'Signal_1' and 'Signal_2' both declare the name "Go"; `BpmnParse\.parseSignals` fails the deployment/, }, ], + [ + 'a process without an id refuses', + bpmnDefs`${EXECUTABLE.replace('id="p" ', '')}`, + { + refusal: UnsupportedDocumentError, + detail: + 'its process has no id; Operaton deploys a process under its id as ' + + 'the definition key, and the deployment fails without one', + }, + ], + [ + 'a start without an id imports in a process the engine does not deploy', + bpmnDefs`${approval}\n${process(' isExecutable="false"', 'sketch', ' ')}`, + { imports: 'approval', warnings: [twoPoolsWarnings[0]] }, + ], [ 'an empty process refuses, since parseStartEvents fails the deployment without a start', bpmnDefs`${process(' isExecutable="true"', 'p', '')}`, @@ -568,6 +608,271 @@ ${EXECUTABLE}`, expect(ir.id).toBe(expected.imports); expect(warnings).toEqual(expected.warnings); }); + + it.each([ + [ + 'a sequence flow imports under a minted id clear of every authored one', + ` + `, + ['S', 'A', 'E'], + ['Flow_S_A_2', 'Flow_S_A'], + [], + ], + [ + "two tasks and an end event import under distinct minted ids, the end clear of the compiler's implicit end", + ` + + + + `, + ['S', 'A', 'E', 'UserTask_p', 'UserTask_p_2', 'EndEvent_p_2'], + ['F1', 'F2'], + [4, 5, 6], + ], + [ + "an end event imports clear of a boundary escape's implicit end, whose boundary id is the container's with a counter", + ` + + + + PT1H + `, + ['S', 'A', 'E', 'E2', 'p_2', 'EndEvent_p_3'], + ['F1', 'F2', 'F3'], + [4], + ], + ])( + 'an element without an id, which the engine deploys: %s', + async (_title, content, nodes, flows, neverRunLines) => { + const { ir, warnings } = await xmlToIr( + bpmnDefs`${process(' isExecutable="true"', 'p', ` \n \n \n${content}`)}`, + ); + // Nothing can flow into a node without an id, so its step never runs. + const { diagnostics } = await validate(irToDsl(ir).source); + expect([ + ir.flowElements.map((n) => n.id), + ir.sequenceFlows.map((f) => f.id), + warnings, + diagnostics.map((d) => [d.range.start.line, d.message]), + ]).toEqual([ + nodes, + flows, + [], + neverRunLines.map((line) => [ + line, + expect.stringMatching(/^This step can never run:/), + ]), + ]); + }, + ); + + const TIMER = + 'PT1H'; + const LINEAR = + ' \n \n \n'; + + const DEPLOY = (method: string, quote = ''): string => + `, which ${method} fails the deployment on${quote}`; + const RUN = (method: string, when: string): string => + `: Operaton deploys it and ${method} fails the run ${when}`; + const NO_ID = '("boundary event has no id")'; + const boundary = (def: string): string => + `${LINEAR} ${def}`; + + it.each([ + [ + 'a lone start event', + ' ', + 'startEvent', + 'p', + DEPLOY('BpmnParse.parseStartFormHandlers'), + ], + [ + 'a message start where the process has no plain start', + ` + `, + 'startEvent', + 'p', + DEPLOY('BpmnParse.parseStartFormHandlers'), + ], + [ + 'a timer start', + ` ${TIMER}`, + 'startEvent', + 'p', + DEPLOY('BpmnParse.parseTimer', ` ('Attribute "id" is required!')`), + ], + [ + 'the start event of a subprocess', + `${LINEAR} `, + 'startEvent', + 'Sub', + DEPLOY('HistoryParseListener.parseStartEvent'), + ], + [ + 'the start event of an event sub-process', + `${LINEAR} `, + 'startEvent', + 'ES', + DEPLOY('HistoryParseListener.parseStartEvent'), + ], + [ + 'a timer boundary event', + boundary(TIMER), + 'boundaryEvent', + 'p', + DEPLOY('BpmnParse.parseTimer', ` ('Attribute "id" is required!')`), + ], + [ + 'a message boundary event', + boundary(''), + 'boundaryEvent', + 'p', + DEPLOY('BpmnParse.parseBoundaryMessageEventDefinition', ` ${NO_ID}`), + ], + [ + 'a signal boundary event', + boundary(''), + 'boundaryEvent', + 'p', + DEPLOY('BpmnParse.parseBoundarySignalEventDefinition', ` ${NO_ID}`), + ], + [ + 'an error boundary event, which deploys but fails the run the error reaches', + boundary(''), + 'boundaryEvent', + 'p', + RUN( + 'ErrorDeclarationForProcessInstanceFinder.isReThrowingErrorEventSubprocess', + 'when an error is thrown inside its step', + ), + ], + [ + 'an escalation boundary event, which deploys but fails the run the escalation reaches', + `${LINEAR} + `, + 'boundaryEvent', + 'p', + RUN( + 'ExecutionEntity.generateActivityInstanceId', + 'when an escalation reaches it', + ), + ], + [ + 'a cancel boundary event, which deploys but fails the run that cancels the transaction', + `${LINEAR} + `, + 'boundaryEvent', + 'p', + RUN( + 'ExecutionEntity.generateActivityInstanceId', + 'when the transaction is cancelled', + ), + ], + [ + 'a conditional boundary event, which deploys but fails the run of its step', + boundary( + 'go', + ), + 'boundaryEvent', + 'p', + RUN('ConditionalEventHandler.handleEvent', 'of its step'), + ], + [ + 'a timer catch event', + `${LINEAR} ${TIMER}`, + 'intermediateCatchEvent', + 'p', + DEPLOY('BpmnParse.parseTimer', ` ('Attribute "id" is required!')`), + ], + [ + 'an event-based gateway', + `${LINEAR} `, + 'eventBasedGateway', + 'p', + DEPLOY('BpmnParse.parseEventBasedGateway'), + ], + ])( + 'an element without an id, which the engine refuses: %s', + async (_title, content, tag, container, failure) => { + await expectRefusal( + xmlToIr( + bpmnDefs` + +${process(' isExecutable="true"', 'p', content)}`, + ), + UnsupportedDocumentError, + `a bpmn:${tag} in '${container}' has no id${failure}; give it one`, + ); + }, + ); + + const HERE = "without an id in 'p')"; + it.each([ + [ + 'a compensation boundary, which the engine runs, reaches the compensation refusal', + boundary(''), + UnsupportedEventFeatureError, + `The element (a bpmn:boundaryEvent ${HERE} cannot be imported`, + ], + [ + 'a boundary with no trigger definition reaches the one-trigger refusal', + boundary(''), + UnsupportedEventFeatureError, + `The element (a bpmn:boundaryEvent ${HERE} cannot be imported`, + ], + [ + 'a boundary inside a sub-process without an id names neither minted id', + `${LINEAR} `, + UnsupportedEventFeatureError, + 'The element (a bpmn:boundaryEvent without an id) cannot be imported', + ], + [ + 'a compensation handler', + `${LINEAR} `, + UnsupportedEventFeatureError, + `The element (a bpmn:userTask ${HERE} cannot be imported`, + ], + [ + 'a link throw naming no link', + `${LINEAR} `, + UnsupportedEventFeatureError, + `The element (a bpmn:intermediateThrowEvent ${HERE} cannot be imported`, + ], + [ + 'a service task with no binding', + `${LINEAR} `, + UnsupportedServiceTaskFormError, + `Service task (a bpmn:serviceTask ${HERE} uses unsupported execution form`, + ], + [ + 'two input parameters sharing a name', + `${LINEAR} 12`, + UnsupportedExtensionFormError, + `The Operaton extension content on (a bpmn:userTask ${HERE} cannot be imported`, + ], + [ + 'a call activity naming nothing to call', + `${LINEAR} `, + UnsupportedCallActivityError, + `The call activity (a bpmn:callActivity ${HERE} cannot be imported`, + ], + ] as const)( + 'an element without an id refused past the id check names no minted id: %s', + async (_title, content, refusal, subject) => { + const err = await expectRefusal( + xmlToIr(operatonDefs`${process(' isExecutable="true"', 'p', content)}`), + refusal, + ); + const minted = /\b[A-Z][A-Za-z]*_(p|SubProcess_p)(_\d+)?\b/; + expect([ + err.message.split(': ')[0], + Object.values(err).filter( + (v) => typeof v === 'string' && minted.test(v), + ), + ]).toEqual([subject, []]); + }, + ); }); describe('xmlToIr: start, end, and emit triggers', () => { @@ -586,7 +891,7 @@ describe('xmlToIr: start, end, and emit triggers', () => { definition: string, { attrs = '', roots = '', defs = bpmnDefs }: EventXmlOptions = {}, ): string => - defs`${roots} + defs`${roots} ${definition} @@ -602,7 +907,7 @@ describe('xmlToIr: start, end, and emit triggers', () => { defs = operatonDefs, }: EventXmlOptions = {}, ): string => - defs`${roots} + defs`${roots} ${definition} @@ -611,7 +916,7 @@ describe('xmlToIr: start, end, and emit triggers', () => { `; const emitXml = (definition: string): string => - operatonDefs`${MESSAGE_ROOT} + operatonDefs`${MESSAGE_ROOT} ${definition} @@ -685,7 +990,7 @@ ${content} ], [ 'two plain starts in a process, which selectInitial fails the deployment on', - bpmnDefs` + bpmnDefs` @@ -767,7 +1072,7 @@ ${content} expect(e.elementId).toBe(elementId); if (remedy !== undefined) { expect(e.message).toBe( - `The event construct at '${elementId}' cannot be imported: ${detail}. ${remedy}`, + `The element '${elementId}' cannot be imported: ${detail}. ${remedy}`, ); } }); @@ -788,7 +1093,7 @@ ${content} expect(warnings).toEqual([ { elementId: 'Sub', - category: 'unmappedConstruct', + category: 'behaviourChanged', message: "The bpmn:transaction 'Sub' has no start event: Operaton " + 'deploys it and SubProcessActivityBehavior.execute fails on ' + @@ -1054,7 +1359,7 @@ describe('xmlToIr: imports a repetition', () => { }, [ on( - 'unmappedConstruct', + 'behaviourChanged', "The bpmn:completionCondition on 'T' is the bare text " + '"nrOfCompletedInstances > 1" with no "${...}" or "#{...}" opener: ' + 'parseMultiInstanceLoopCharacteristics hands it to createExpression as ' + @@ -1076,12 +1381,7 @@ describe('xmlToIr: imports a repetition', () => { 'operaton:collection="lines" operaton:async="true"', '', { collection: 'lines', asyncBefore: true }, - [ - on( - 'unmappedConstruct', - asyncRespellingMessage("the repetition of 'T'"), - ), - ], + [on('rewritten', asyncRespellingMessage("the repetition of 'T'"))], ], ...( [ @@ -1255,7 +1555,7 @@ describe('xmlToIr: a #{...} body the printer spells bare is rebuilt inside ${... ] as const)('%s reports the rewrap', async (_title, xml, message) => { const { warnings } = await xmlToIr(xml); expect(warnings.map((w) => w.message)).toEqual([message]); - expect(warnings.map((w) => w.category)).toEqual(['unmappedConstruct']); + expect(warnings.map((w) => w.category)).toEqual(['rewritten']); }); }); @@ -1333,7 +1633,7 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even 'an unprefixed xsi:type="tFormalExpression" in a default-namespace document', ` - + @@ -1394,7 +1694,7 @@ describe('xmlToIr: a scripted condition on a sequence flow or a conditional even ); expect(conditionOf(ir)).toBe(imported); expect(warnings).toEqual([ - { elementId: 'F2', category: 'unmappedConstruct', message }, + { elementId: 'F2', category: 'behaviourChanged', message }, ]); }, ); @@ -1523,21 +1823,57 @@ describe('xmlToIr: warns for dropped extension attributes', () => { startAttrs = '', startId = 'S', ): string => - operatonDefs` + operatonDefs` `; - it('a process keeps the history time to live it authored', async () => { + it.each([ + ['an authored history time to live is kept', 'P90D', 'P90D', []], + ['the exported default reads back as absent', 'P30D', undefined, []], + [ + 'an absent history time to live warns that the rebuilt process deploys', + undefined, + undefined, + [ + { + elementId: 'p', + category: 'behaviourChanged', + message: + "The process 'p' sets no historyTimeToLive, which " + + 'HistoryTimeToLiveParser.parseAndValidate refuses under the ' + + "engine's default enforceHistoryTimeToLive; it is written back " + + "with 'P30D', so the rebuilt process deploys where the source " + + 'did not.', + }, + ], + ], + ])('%s', async (_title, authored, expected, expectedWarnings) => { const { ir, warnings } = await xmlToIr( - headerDoc('operaton:historyTimeToLive="P90D"'), + operatonDefs` + + + + `, ); - expect([ir.historyTimeToLive, warnings]).toEqual(['P90D', []]); + expect([ir.historyTimeToLive, warnings]).toEqual([ + expected, + expectedWarnings, + ]); + }); + + it('isStartableInTasklist imports as BpmnParse.isStartable reads it, true only for a case-insensitive "true"', async () => { + const read = async (value: string) => + (await xmlToIr(headerDoc(`operaton:isStartableInTasklist="${value}"`))).ir + .isStartableInTasklist; + expect( + await Promise.all(['TRUE', 'true', 'false', 'no'].map(read)), + ).toEqual([true, true, false, false]); }); const starterDoc = (processAttrs: string, children: string): string => - operatonDefs` + operatonDefs` ${children} @@ -1569,7 +1905,7 @@ ${children} }, warnings: [ [ - 'unmappedConstruct', + 'rewritten', 'p', "The operaton:potentialStarter on 'p' imports as " + 'candidateStarterUsers: "a,b" and candidateStarterGroups: "g,h": ' + @@ -1683,10 +2019,8 @@ ${children} }, { title: - 'the job priority, task priority and tasklist flag on the process name parseProcess', - xml: headerDoc( - 'operaton:jobPriority="3" operaton:taskPriority="4" operaton:isStartableInTasklist="false"', - ), + 'the job priority and task priority on the process name parseProcess', + xml: headerDoc('operaton:jobPriority="3" operaton:taskPriority="4"'), warnings: [ unread( "'operaton:jobPriority' setting", @@ -1700,12 +2034,6 @@ ${children} 'a ', 'reads it (BpmnParse.parseProcess through parsePriority)', ), - unread( - "'operaton:isStartableInTasklist' setting", - 'p', - 'a ', - 'reads it (BpmnParse.parseProcess through isStartable)', - ), ], }, ])('$title', async ({ xml, warnings: expected }) => { @@ -1836,7 +2164,7 @@ describe('xmlToIr: bpmn:documentation', () => { ${doc('Raised on day three.')} ${doc('Sent by billing.')} ${doc('Broadcast by the warehouse.')} - ${doc('Onboarding, end to end.')} + ${doc('Onboarding, end to end.')} ${doc('Fires when HR files the request.')} ${KINDS.map(([tag, attrs]) => ` ${doc(`On ${tag}.`)}${CONTENT[tag] ?? ''}`).join('\n')} @@ -1963,7 +2291,7 @@ describe('xmlToIr: warns for unmapped BPMN content', () => { attrs = 'isExecutable="true"', ) => bpmnDefs`${roots} - + ${content} @@ -2009,10 +2337,35 @@ describe('xmlToIr: warns for unmapped BPMN content', () => { }, ], ], + [ + 'an eventDefinitionRef is reported on the event that names it, beside the root it names', + withRoots(' ').replace( + '', + '\n TED\n ', + ), + [ + { + elementId: 'S', + category: 'unmappedConstruct', + message: + "The eventDefinitionRef 'TED' on 'S' was not imported: BpmnParse " + + 'reads only the event definitions nested in the event, so the ' + + 'document written back runs the same.', + }, + { + elementId: 'TED', + category: 'unmappedConstruct', + message: + "A bpmn:terminateEventDefinition 'TED' root element was not " + + 'imported (this tool imports the executable flow and the engine ' + + 'settings on its steps, and nothing declared or drawn beside it).', + }, + ], + ], [ 'isExecutable="false" is reported, and the process imports as executable regardless', withRoots('', '', 'isExecutable="false"'), - [warning('p', /isExecutable="false".*deploy/i, 'unmappedConstruct')], + [warning('p', /isExecutable="false".*deploy/i, 'behaviourChanged')], ], ])('%s', async (_title, xml, expected) => { const { ir, warnings } = await xmlToIr(xml); @@ -2120,7 +2473,7 @@ describe('xmlToIr: callActivity import', () => { 'word and BaseCallableElement.isLatestBinding reads none as ' + 'latest, and this tool writes it back as ' + 'calledElementBinding="latest", which the engine reads the same.', - 'unmappedConstruct', + 'rewritten', ), CAMUNDA_ALIAS_WARNING, ], @@ -2293,7 +2646,7 @@ describe('xmlToIr: callActivity import', () => { describe('xmlToIr: event layer import', () => { const fullEventXml = dualDefs` - + @@ -2574,7 +2927,7 @@ describe('xmlToIr: event layer import', () => { const danglingErrorRef = (ownerId: string): ImportWarning => ({ elementId: ownerId, - category: 'unmappedConstruct', + category: 'rewritten', message: `The errorRef 'Error_Missing' on '${ownerId}' names no bpmn:error ` + "root and imports as the code 'Error_Missing': Operaton takes a " + @@ -2962,7 +3315,7 @@ describe('xmlToIr: boundary event import', () => { const boundaryXml = bpmnDefs` - + @@ -3274,7 +3627,7 @@ describe('xmlToIr: link events', () => { extraFlows = '', defs = bpmnDefs, }: PairOptions = {}): string => - defs` + defs` ${throwChildren} @@ -3529,7 +3882,7 @@ describe('xmlToIr: BPMN-native assignment and the quantity attributes', () => { ); expect(node).toEqual({ kind: 'userTask', id: 'Review', ...imported }); expect(reported(warnings)).toEqual([ - ['unmappedConstruct', 'Review', expect.stringContaining(detail)], + ['rewritten', 'Review', expect.stringContaining(detail)], ]); }, ); @@ -3561,7 +3914,7 @@ ${role('potentialOwner', 'Owner_1', 'managers')} }); expect(reported(warnings)).toEqual([ [ - 'unmappedConstruct', + 'rewritten', 'Review', expect.stringMatching( /^The bpmn:humanPerformer 'Lead' on 'Review' imports as assignee: "demo":/, @@ -3641,7 +3994,7 @@ ${role('potentialOwner', 'Owner_1', 'managers')} describe('xmlToIr: flat engine settings on every carrying node kind', () => { const everyKindXml = operatonDefs` - + { const RESPELLED_RESULT_VARIABLE = { elementId: 'T', - category: 'unmappedConstruct', + category: 'rewritten', message: 'The operaton:resultVariableName="out" on \'T\' imports as ' + 'resultVariable: "out": BpmnParse.parseResultVariable reads the two ' + @@ -3766,7 +4119,7 @@ describe('xmlToIr: flat engine settings on every carrying node kind', () => { describe("xmlToIr: a timer job's lock is read where BpmnParse.parseTimer reads it", () => { const boundaryTimerDoc = (tagAttrs: string, defAttrs: string): string => - operatonDefs` + operatonDefs` @@ -3786,7 +4139,7 @@ describe("xmlToIr: a timer job's lock is read where BpmnParse.parseTimer reads i defAttrs: string, subAttrs = '', ): string => - operatonDefs` + operatonDefs` @@ -3890,7 +4243,7 @@ describe('xmlToIr: the job settings on a gateway', () => { asyncBefore: true, }); expect(warnings.map((w) => [w.elementId, w.category, w.message])).toEqual([ - ['G', 'unmappedConstruct', asyncRespellingMessage("'G'")], + ['G', 'rewritten', asyncRespellingMessage("'G'")], ]); }); }); @@ -3920,7 +4273,7 @@ const ioBlock = (params: string): string => describe('xmlToIr: input/output parameters', () => { it('every activity kind reads its own io block, and an event that runs one reports it naming parseActivityInputOutput', async () => { const xml = operatonDefs` - + ${IO()} ${IO()} @@ -4101,7 +4454,7 @@ describe('xmlToIr: execution listeners', () => { ` `, ); const { warnings } = await xmlToIr( - operatonDefs` ${listener('start')} + operatonDefs` ${listener('start')} ${listener('end')} @@ -4140,7 +4493,7 @@ describe('xmlToIr: execution listeners', () => { warnings: [ { elementId: 'Svc', - category: 'extensionAttribute', + category: 'carriedAsWritten', message: 'An operaton:executionListener on \'Svc\' has expression="": ' + 'ExpressionExecutionListener evaluates the empty text rather ' + @@ -4217,7 +4570,7 @@ describe('xmlToIr: task listeners', () => { warnings: [ { elementId: 'Review', - category: 'extensionAttribute', + category: 'carriedAsWritten', message: 'An operaton:taskListener on \'Review\' has class="": ' + 'BpmnParse.parseTaskListener checks no listener attribute for ' + @@ -4234,7 +4587,7 @@ describe('xmlToIr: task listeners', () => { warnings: [ { elementId: 'Review', - category: 'extensionAttribute', + category: 'carriedAsWritten', message: 'An operaton:taskListener on \'Review\' has expression="": ' + 'ExpressionTaskListener evaluates the empty text rather than ' + @@ -4249,7 +4602,7 @@ describe('xmlToIr: task listeners', () => { warnings: [ { elementId: 'Review', - category: 'extensionAttribute', + category: 'carriedAsWritten', message: "An operaton:taskListener on 'Review' has " + 'delegateExpression="": BpmnParse.parseTaskListener checks no ' + @@ -4378,6 +4731,11 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => message, }); + const rewrote = (message: string): ImportWarning => ({ + ...warned(message), + category: 'rewritten', + }); + const dropped = (reason: string, name = "'greeting'"): ImportWarning => warned(`The injected field ${name} on 'Svc' was not imported: ${reason}.`); @@ -4406,7 +4764,7 @@ describe('xmlToIr: an injected field rides a class or a delegate binding', () => field('name="greeting"', 'hello'), SVC_INJECTED, [ - warned( + rewrote( "The injected field 'greeting' on 'Svc' writes its value in an " + 'operaton:string child, which this tool writes back as a ' + 'stringValue attribute; the engine injects the same text either way.', @@ -4568,7 +4926,7 @@ describe('xmlToIr: a mail or shell task imports with its fields on the three tag warnings: [ { elementId: 'T', - category: 'extensionAttribute', + category: 'carriedAsWritten', message: "The resultVariable 'out' on 'T' was imported as written, and " + 'the printed script draws a warning at the step: ' + @@ -4592,7 +4950,7 @@ describe('xmlToIr: a mail or shell task imports with its fields on the three tag warnings: [ { elementId: 'T', - category: 'extensionAttribute', + category: 'carriedAsWritten', message: "The shell field 'wait' spelled 'TRUE' on 'T' was imported as " + 'written, and the printed script draws an error at the field: ' + @@ -4675,7 +5033,7 @@ describe('xmlToIr: a service-like task the engine would refuse is refused with i [ 'a built-in type on the definition of a thrown message', operatonDefs` - + @@ -4971,6 +5329,9 @@ ${validation(constraints)} }); const reviewWarning = (message: RegExp) => warning('Review', message); + const carried = (message: RegExp) => + warning('Review', message, 'carriedAsWritten'); + const rewrote = (message: RegExp) => warning('Review', message, 'rewritten'); it.each([ { @@ -5021,7 +5382,7 @@ ${validation(' ')} constraints: [{ name: 'min', config: '0' }], }, warnings: [ - reviewWarning( + carried( /'min'.*imported as written.*number field.*MinValidator\.validate/, ), ], @@ -5037,9 +5398,7 @@ ${validation(' ')} constraints: [{ name: 'min', config: 'abc' }], }, warnings: [ - reviewWarning( - /'min'.*imported as written.*MinValidator\.validate.*'abc'/, - ), + carried(/'min'.*imported as written.*MinValidator\.validate.*'abc'/), ], }, { @@ -5055,9 +5414,7 @@ ${validation(' ')} values: [{ id: 'a' }, { id: 'b' }], }, warnings: [ - reviewWarning( - /'zzz'.*imported as written.*EnumFormType\.validateValue/, - ), + carried(/'zzz'.*imported as written.*EnumFormType\.validateValue/), ], }, { @@ -5065,7 +5422,7 @@ ${validation(' ')} field: ` `, imported: { id: 'amount', type: 'number', defaultValue: '1.5' }, warnings: [ - reviewWarning( + carried( /^The default '1\.5' on form field 'amount' of 'Review' was imported as written.*LongFormType\.convertValue.*Long\.valueOf/, ), ], @@ -5075,7 +5432,7 @@ ${validation(' ')} field: ` `, imported: { id: 'flag', type: 'boolean', defaultValue: 'maybe' }, warnings: [ - reviewWarning( + carried( /^The default 'maybe' on form field 'flag' of 'Review' was imported as written.*BooleanFormType\.convertValue.*Boolean\.valueOf/, ), ], @@ -5085,7 +5442,7 @@ ${validation(' ')} field: ` `, imported: { id: 'due', type: 'date', defaultValue: '2026-01-01' }, warnings: [ - reviewWarning( + carried( /^The default '2026-01-01' on form field 'due' of 'Review' was imported as written.*DateFormType.*dd\/MM\/yyyy/, ), ], @@ -5108,7 +5465,7 @@ ${validation(' ')} type: 'enum', values: [{ id: 'a', label: 'Again' }, { id: 'b' }], }, - warnings: [reviewWarning(/value 'a'.*'plan'.*LinkedHashMap/)], + warnings: [rewrote(/value 'a'.*'plan'.*LinkedHashMap/)], }, { case: 'an enum value with no id is dropped', @@ -5137,7 +5494,7 @@ ${validation(' ')} ], }, warnings: [ - reviewWarning( + rewrote( /property 'hint'.*'note'.*once, at its first position with its last value, as DefaultFormHandler.parseProperties keeps it \(LinkedHashMap.put\)/, ), ], @@ -5186,7 +5543,7 @@ ${validation(' ')} const codedErrorsDoc = (body: string): string => operatonDefs` - + ${body} @@ -5208,7 +5565,7 @@ describe('xmlToIr: external task extras', () => { const BLANK_ROOT = ' \n'; const mappingDoc = (mapping: string, roots = CODED_ROOT): string => - operatonDefs`${roots} + operatonDefs`${roots} ${externalTask('', mapping)} @@ -5266,17 +5623,25 @@ ${externalTask('', mapping)} it.each([ { - case: 'a property with no name is skipped, naming which', + case: 'a property with no name is skipped and a repeated name is kept once with its last value, each naming which', body: externalTask( '', propertiesOf(` - `), + + `), ), binding: { ...externalBinding('charge-card'), - properties: [{ key: 'k', value: 'v' }], + properties: [{ key: 'k', value: 'w' }], }, - warnings: [warning('Charge', /operaton:property #2.*no name/)], + warnings: [ + warning('Charge', /operaton:property #2.*no name/), + warning( + 'Charge', + /operaton:property 'k'.*written twice.*HashMap\.put/, + 'rewritten', + ), + ], }, { case: 'the three extras on a class-bound service task are dropped, each naming the one reader', @@ -5312,7 +5677,7 @@ ${errorMapping('errorRef="Err_Declined" expression="${true}"')}`)} { // The clean task comes first, so a misattributed warning fails. const xml = operatonDefs` - + ${extensionElements(`${propertiesOf(' ')} @@ -5576,7 +5941,7 @@ describe('xmlToIr: task kinds', () => { ' \n'; const receiveDoc = (attrs: string, roots = ''): string => - operatonDefs`${roots} + operatonDefs`${roots} @@ -5679,7 +6044,7 @@ describe('xmlToIr: task kinds', () => { const MANUAL_TASK_WARNING = { elementId: 'M1', - category: 'unmappedConstruct', + category: 'rewritten', message: "The bpmn:manualTask 'M1' imports as a plain step: token flow, " + 'waiting, listeners, async and job configuration are all unchanged, ' + @@ -5718,7 +6083,7 @@ describe('xmlToIr: task kinds', () => { expect(warnings).toEqual([ { elementId: 'M', - category: 'unmappedConstruct', + category: 'rewritten', message: "The bpmn:intermediateThrowEvent 'M' carries no event definition " + 'and imports as a plain step: BpmnParse.parseIntermediateThrowEvent ' + @@ -5726,7 +6091,7 @@ describe('xmlToIr: task kinds', () => { "leaves, as a task's behaviour does, so token flow, listeners, " + 'async and job configuration are unchanged, but history and ' + "Cockpit will report its activity type as 'task' rather than " + - "'intermediateThrowEvent'.", + "'intermediateNoneThrowEvent'.", }, ]); @@ -5822,7 +6187,7 @@ ${beside} ); expect(e.elementId).toBe(elementId); expect(e.message).toBe( - `The event construct at '${elementId}' cannot be imported: ${detail}`, + `The element '${elementId}' cannot be imported: ${detail}`, ); }); @@ -5835,6 +6200,7 @@ ${beside} 'cancel boundary event attached to it: Operaton deploys the file and ' + 'then stops with an error the first time that end is reached. Write ' + "'on Book: cancel { ... }' beside the block to catch it.", + 'carriedAsWritten', ], [ 'a cancel boundary on a block that never gives itself up warns, naming the unreachable path', @@ -5843,6 +6209,7 @@ ${beside} "The cancel boundary event on 'Book' was imported, but nothing " + 'inside the block gives it up, so what follows the boundary can ' + 'never run.', + 'carriedAsWritten', ], [ 'a method on the block is dropped, since Operaton never reads it there', @@ -5851,6 +6218,7 @@ ${beside} "The 'method' attribute on 'Book' was not imported: Operaton reads it " + 'on a not at all, so the imported block runs ' + 'exactly as the source document does.', + 'unmappedConstruct', ], [ 'triggeredByEvent on the block is dropped, since Operaton runs it as an ordinary step', @@ -5860,22 +6228,21 @@ ${beside} 'Operaton ignores it on a and runs the block as ' + 'an ordinary step of the surrounding flow, so the imported block ' + 'runs exactly as the source document does.', + 'unmappedConstruct', ], - ])('%s', async (_title, xml, elementId, message) => { + ])('%s', async (_title, xml, elementId, message, category) => { const { ir, warnings } = await xmlToIr(xml); const book = subProcess(ir, 'Book'); expect(book.element).toBe('transaction'); expect(book).not.toHaveProperty('method'); expect(book).not.toHaveProperty('triggeredByEvent'); - expect(warnings).toEqual([ - { elementId, category: 'unmappedConstruct', message }, - ]); + expect(warnings).toEqual([{ elementId, category, message }]); }); }); describe('xmlToIr: an either-branch split', () => { const xorDoc = (gateway: string, routes: string): string => - bpmnDefs` + bpmnDefs` ${gateway} @@ -5942,7 +6309,7 @@ describe('xmlToIr: a fallback route named on a step', () => { `${body}`; const stepDoc = (element: string): string => - bpmnDefs` + bpmnDefs` ${element} @@ -6055,7 +6422,7 @@ describe('xmlToIr: a wait with several branches', () => { extraFlows = '', defs = bpmnDefs, }: WaitOptions = {}): string => - defs`${roots} + defs`${roots} { const candidateUsersWarning = (id: string, users: string) => ({ elementId: 'Review', - category: 'unmappedConstruct' as const, + category: 'rewritten' as const, message: `The bpmn:potentialOwner '${id}' on 'Review' imports as ` + `candidateUsers: "${users}": Operaton reads its formal expression ` + diff --git a/tests/boundary-events.round-trip.test.ts b/tests/boundary-events.round-trip.test.ts index d4c52ef..04595a3 100644 --- a/tests/boundary-events.round-trip.test.ts +++ b/tests/boundary-events.round-trip.test.ts @@ -98,7 +98,7 @@ const IMPORT_FIRST_BPMN = ` - + Flow_CrateArrived_InspectCrate diff --git a/tests/call-activity.round-trip.test.ts b/tests/call-activity.round-trip.test.ts index 2703cb9..c765477 100644 --- a/tests/call-activity.round-trip.test.ts +++ b/tests/call-activity.round-trip.test.ts @@ -298,7 +298,7 @@ const HANDWRITTEN_BPMN = ` id="Definitions_1" targetNamespace="http://bpmn.io/schema/bpmn"> - + Flow_Start_ReviewApprovalCall diff --git a/tests/compensation.round-trip.test.ts b/tests/compensation.round-trip.test.ts index 5cf5aed..b12b447 100644 --- a/tests/compensation.round-trip.test.ts +++ b/tests/compensation.round-trip.test.ts @@ -26,7 +26,7 @@ function startEventOpenTag(xml: string, id: string): string | undefined { // `waitForCompletion="true"` is the moddle default, so import drops it. const IMPORT_FIRST_BPMN = ` - + Flow_Begin_Pick diff --git a/tests/e2e/forms-and-external-tasks.test.ts b/tests/e2e/forms-and-external-tasks.test.ts index ad8a7fd..93fb746 100644 --- a/tests/e2e/forms-and-external-tasks.test.ts +++ b/tests/e2e/forms-and-external-tasks.test.ts @@ -40,7 +40,7 @@ const ASSIGNMENT_BPMN = ` - + @@ -177,8 +177,8 @@ describe.skipIf(SKIP)('E2E: forms and external tasks on Operaton', () => { candidateGroups: 'managers,finance', }); expect(warnings.map((w) => [w.category, w.elementId])).toEqual([ - ['unmappedConstruct', 'Review'], - ['unmappedConstruct', 'Review'], + ['rewritten', 'Review'], + ['rewritten', 'Review'], ]); const xmlPath = resolve(__dirname, `../../out/${ASSIGNMENT_KEY}.bpmn`); diff --git a/tests/engine-attributes.round-trip.test.ts b/tests/engine-attributes.round-trip.test.ts index 97182b7..bbf40ec 100644 --- a/tests/engine-attributes.round-trip.test.ts +++ b/tests/engine-attributes.round-trip.test.ts @@ -19,6 +19,7 @@ const PROCESS_HEADER = { historyTimeToLive: 'P90D', candidateStarterUsers: 'demo,manager', candidateStarterGroups: 'adjusters', + isStartableInTasklist: false, }; const ENGINE_ATTRIBUTE_CONTRACT: readonly (readonly [ @@ -81,6 +82,7 @@ it('keeps every authored setting, the header settings and the async await at eve historyTimeToLive, candidateStarterUsers, candidateStarterGroups, + isStartableInTasklist, } = ir; expect( { @@ -88,6 +90,7 @@ it('keeps every authored setting, the header settings and the async await at eve historyTimeToLive, candidateStarterUsers, candidateStarterGroups, + isStartableInTasklist, }, label, ).toEqual(PROCESS_HEADER); diff --git a/tests/event-handlers.round-trip.test.ts b/tests/event-handlers.round-trip.test.ts index 4ba62f5..113f8e8 100644 --- a/tests/event-handlers.round-trip.test.ts +++ b/tests/event-handlers.round-trip.test.ts @@ -23,7 +23,7 @@ const rt = roundTripFixture('event-handlers', { const IMPORT_FIRST_BPMN = ` - + Flow_Begin_DoWork diff --git a/tests/event-triggers.round-trip.test.ts b/tests/event-triggers.round-trip.test.ts index 2cc26d5..e49fd10 100644 --- a/tests/event-triggers.round-trip.test.ts +++ b/tests/event-triggers.round-trip.test.ts @@ -28,7 +28,7 @@ function timerExpressions(container: FlowContainer): string[] { const IMPORT_FIRST_BPMN = ` - + Flow_Begin_Dispatch diff --git a/tests/fixtures/invoice-receipt-no-fallback.bpmn b/tests/fixtures/invoice-receipt-no-fallback.bpmn new file mode 100644 index 0000000..742bd84 --- /dev/null +++ b/tests/fixtures/invoice-receipt-no-fallback.bpmn @@ -0,0 +1,277 @@ + + + + + + + + + + + + + + + prepareBankTransfer + invoiceProcessed + ServiceTask_1 + + + assignApprover + StartEvent_1 + reviewSuccessful_gw + invoiceNotProcessed + reviewInvoice + + + approveInvoice + invoice_approved + ServiceTask_06mdb3v + + + + + Approve the invoice (or not). + + + if(!!task.getVariable('approver')) { + task.setAssignee(approver); +} + + + task.setVariable('approver', task.getAssignee()); + + + sequenceFlow_178 + reviewSuccessful + sequenceFlow_180 + + + sequenceFlow_180 + invoiceNotApproved + invoiceApproved + + + SequenceFlow_1 + sequenceFlow_178 + + + SequenceFlow_1 + + + sequenceFlow_183 + reviewNotSuccessful + reviewSuccessful + + + Prepare the bank transfer. + SequenceFlow_1pcdtz2 + SequenceFlow_2 + + + DataStoreReference_1 + Property_16iplb1 + + + + SequenceFlow_3 + + + SequenceFlow_2 + SequenceFlow_3 + + + reviewNotSuccessful + + + invoiceApproved + SequenceFlow_1pcdtz2 + + + + + + + + + + + invoiceNotApproved + sequenceFlow_183 + + + + + + ${!clarified} + + + + + ${clarified} + + + ${approved} + + + + + ${!approved} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fuzz-regressions.test.ts b/tests/fuzz-regressions.test.ts index 895b413..ab13e75 100644 --- a/tests/fuzz-regressions.test.ts +++ b/tests/fuzz-regressions.test.ts @@ -12,8 +12,8 @@ import { compareModels, modelSignature } from './helpers/model-equivalence.js'; import type { ModelComparison } from './helpers/model-equivalence.js'; // Shapes whose print depends on the model's element and flow order. The third -// column is the `compareModels` class of the reversed-order reread ('same' if -// absent). +// column is the `compareModels` class of the reread against the reversed model +// ('same' if absent). const ORDER_ROWS: [ title: string, source: string, @@ -389,7 +389,7 @@ const ORDER_ROWS: [ } goto Fin }`, - 'restructured', + 'canonical', ], [ 'a triggered start whose chain ends in its own authored end prints ahead of a plain start whose chain runs on to the implicit one', @@ -766,7 +766,6 @@ const ORDER_ROWS: [ goto B } }`, - 'restructured', ], [ 'a do-while around a while that gotos enter from before and after keeps both loops', @@ -849,7 +848,6 @@ const ORDER_ROWS: [ } while (y) } }`, - 'restructured', ], [ 'a nested if branch that only a later goto jumps back to prints inside its if, not after the implicit end', @@ -918,7 +916,6 @@ const ORDER_ROWS: [ goto B } }`, - 'restructured', ], [ 'an if around a do-while keeps its own merge when a goto after the loop inside it has one too', @@ -940,7 +937,6 @@ const ORDER_ROWS: [ } } }`, - 'restructured', ], [ 'a goto from a nested if into a while head after the if keeps the loop when a later goto enters the inner while', @@ -2344,11 +2340,13 @@ function reversed(c: C): C { it.each(ORDER_ROWS)( '%s, in model order and reversed', async (_title, source, comparison = 'same') => { - const { ir1, ir2 } = await expectStableRoundTrip(source); - const dsl = printDsl(reversed(ir2)); + // Reversing swaps the order an exclusive split tries its conditions in, + // so the reread is compared with the reversed model, not the source. + const model = reversed((await expectStableRoundTrip(source)).ir2); + const dsl = printDsl(model); expect(await validationErrors(dsl)).toEqual([]); const { ir1: reread, dsl: next } = await roundTrip(dsl); - expect(compareModels(ir1, reread)).toBe(comparison); + expect(compareModels(model, reread)).toBe(comparison); expect(next).toBe(dsl); }, ); @@ -2886,7 +2884,7 @@ it('an implicit start and a conditional start entering the same step keep both s `); expect(await validationErrors(dsl)).toEqual([]); const { ir1: reread, dsl: next } = await roundTrip(dsl); - expect(compareModels(ir, reread)).toBe('restructured'); + expect(compareModels(ir, reread)).toBe('same'); expect(next).toBe(dsl); }); @@ -2930,7 +2928,7 @@ it('a start entering a step reached only through a printed link hop keeps the fl `); expect(await validationErrors(dsl)).toEqual([]); const { ir1: reread, dsl: dsl2 } = await roundTrip(dsl); - expect(compareModels(ir, reread)).toBe('restructured'); + expect(compareModels(ir, reread)).toBe('same'); // The merge is compiled away, so `Ca` reaches the elided end on its own; the // end must still count as displaced, or it prints under a refused name. expect(await validationErrors(dsl2)).toEqual([]); @@ -2947,7 +2945,7 @@ it('a start entering a step reached only through a printed link hop keeps the fl } `); const { ir1: rereadAgain, dsl: dsl3 } = await roundTrip(dsl2); - expect(compareModels(ir, rereadAgain)).toBe('restructured'); + expect(compareModels(ir, rereadAgain)).toBe('same'); expect(dsl3).toBe(dsl2); }); diff --git a/tests/fuzz.test.ts b/tests/fuzz.test.ts index 31925e1..fabc33c 100644 --- a/tests/fuzz.test.ts +++ b/tests/fuzz.test.ts @@ -1,7 +1,7 @@ // Round-trip fuzz: per seed, generate a program, then parse -> astToIr -> irToXml // -> xmlToIr -> irToDsl -> revalidate -> astToIr, compare, and print a second time. // A valid seed fails on a FAIL_CLASSES class, any print warning, or an unstable -// second print. +// second print; the run fails on any import warning but the uncaught-cancel one. // // FUZZ_N (default 200), FUZZ_SEED (default 1), FUZZ_OUT (writes results.jsonl, // summary.json and minimized programs to min/), FUZZ_SHUFFLE (`reverse` or a @@ -265,6 +265,14 @@ function short(x: unknown): string { // Blanks process names and numbers so one symptom is one key across seeds. const PROCESS_NAME_PATTERN = new RegExp(PROCESS_NAMES.join('|'), 'g'); +/** + * The generator keeps a cancel end in an attempt block nothing catches, a shape + * that deploys but fails the run when the end is reached, so the importer + * warns about it and nothing else. + */ +const UNCAUGHT_CANCEL_WARNING = + /^The block '[^']+' holds an end event that gives it up, with no cancel boundary event attached to it: /; + function normalizeMessage(m: string): string { return m .replace(PROCESS_NAME_PATTERN, '') @@ -885,6 +893,11 @@ describe('round-trip fuzz', () => { ); expect(rows.length).toBe(N); expect(failingSeeds).toEqual([]); + expect( + Object.keys(importWarningMessages).filter( + (m) => !UNCAUGHT_CANCEL_WARNING.test(m), + ), + ).toEqual([]); }, ); }); diff --git a/tests/golden/README.md b/tests/golden/README.md index 05e85bf..da7db33 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -147,7 +147,7 @@ Contract: the four event definitions, their order, and the absence of any `name` ## `engine-attributes.{bpmnscript,bpmn}` A motor-claim settlement narrative carrying the flat engine settings, the ones whose value is a single scalar. -`versionTag`, `historyTimeToLive`, `candidateStarterUsers`, and `candidateStarterGroups` sit on the process header, `initiator` on the start, and `asyncBefore`, `asyncAfter`, `exclusive`, `jobPriority`, and `retryCycle` are spread across a start, an end, a user task, a service task, a script task, a subprocess, a call, an `await`, an `emit`, and both handler forms. +`versionTag`, `historyTimeToLive`, `candidateStarterUsers`, `candidateStarterGroups`, and `isStartableInTasklist` sit on the process header, `initiator` on the start, and `asyncBefore`, `asyncAfter`, `exclusive`, `jobPriority`, and `retryCycle` are spread across a start, an end, a user task, a service task, a script task, a subprocess, a call, an `await`, an `emit`, and both handler forms. Five of the seven keys a user task owns (`assignee`, `formKey`, `candidateGroups`, `candidateUsers`, `priority`) sit together on one task, and `resultVariable` on both a service and a script task. `TriageClaim` names its form with `formKey` and `ApprovePayout` with a `formRef` pinned to a version, so both ways of naming a form sit in one artifact on separate tasks, which is the only way they can appear together: Operaton refuses to deploy a user task carrying both. The header's `historyTimeToLive` is `P90D` rather than the `P30D` the exporter stamps on a process that wrote none, because the importer reads that value back as unwritten and the fixture would otherwise pin nothing. @@ -160,7 +160,7 @@ A host-less `on timer` is the one exception: its `jobPriority`, `retryCycle` and A `while`, an `if`, and a `parallel` put five synthesized gateways in the same artifact, none of which is written with a setting, so the pair pins that no direction invents one on a gateway; what a gateway written with settings carries is the `gateway-settings` pair's to pin. Every named node carries an explicit id, and the frozen artifact imports without a single warning. -Contract: the `(node id, attribute, value)` table in `tests/engine-attributes.round-trip.test.ts`, the four engine settings on the header (`3.1.0`, `P90D`, `demo,manager`, `adjusters`) asserted as one record, the three `operaton:formRef*` attributes on `ApprovePayout`, and the absence of any engine setting on the gateways, which are written without one, and on the event sub-process's trigger start event. +Contract: the `(node id, attribute, value)` table in `tests/engine-attributes.round-trip.test.ts`, the five engine settings on the header (`3.1.0`, `P90D`, `demo,manager`, `adjusters`, `false`) asserted as one record, the three `operaton:formRef*` attributes on `ApprovePayout`, and the absence of any engine setting on the gateways, which are written without one, and on the event sub-process's trigger start event. ## `input-output.{bpmnscript,bpmn}` diff --git a/tests/golden/engine-attributes.bpmn b/tests/golden/engine-attributes.bpmn index 4a8048e..addaa51 100644 --- a/tests/golden/engine-attributes.bpmn +++ b/tests/golden/engine-attributes.bpmn @@ -1,6 +1,6 @@ - + diff --git a/tests/golden/engine-attributes.bpmnscript b/tests/golden/engine-attributes.bpmnscript index 3a46d99..d9e69a8 100644 --- a/tests/golden/engine-attributes.bpmnscript +++ b/tests/golden/engine-attributes.bpmnscript @@ -3,7 +3,8 @@ process claim-settlement( versionTag: "3.1.0", historyTimeToLive: "P90D", candidateStarterUsers: "demo,manager", - candidateStarterGroups: "adjusters" + candidateStarterGroups: "adjusters", + isStartableInTasklist: false ) { escalation PAYOUT_REVIEW diff --git a/tests/goto-fallback.round-trip.test.ts b/tests/goto-fallback.round-trip.test.ts index 1659481..baf3eb5 100644 --- a/tests/goto-fallback.round-trip.test.ts +++ b/tests/goto-fallback.round-trip.test.ts @@ -114,10 +114,10 @@ describe('an edge with no statement form degrades without inventing a target, an ' var done: any', ' var more: any', ' start Start_1', - ' if (done) {', - ' } else {', + ' if (true) {', ' user Body_1', ` ${UNSTRUCTURED_MARKER} (dropped edge into Gateway_L_loop)`, + ' } else if (done) {', ' }', ' end End_1', '}', diff --git a/tests/helpers/bpmn-doc.ts b/tests/helpers/bpmn-doc.ts index 150a61c..17137a5 100644 --- a/tests/helpers/bpmn-doc.ts +++ b/tests/helpers/bpmn-doc.ts @@ -5,7 +5,7 @@ export function bpmnDoc(body: string, roots = ''): string { '\n' + roots + - ` \n${body}\n \n` + + ` \n${body}\n \n` + '' ); } diff --git a/tests/helpers/import-first.ts b/tests/helpers/import-first.ts index a027bd5..8dce9c4 100644 --- a/tests/helpers/import-first.ts +++ b/tests/helpers/import-first.ts @@ -12,7 +12,7 @@ import { parseToAst, printDsl } from './pipeline.js'; export const camundaAliasWarning = (elementId: string): ImportWarning => ({ elementId, - category: 'unmappedConstruct', + category: 'rewritten', message: 'The file declares the camunda namespace; it was read as the operaton ' + 'namespace, since `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` falls back ' + diff --git a/tests/helpers/model-equivalence.test.ts b/tests/helpers/model-equivalence.test.ts index b65cb8c..a8f5a43 100644 --- a/tests/helpers/model-equivalence.test.ts +++ b/tests/helpers/model-equivalence.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { astToIr } from '@bpmn-script/transform'; +import type { BpmnProcess, FlowElement } from '@bpmn-script/transform'; import { compareModels, type ModelComparison } from './model-equivalence.js'; import { parseToAst } from './pipeline.js'; @@ -156,7 +157,7 @@ const table: [title: string, a: string, b: string, want: ModelComparison][] = [ 'restructured', ], [ - 'a do-while printed as an if with a backward goto is restructured', + 'a do-while printed as an if with a backward goto is the same model', `process p { var x: any var y: any @@ -189,7 +190,7 @@ const table: [title: string, a: string, b: string, want: ModelComparison][] = [ goto Call } }`, - 'restructured', + 'same', ], [ 'swapped if and else bodies are changed', @@ -244,8 +245,176 @@ const table: [title: string, a: string, b: string, want: ModelComparison][] = [ ], ]; +// Shapes the compiler never emits, as a modeler's document can: node kinds by +// id, and flows as `[id: ]source -> target[ ? condition]`. +const model = ( + kinds: Record, + flows: string[], +): BpmnProcess => ({ + id: 'p', + isExecutable: true, + flowElements: Object.entries(kinds).map( + ([id, kind]) => ({ kind, id }) as FlowElement, + ), + sequenceFlows: flows.map((flow, i) => { + const [, id = `Flow_${i}`, sourceRef, targetRef, condition] = + /^(?:(\S+): )?(\S+) -> (\S+)(?: \? (.+))?$/.exec(flow)!; + return { + id, + sourceRef, + targetRef, + ...(condition === undefined ? {} : { conditionExpression: condition }), + }; + }), +}); + +// A parallel fork into A, B and C whose branches meet at `join`, A and B first +// through an exclusive merge when `merged`. +const forked = (join: string, merged = false): BpmnProcess => + model( + { + S: 'startEvent', + F: 'parallelGateway', + A: 'userTask', + B: 'userTask', + C: 'userTask', + ...(merged ? { M: 'exclusiveGateway' } : {}), + J: join, + E: 'endEvent', + }, + [ + 'S -> F', + 'F -> A', + 'F -> B', + 'F -> C', + ...(merged ? ['A -> M', 'B -> M', 'M -> J'] : ['A -> J', 'B -> J']), + 'C -> J', + 'J -> E', + ], + ); +const chainedJoins = (end: string): BpmnProcess => + model( + { + T: 'userTask', + Gateway_a_join: 'exclusiveGateway', + Gateway_b_join: 'exclusiveGateway', + E1: 'endEvent', + E2: 'endEvent', + }, + [ + 'T -> Gateway_a_join', + 'Gateway_a_join -> Gateway_b_join', + `Gateway_b_join -> ${end}`, + ], + ); +const split = (routes: string[]): BpmnProcess => + model( + { T: 'userTask', G: 'exclusiveGateway', A: 'userTask', B: 'userTask' }, + ['T -> G', ...routes], + ); +const documented = (ir: BpmnProcess, id: string): BpmnProcess => ({ + ...ir, + flowElements: ir.flowElements.map((el) => + el.id === id ? { ...el, documentation: 'Why we merge' } : el, + ), +}); +const nestedSplits = ( + [outer, inner]: [string, string], + merge?: string, + flowId = (_n: number): string => '', +): BpmnProcess => + model( + { + T: 'userTask', + A: 'userTask', + B: 'userTask', + C: 'userTask', + D: 'userTask', + [outer]: 'exclusiveGateway', + [inner]: 'exclusiveGateway', + ...(merge === undefined ? {} : { [merge]: 'exclusiveGateway' }), + }, + [ + `T -> ${outer}`, + `${outer} -> A ? \${a}`, + `${outer} -> ${inner} ? \${b}`, + `${inner} -> B ? \${c}`, + `${inner} -> C ? \${d}`, + ...(merge === undefined + ? ['A -> D', 'B -> D', 'C -> D'] + : [`A -> ${merge}`, `B -> ${merge}`, `C -> ${merge}`, `${merge} -> D`]), + ].map((flow, n) => flowId(n) + flow), + ); + +const builtTable: [ + title: string, + a: BpmnProcess, + b: BpmnProcess, + want: ModelComparison, +][] = [ + [ + 'a parallel join and an exclusive join over the same branches are changed', + forked('parallelGateway'), + forked('exclusiveGateway'), + 'changed', + ], + [ + 'an exclusive merge dropped in front of a parallel join is changed', + forked('parallelGateway', true), + forked('parallelGateway'), + 'changed', + ], + [ + 'two chained joins leading to different ends are changed', + chainedJoins('E1'), + chainedJoins('E2'), + 'changed', + ], + [ + 'swapped overlapping conditions at an exclusive split are changed', + split(['G -> A ? ${a}', 'G -> B ? ${a}']), + split(['G -> B ? ${a}', 'G -> A ? ${a}']), + 'changed', + ], + [ + 'a route with no condition swapped with a conditioned one at an exclusive split is changed', + split(['G -> A', 'G -> B ? ${a}']), + split(['G -> B ? ${a}', 'G -> A']), + 'changed', + ], + [ + 'an event-based gateway with one route out is not a transparent merge', + model( + { T: 'userTask', R: 'eventBasedGateway', W: 'intermediateCatchEvent' }, + ['T -> R', 'R -> W'], + ), + model({ T: 'userTask', W: 'intermediateCatchEvent' }, ['T -> W']), + 'changed', + ], + [ + "a modeler's own flow and gateway ids and an extra merge are the same model", + nestedSplits(['Gateway_p_0_split', 'Gateway_p_1_split']), + nestedSplits( + ['Gateway_0x', 'Gateway_1y'], + 'Gateway_2z', + (n) => `SequenceFlow_${n}: `, + ), + 'same', + ], + [ + 'documentation on an otherwise transparent merge is changed', + nestedSplits(['G1', 'G2'], 'M'), + documented(nestedSplits(['G1', 'G2'], 'M'), 'M'), + 'changed', + ], +]; + describe('compareModels', () => { it.each(table)('%s', async (_title, a, b, want) => { expect(compareModels(await compile(a), await compile(b))).toBe(want); }); + + it.each(builtTable)('%s', (_title, a, b, want) => { + expect(compareModels(a, b)).toBe(want); + }); }); diff --git a/tests/helpers/model-equivalence.ts b/tests/helpers/model-equivalence.ts index 3ac291b..52b6ad0 100644 --- a/tests/helpers/model-equivalence.ts +++ b/tests/helpers/model-equivalence.ts @@ -102,8 +102,7 @@ function canonicalizeCoordinateIds(container: BpmnProcess): BpmnProcess { } // Gateways are renamed after the events, by kind and renamed neighbours; the - // second round gives gateway neighbours their first-round names. The role - // suffix survives so `normalizeIr` still inlines a pass-through join. + // second round gives gateway neighbours their first-round names. const byId = new Map(elements.map((e) => [e.id, e])); for (let round = 0; round < 2; round++) { const previous = new Map(rename); @@ -190,11 +189,14 @@ function containerSignature(container: BpmnProcess): string[] { // A settings-free exclusive gateway is pure routing that a hoist or a loop // printed as a `goto` adds or removes; its flow conditions still label the // route. Any other gateway changes how the route runs. + // A join synchronizes its incoming flows, so their number is part of it. const gatewayMark = (el: FlowElement): string | undefined => { const { name: _name, ...settings } = ownContent(el); - return el.kind === 'exclusiveGateway' && Object.keys(settings).length === 1 - ? undefined - : stable(settings); + if (el.kind === 'exclusiveGateway' && Object.keys(settings).length === 1) { + return undefined; + } + const joins = container.sequenceFlows.filter((f) => f.targetRef === el.id); + return stable({ ...settings, joins: joins.length }); }; const nodes = container.flowElements.filter((el) => !isGateway(el)); @@ -205,10 +207,18 @@ function containerSignature(container: BpmnProcess): string[] { // the chains the compiler emits, memoize per gateway if that changes. const walk = (at: string, labels: string[], onPath: Set): void => { const source = byId.get(at); + // An exclusive gateway tries its non-default flows in document order. + const ranked = + source?.kind === 'exclusiveGateway' + ? (outgoing.get(at) ?? []).filter( + (f) => f.id !== source.defaultFlowId, + ) + : []; for (const f of outgoing.get(at) ?? []) { const route = [...labels]; + const rank = ranked.includes(f) ? `#${ranked.indexOf(f)} ` : ''; if (f.conditionExpression !== undefined) { - route.push(`if ${f.conditionExpression}`); + route.push(`if ${rank}${f.conditionExpression}`); } else if ( source !== undefined && 'defaultFlowId' in source && diff --git a/tests/helpers/normalize-ir.ts b/tests/helpers/normalize-ir.ts index 926a652..299b1fe 100644 --- a/tests/helpers/normalize-ir.ts +++ b/tests/helpers/normalize-ir.ts @@ -1,7 +1,7 @@ // Canonicalizes authored vs synthesized ids before `toEqual`: gateways, -// boundaries and event sub-processes are re-keyed by structure; task and event -// ids must survive verbatim. Flows never cross a sub-process, so this runs per -// container. +// boundaries, event sub-processes and flows are re-keyed by structure; task and +// event ids must survive verbatim. Flows never cross a sub-process, so this +// runs per container. import { ENGINE_KEYS } from '@bpmn-script/language'; import { @@ -13,8 +13,6 @@ import { type SequenceFlow, } from '@bpmn-script/transform'; -const SYNTHESIZED_JOIN_ID = /^Gateway_.+_join$/; - export function normalizeIr(ir: BpmnProcess): BpmnProcess { return normalizeContainer(ir); } @@ -52,10 +50,24 @@ function normalizeContainer(container: T): T { // The structured syntax has no slot for a gateway label. const { name: _name, ...withoutName } = fe; - return { ...withoutName, id, ...reKeyedDefault(fe) }; + return { ...withoutName, id, ...reKeyedDefault(fe), ...routeOrder(fe) }; }) .sort((a, b) => a.id.localeCompare(b.id)); + // Operaton takes the first non-default flow of an exclusive gateway that + // carries no condition or one that holds, in document order, so that order + // is compared. + function routeOrder(fe: FlowElement): { routeOrder?: string[] } { + if (fe.kind !== 'exclusiveGateway') return {}; + const routes = inlined.sequenceFlows + .filter((sf) => sf.sourceRef === fe.id && sf.id !== fe.defaultFlowId) + .map( + (sf) => + `${canonicalId(sf.targetRef)} ${sf.conditionExpression ?? ''}`, + ); + return routes.length > 1 ? { routeOrder: routes } : {}; + } + function reKeyedDefault(fe: FlowElement): { defaultFlowId?: string } { const declared = 'defaultFlowId' in fe ? fe.defaultFlowId : undefined; if (declared === undefined) return {}; @@ -68,7 +80,13 @@ function normalizeContainer(container: T): T { const sequenceFlows: SequenceFlow[] = inlined.sequenceFlows .map((sf) => normalizeFlow(sf, canonicalId)) - .sort((a, b) => a.id.localeCompare(b.id)); + .sort( + (a, b) => + a.id.localeCompare(b.id) || + (a.conditionExpression ?? '').localeCompare( + b.conditionExpression ?? '', + ), + ); return { ...container, @@ -77,33 +95,66 @@ function normalizeContainer(container: T): T { } as T; } -// A re-synthesized `if/else` grows a join the authored IR never had, so such a -// join is transparent. One carrying a job setting stays, or a dropped `join*` -// key would vanish from both sides of the comparison. +// A settings-free exclusive gateway with one outgoing flow merges without +// synchronizing, as an activity with several incoming flows does, so it is +// transparent unless the node it leads to is a parallel or inclusive join. +// A parallel or inclusive gateway is transparent only with one incoming flow, +// and an event-based one never is, since it waits for its event. One +// carrying a job setting stays, or a dropped `join*` key would vanish from +// both sides of the comparison. function inlinePassThroughJoins(ir: FlowContainer): FlowContainer { + const byId = new Map(ir.flowElements.map((fe) => [fe.id, fe])); const successorOf = new Map(); for (const fe of ir.flowElements) { - if (!isGateway(fe)) continue; - if (!SYNTHESIZED_JOIN_ID.test(fe.id)) continue; - if (ENGINE_KEYS.some((key) => key in fe)) continue; - + if (!isGateway(fe) || fe.kind === 'eventBasedGateway') continue; + if (ENGINE_KEYS.some((key) => key in fe) || fe.documentation !== undefined) + continue; const outgoing = ir.sequenceFlows.filter((sf) => sf.sourceRef === fe.id); const incoming = ir.sequenceFlows.filter((sf) => sf.targetRef === fe.id); - if (outgoing.length === 1 && incoming.length >= 1) { + if (outgoing.length !== 1 || incoming.length === 0) continue; + if (incoming.length === 1 || fe.kind === 'exclusiveGateway') { successorOf.set(fe.id, outgoing[0].targetRef); } } + // A chain of joins resolves to the first node that stays; a gateway-only + // cycle is kept whole. + const resolve = (id: string): string | undefined => { + const seen = new Set(); + let at = id; + while (successorOf.has(at)) { + if (seen.has(at)) return undefined; + seen.add(at); + at = successorOf.get(at)!; + } + return at; + }; + // Keeping a merge only shortens other chains, so the loop reaches the same + // fixpoint whatever the element order. + for (let kept = true; kept;) { + kept = false; + for (const [id] of successorOf) { + const end = resolve(id); + const kind = end === undefined ? undefined : byId.get(end)?.kind; + const merges = ir.sequenceFlows.filter((sf) => sf.targetRef === id); + const synchronizes = + kind === 'parallelGateway' || kind === 'inclusiveGateway'; + if (end === undefined || (merges.length > 1 && synchronizes)) { + successorOf.delete(id); + kept = true; + } + } + } if (successorOf.size === 0) return ir; const flowElements = ir.flowElements.filter((fe) => !successorOf.has(fe.id)); - const sequenceFlows = ir.sequenceFlows .filter((sf) => !successorOf.has(sf.sourceRef)) - .map((sf) => { - const successor = successorOf.get(sf.targetRef); - return successor !== undefined ? { ...sf, targetRef: successor } : sf; - }); + .map((sf) => + successorOf.has(sf.targetRef) + ? { ...sf, targetRef: resolve(sf.targetRef)! } + : sf, + ); return { ...ir, flowElements, sequenceFlows }; } @@ -127,20 +178,25 @@ function buildCanonicalIds( } // After join inlining, a hand-named gateway and its synthesized twin share -// their adjacency. +// their adjacency. A neighbouring gateway is named by its kind, since its own +// id differs between the two sides. function gatewaySignature( fe: FlowElement, ir: FlowContainer, ): string | undefined { if (!isGateway(fe)) return undefined; + const name = (id: string): string => { + const el = ir.flowElements.find((e) => e.id === id); + return el !== undefined && isGateway(el) ? `gateway:${el.kind}` : id; + }; const incoming = ir.sequenceFlows .filter((sf) => sf.targetRef === fe.id) - .map((sf) => sf.sourceRef) + .map((sf) => name(sf.sourceRef)) .sort(); const outgoing = ir.sequenceFlows .filter((sf) => sf.sourceRef === fe.id) - .map((sf) => sf.targetRef) + .map((sf) => name(sf.targetRef)) .sort(); return `Gateway_${fe.kind}_[in:${incoming.join(',')}]_[out:${outgoing.join(',')}]`; @@ -208,23 +264,12 @@ function definitionPayloadKey(def: EventDefinition | undefined): string { } } -// `Flow_` prefixes every generated flow id. +// The DSL has no syntax for a flow id, so every flow is keyed by its ends. function normalizeFlow( sf: SequenceFlow, canonicalId: (id: string) => string, ): SequenceFlow { const sourceRef = canonicalId(sf.sourceRef); const targetRef = canonicalId(sf.targetRef); - const touchesReKeyedNode = - sourceRef !== sf.sourceRef || targetRef !== sf.targetRef; - - if (/^Flow_/.test(sf.id) || touchesReKeyedNode) { - return { - ...sf, - id: `Flow_${sourceRef}_${targetRef}`, - sourceRef, - targetRef, - }; - } - return sf; + return { ...sf, id: `Flow_${sourceRef}_${targetRef}`, sourceRef, targetRef }; } diff --git a/tests/import-contract.round-trip.test.ts b/tests/import-contract.round-trip.test.ts index 1fb5ab8..2f1cfad 100644 --- a/tests/import-contract.round-trip.test.ts +++ b/tests/import-contract.round-trip.test.ts @@ -64,7 +64,7 @@ const dataDropped = (tag: string, id: string) => const EXPECTED_WARNINGS: ImportWarning[] = [ { elementId: 'SignOff', - category: 'unmappedConstruct', + category: 'rewritten', message: manualTaskMessage('SignOff'), }, { diff --git a/tests/invoice-receipt.import.test.ts b/tests/invoice-receipt.import.test.ts index b0214bb..695c6b1 100644 --- a/tests/invoice-receipt.import.test.ts +++ b/tests/invoice-receipt.import.test.ts @@ -13,10 +13,9 @@ import { realNodeReachability } from './helpers/real-node-reachability.js'; import { parseToAst, validate } from './helpers/pipeline.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const FIXTURE = readFileSync( - resolve(__dirname, 'fixtures/invoice-receipt.bpmn'), - 'utf-8', -); +const fixture = (name: string) => + readFileSync(resolve(__dirname, 'fixtures', name), 'utf-8'); +const FIXTURE = fixture('invoice-receipt.bpmn'); const PRINTED = [ 'process invoice(label: "Invoice Receipt", versionTag: "V2.0", historyTimeToLive: "45") {', @@ -68,7 +67,7 @@ describe('the Operaton invoice example', () => { { elementId: 'prepareBankTransfer', category: 'unmappedConstruct' }, { elementId: 'prepareBankTransfer', category: 'unmappedConstruct' }, { elementId: 'StartEvent_1', category: 'extensionAttribute' }, - { elementId: 'ServiceTask_1', category: 'unmappedConstruct' }, + { elementId: 'ServiceTask_1', category: 'rewritten' }, { elementId: 'reviewSuccessful', category: 'label' }, { elementId: 'reviewNotSuccessful', category: 'label' }, { elementId: 'invoiceApproved', category: 'label' }, @@ -101,4 +100,22 @@ describe('the Operaton invoice example', () => { ); expect((await xmlToIr(await irToXml(reDesugared))).warnings).toEqual([]); }); + + it.each([ + ['the loop-test version', 'invoice-receipt.bpmn'], + [ + 'the version whose approval split names no fallback', + 'invoice-receipt-no-fallback.bpmn', + ], + ])( + '%s prints a script that builds without warnings and prints the same script again from the document it builds', + async (_, name) => { + const print = async (xml: string) => + irToDsl((await xmlToIr(xml)).ir).source; + const first = await print(fixture(name)); + expect((await validate(first)).diagnostics).toEqual([]); + const rebuilt = await irToXml(astToIr(await parseToAst(first))); + expect(await print(rebuilt)).toEqual(first); + }, + ); }); diff --git a/tests/round-trip-constructs.test.ts b/tests/round-trip-constructs.test.ts index 1eb7205..fb6d4c4 100644 --- a/tests/round-trip-constructs.test.ts +++ b/tests/round-trip-constructs.test.ts @@ -153,9 +153,16 @@ it('goto degradation keeps every authored node, edge and condition over two roun expect(realIds(irReDesugared)).toEqual(realIds(irImport)); }); -// The XML writer numeric-escapes `"` and line feeds in attribute values. +// Reads attribute values as a conforming XML parser does: a raw tab, carriage +// return or line feed becomes a space (XML 1.0, 3.3.3), a character reference +// keeps its character. function decodeXmlEntities(xml: string): string { - return xml.replace(/"/g, '"').replace(/ /g, '\n'); + return xml + .replace(/<[^>]*>/g, (tag) => tag.replace(/[\t\r\n]/g, ' ')) + .replace(/"/g, '"') + .replace(/ /g, '\n') + .replace(/ /g, '\t') + .replace(/ /g, '\r'); } describe('two-pass round trip: printed expression text stays engine-runnable and stable', () => { @@ -292,24 +299,24 @@ describe('two-pass round trip: printed expression text stays engine-runnable and [], ], [ - // No tab case: moddle-xml leaves a tab in an attribute unescaped, so a strict parser reads it as a space. - 'a real line feed in an io value, a map key or a field value prints as its two-character escape', + 'a real line feed, tab or carriage return in an io value, a map key, a field value or a label reaches the engine and prints as its two-character escape', [ 'process p {', - ' service A(class: "x") {', + ' service A(class: "x", label: "a\\tb\\rc") {', ' input m = { "a\\nb": 1 }', ' input s = "l1\\nl2"', - ' field f = "f1\\nf2"', + ' field f = "f1\\nf2\\tf3\\rf4"', ' }', '}', '', ].join('\n'), [ - 'stringValue="f1\nf2"', + 'name="a\tb\rc"', + 'stringValue="f1\nf2\tf3\rf4"', 'key="a\nb"', 'l1\nl2', ], - ['"f1\\nf2"', '"l1\\nl2"'], + ['"a\\tb\\rc"', '"f1\\nf2\\tf3\\rf4"', '"l1\\nl2"'], ], [ 'a map key that is itself a template opener prints with a backslash so it re-parses as a literal key, not a raw template', From 137f57c9d987502140984d447f2536e148ee6b87 Mon Sep 17 00:00:00 2001 From: Marlon Kranz Date: Sat, 26 Sep 2026 23:01:05 +0200 Subject: [PATCH 11/11] Correct the decision records and the coverage table against the code, and add the expense-claim example --- docs/bpmn-coverage.md | 77 ++++++++++--------- .../0003-use-langium-as-language-workbench.md | 9 ++- .../0008-fork-camunda-moddle-extension.md | 2 +- .../0010-deterministic-synthesized-ids.md | 20 ++++- ...11-structured-grammar-one-bracket-shape.md | 2 +- docs/decisions/0012-honest-bpmn-import.md | 12 ++- ...on-engine-attributes-as-named-ir-fields.md | 5 ++ .../0024-event-trigger-payloads-paren-slot.md | 15 ++-- .../0025-events-at-the-process-boundary.md | 4 + ...boundary-events-attached-to-an-activity.md | 8 +- examples/README.md | 7 +- .../processes/expense-claim.bpmnscript | 38 +++++++++ .../example/expense/AutoApproveDelegate.java | 17 ++++ .../example/expense/BookPaymentDelegate.java | 17 ++++ .../example/expense/CheckPolicyDelegate.java | 25 ++++++ .../com/example/expense/NotifyDelegate.java | 17 ++++ 16 files changed, 215 insertions(+), 60 deletions(-) create mode 100644 examples/spring-boot/processes/expense-claim.bpmnscript create mode 100644 examples/spring-boot/src/main/java/com/example/expense/AutoApproveDelegate.java create mode 100644 examples/spring-boot/src/main/java/com/example/expense/BookPaymentDelegate.java create mode 100644 examples/spring-boot/src/main/java/com/example/expense/CheckPolicyDelegate.java create mode 100644 examples/spring-boot/src/main/java/com/example/expense/NotifyDelegate.java diff --git a/docs/bpmn-coverage.md b/docs/bpmn-coverage.md index eb5771f..387c992 100644 --- a/docs/bpmn-coverage.md +++ b/docs/bpmn-coverage.md @@ -2,7 +2,7 @@ Which BPMN 2.0 elements BPMNscript writes, and what the importer does with each one it meets in a `.bpmn` file. The BPMNscript column is the statement that compiles to the element, or "not authored" when the language has no form for it; the statement's keys are in [packages/language/README.md](../packages/language/README.md). -The import column says whether `xmlToIr` imports the element, drops it with a warning (the category in parentheses), or refuses the file (the error class in parentheses); the exact conditions are in the [import contract](../packages/transform/README.md#the-import-contract). +The import column says whether `xmlToIr` imports the element, imports it changed or drops it with a warning (the category in parentheses), or refuses the file (the error class in parentheses); the exact conditions are in the [import contract](../packages/transform/README.md#the-import-contract). Operaton's own extension elements (`operaton:` settings, input/output parameters, listeners, form fields) are not BPMN elements and are listed with the [IR shape](../packages/transform/README.md#ir-shape) instead. ## Activities @@ -10,9 +10,9 @@ Operaton's own extension elements (`operaton:` settings, input/output parameters | BPMN element | BPMNscript | Import | | ------------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `bpmn:task` | `step X` | imported | -| `bpmn:manualTask` | not authored | imported as `step`, warned (`unmappedConstruct`): history reports `task` where the source wrote `manualTask` | +| `bpmn:manualTask` | not authored | imported as `step`, warned (`rewritten`): history reports `task` where the source wrote `manualTask` | | `bpmn:userTask` | `user X(...)` | imported; refused on a `bpmn:humanPerformer` beside `operaton:assignee` or a second `bpmn:humanPerformer` (`UnsupportedAssignmentError`) | -| `bpmn:humanPerformer`, `bpmn:potentialOwner` on a user task | `assignee`, `candidateUsers`, `candidateGroups` keys | imported onto those keys, warned (`unmappedConstruct`) naming the rewrite | +| `bpmn:humanPerformer`, `bpmn:potentialOwner` on a user task | `assignee`, `candidateUsers`, `candidateGroups` keys | imported onto those keys, warned (`rewritten`) naming the rewrite | | `bpmn:performer`, `bpmn:resourceRole`, a role on any other activity | not authored | dropped, warned (`unmappedConstruct`) | | `bpmn:serviceTask` | `service X(...)` | imported; refused without a binding the engine reaches, with a `type` outside `external`, `mail`, and `shell`, or with an `operaton:connector` (`UnsupportedServiceTaskFormError`) | | `bpmn:sendTask` | `send X(...)` | imported, refused as a service task is | @@ -22,7 +22,7 @@ Operaton's own extension elements (`operaton:` settings, input/output parameters | `bpmn:callActivity` | `call X(process: "...")` | imported; refused on a `caseRef`, a `calledElementTenantId`, a `versionTag` binding, or an `in`/`out` mapping outside the recognized shapes (`UnsupportedCallActivityError`) | | `bpmn:subProcess` | `subprocess X { }` | imported; refused without a start event (`UnsupportedEventFeatureError`) | | `bpmn:subProcess` with `triggeredByEvent="true"` | `on { }` | imported as an event handler, see the start event rows | -| `bpmn:transaction` | `attempt X { }` | imported, a missing start event added; `method`, `protocol`, and `triggeredByEvent` dropped, warned (`unmappedConstruct`) | +| `bpmn:transaction` | `attempt X { }` | imported, a missing start event added, warned (`behaviourChanged`); `method`, `protocol`, and `triggeredByEvent` dropped, warned (`unmappedConstruct`) | | `bpmn:adHocSubProcess` | not authored | refused (`UnsupportedElementError`) | | `bpmn:multiInstanceLoopCharacteristics` | `for each x in c`, `for 3`, with `sequentially` | imported; refused on an event handler, beside an `operaton:outputParameter`, or with a `loopCardinality` body the script cannot spell (`UnsupportedLoopCharacteristicsError`) | | `bpmn:standardLoopCharacteristics` | not authored | dropped, warned (`unmappedConstruct`): Operaton runs the activity once regardless | @@ -30,34 +30,34 @@ Operaton's own extension elements (`operaton:` settings, input/output parameters ## Gateways -| BPMN element | BPMNscript | Import | -| ------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `bpmn:exclusiveGateway` | `if` / `else if` / `else`, `while`, `do ... while` | imported; refused on a shape `BpmnParse.validateExclusiveGateway` fails, such as two unconditioned routes (`UnsupportedGatewayShapeError`) | -| `bpmn:parallelGateway` | `parallel { { } { } }` | imported | -| `bpmn:inclusiveGateway` | `parallel { if (c) { } else { } }` | imported | -| `bpmn:eventBasedGateway` | `await { (...) { } (...) { } }` | imported; refused when a branch leads to anything but a catch event or the gateway carries `asyncAfter` (`UnsupportedEventFeatureError`); `instantiate` and `eventGatewayType` dropped, warned (`unmappedConstruct`) | -| `bpmn:complexGateway` | not authored | refused (`UnsupportedElementError`) | -| `name` on any gateway | not authored | imported and then left out of the script, warned on print (`label`) | -| a listener on any gateway | not authored | dropped, warned (`extensionAttribute`) | +| BPMN element | BPMNscript | Import | +| ------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bpmn:exclusiveGateway` | `if` / `else if` / `else`, `while`, `do ... while` | imported; refused on a shape `BpmnParse.validateExclusiveGateway` fails, such as two unconditioned routes (`UnsupportedGatewayShapeError`); a route with no condition listed before a conditioned one prints in its place as an `if (true)` head | +| `bpmn:parallelGateway` | `parallel { { } { } }` | imported | +| `bpmn:inclusiveGateway` | `parallel { if (c) { } else { } }` | imported | +| `bpmn:eventBasedGateway` | `await { (...) { } (...) { } }` | imported; refused when a branch leads to anything but a catch event or the gateway carries `asyncAfter` (`UnsupportedEventFeatureError`), or without an id (`UnsupportedDocumentError`); `instantiate` and `eventGatewayType` dropped, warned (`unmappedConstruct`) | +| `bpmn:complexGateway` | not authored | refused (`UnsupportedElementError`) | +| `name` on any gateway | not authored | imported and then left out of the script, warned on print (`label`) | +| a listener on any gateway | not authored | dropped, warned (`extensionAttribute`) | ## Events ### Start events -| BPMN element | BPMNscript | Import | -| -------------------------------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `bpmn:startEvent` with no definition | `start X` | imported; a second one on the process refused (`UnsupportedEventFeatureError`) | -| message start | `start X message("Name")` | imported; refused when the name is an expression (`UnsupportedEventFeatureError`) | -| signal start | `start X signal("Name")` | imported | -| timer start | `start X timer("PT1H")`, `at:`, `every:` | imported | -| conditional start | `start X condition(expr)` | imported | -| error, escalation, or compensation start on the process | not authored | refused, since Operaton ignores the trigger (`UnsupportedEventFeatureError`) | -| any other definition on a process start | not authored | refused (`UnsupportedEventDefinitionError`) | -| a start with a definition inside a sub-process or transaction | not authored | refused (`UnsupportedEventFeatureError`) | -| a second start event inside a `bpmn:subProcess` or `bpmn:transaction` | not authored | refused (`UnsupportedEventFeatureError`) | -| an event sub-process start: error, escalation, message, signal, timer, condition | `on (...) { }`, `alongside` when allowed | imported as the handler's trigger; refused with more or fewer than one definition (`UnsupportedEventFeatureError`) | -| an event sub-process start carrying a cancel, link, or terminate definition | not authored | refused (`UnsupportedEventDefinitionError`) | -| an event sub-process start: compensation | `on compensation { }` inside a `subprocess` | imported inside a sub-process or transaction; refused on the process or in another handler (`UnsupportedEventFeatureError`) | +| BPMN element | BPMNscript | Import | +| -------------------------------------------------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `bpmn:startEvent` with no definition | `start X` | imported; a second one on the process refused (`UnsupportedEventFeatureError`); any start without an id refused (`UnsupportedDocumentError`) | +| message start | `start X message("Name")` | imported; refused when the name is an expression (`UnsupportedEventFeatureError`) | +| signal start | `start X signal("Name")` | imported | +| timer start | `start X timer("PT1H")`, `at:`, `every:` | imported; refused without an id (`UnsupportedDocumentError`) | +| conditional start | `start X condition(expr)` | imported | +| error, escalation, or compensation start on the process | not authored | refused, since Operaton ignores the trigger (`UnsupportedEventFeatureError`) | +| any other definition on a process start | not authored | refused (`UnsupportedEventDefinitionError`) | +| a start with a definition inside a sub-process or transaction | not authored | refused (`UnsupportedEventFeatureError`) | +| a second start event inside a `bpmn:subProcess` or `bpmn:transaction` | not authored | refused (`UnsupportedEventFeatureError`) | +| an event sub-process start: error, escalation, message, signal, timer, condition | `on (...) { }`, `alongside` when allowed | imported as the handler's trigger; refused with more or fewer than one definition (`UnsupportedEventFeatureError`) | +| an event sub-process start carrying a cancel, link, or terminate definition | not authored | refused (`UnsupportedEventDefinitionError`) | +| an event sub-process start: compensation | `on compensation { }` inside a `subprocess` | imported inside a sub-process or transaction; refused on the process or in another handler (`UnsupportedEventFeatureError`) | ### End events @@ -77,7 +77,7 @@ Operaton's own extension elements (`operaton:` settings, input/output parameters | BPMN element | BPMNscript | Import | | ------------------------------------------------ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `bpmn:intermediateThrowEvent` with no definition | not authored | imported as `step`, warned (`unmappedConstruct`): history reports `task` | +| `bpmn:intermediateThrowEvent` with no definition | not authored | imported as `step`, warned (`rewritten`): history reports `task` | | escalation throw | `emit escalation(CODE)` | imported | | message throw | `emit message("Name")` | imported; refused with a connector (`UnsupportedEventFeatureError`), or a `type` other than `external`, or `external` with no `topic` (`UnsupportedServiceTaskFormError`); `type="external"` with a `topic` imports as a binding, the same rule a message end follows | | signal throw | `emit signal("Name")` | imported | @@ -92,7 +92,7 @@ Operaton's own extension elements (`operaton:` settings, input/output parameters | -------------------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------- | | message catch | `await message("Name")` | imported | | signal catch | `await signal("Name")` | imported | -| timer catch | `await timer("PT1H")` | imported | +| timer catch | `await timer("PT1H")` | imported; refused without an id (`UnsupportedDocumentError`) | | conditional catch | `await condition(expr)` | imported | | link catch | `await link("Name")` | imported; refused when it names no link or a sequence flow enters it (`UnsupportedEventFeatureError`) | | a catch with no definition or any other kind | not authored | refused (`UnsupportedEventFeatureError`) | @@ -108,17 +108,18 @@ Operaton's own extension elements (`operaton:` settings, input/output parameters | compensation boundary with its `bpmn:association` and handler activity | not authored; write `on compensation { }` inside a `subprocess` | refused, the message names the rewrite (`UnsupportedEventFeatureError`) | | any other definition on a boundary | not authored | refused (`UnsupportedEventDefinitionError`) | | a boundary with no `attachedToRef`, an incoming flow, or no definition | not authored | refused (`UnsupportedEventFeatureError`) | +| a boundary without an id, other than a compensation boundary | not authored | refused (`UnsupportedDocumentError`) | ## Sequence flow -| BPMN element | BPMNscript | Import | -| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `bpmn:sequenceFlow` | never written: steps run top to bottom, `goto X` for a jump | imported; the printer rebuilds blocks from the flow graph and writes `goto` where no block fits; a route it cannot place is left out, warned on print (`droppedEdge`, `degradedSplit`) | -| `bpmn:conditionExpression`, and a conditional event definition's `bpmn:condition` | the condition of `if`, `while`, `do ... while`, a `parallel` branch, and of `condition(expr)` | imported; refused with a `language` attribute or an `xsi:type` other than `tFormalExpression` (`UnsupportedConditionExpressionError`) | -| `default` on a gateway | `else`, the fallback branch | imported as written (`mapDefaultingGateway`); on an exclusive gateway the shape check above can still refuse the gateway around it | -| `default` on an activity | `else`, the fallback branch | imported when it names one of the activity's own outgoing flows; dropped, warned (`unmappedConstruct`) otherwise | -| `name` on a sequence flow | not authored, derived from the condition | warned (`label`) when it differs from the derived label | -| a flow into or out of an event sub-process | not authored | refused, `BpmnParse.parseSequenceFlow` fails on it (`UnsupportedEventFeatureError`) | +| BPMN element | BPMNscript | Import | +| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `bpmn:sequenceFlow` | never written: steps run top to bottom, `goto X` for a jump | imported, one without an id under an id minted from its ends; the printer rebuilds blocks from the flow graph and writes `goto` where no block fits; a route it cannot place is left out, warned on print (`droppedEdge`, `degradedSplit`) | +| `bpmn:conditionExpression`, and a conditional event definition's `bpmn:condition` | the condition of `if`, `while`, `do ... while`, a `parallel` branch, and of `condition(expr)` | imported; refused with a `language` attribute or an `xsi:type` other than `tFormalExpression` (`UnsupportedConditionExpressionError`) | +| `default` on a gateway | `else`, the fallback branch | imported as written (`mapDefaultingGateway`); on an exclusive gateway the shape check above can still refuse the gateway around it | +| `default` on an activity | `else`, the fallback branch | imported when it names one of the activity's own outgoing flows; dropped, warned (`unmappedConstruct`) otherwise | +| `name` on a sequence flow | not authored, derived from the condition | warned (`label`) when it differs from the derived label | +| a flow into or out of an event sub-process | not authored | refused, `BpmnParse.parseSequenceFlow` fails on it (`UnsupportedEventFeatureError`) | ## Data @@ -137,7 +138,7 @@ Operaton's own extension elements (`operaton:` settings, input/output parameters | `bpmn:process`, one marked `isExecutable="true"` | `process X { }`, one per file | imported | | a second executable `bpmn:process` | not authored | refused (`UnsupportedCollaborationError`) | | a `bpmn:process` not marked executable beside the executable one | not authored | dropped, warned (`unmappedConstruct`) | -| a lone `bpmn:process` not marked executable | `process X { }` | imported as an executable process, warned (`unmappedConstruct`) | +| a lone `bpmn:process` not marked executable | `process X { }` | imported as an executable process, warned (`behaviourChanged`) | | several `bpmn:process` elements, none marked executable | not authored | refused (`UnsupportedDocumentError`) | | `bpmn:collaboration`, `bpmn:participant` | not authored | dropped, warned (`unmappedConstruct`), one warning per pool | | `bpmn:messageFlow` | not authored | dropped, warned (`unmappedConstruct`), one warning per flow | diff --git a/docs/decisions/0003-use-langium-as-language-workbench.md b/docs/decisions/0003-use-langium-as-language-workbench.md index 7b23faf..02294a0 100644 --- a/docs/decisions/0003-use-langium-as-language-workbench.md +++ b/docs/decisions/0003-use-langium-as-language-workbench.md @@ -24,20 +24,22 @@ Which language workbench should be used to implement the DSL infrastructure? - Langium: TypeScript throughout, with built-in scoping, cross-reference resolution and validation infrastructure, and a Chevrotain parser with unbounded (ALL(\*)) lookahead. - Eclipse Xtext: the most mature workbench (20 years), with EMF integration giving native access to the BPMN 2.0 metamodel and a built-in AST-to-text serializer, but VS Code support needs a hybrid Java/TypeScript stack with a JVM backend, no browser deployment path exists, the learning curve is steep (EMF, Guice, Xtend, Eclipse, MWE2), and its maintenance future has been in question since a 2020 sustainability discussion. -- MontiCore: has an existing BPMN Workflow DSL grammar, auto-generates pretty printers from grammars, and has strong language composition (inheritance, embedding, aggregation), but has no LSP support, no VS Code integration path, and no browser deployment path. +- MontiCore: has an existing BPMN Workflow DSL grammar, auto-generates pretty printers from grammars, and has strong language composition (inheritance, embedding, aggregation). + A language server and IntelliJ and VS Code plugins can be generated from a MontiCore grammar (Charles, Hellwig and Rumpe, Modellierung 2026), but its toolchain is Java, and the BPMN libraries this project builds on are TypeScript. - ANTLR with a manual LSP server: the most widely used parser generator, with full control and no framework lock-in, and grammar-aware completion via `antlr4-c3`, but every IDE feature would need to be implemented by hand, an estimated 3-5 weeks of the timeline, with no generated VS Code extension scaffolding. - JetBrains MPS: backed by JetBrains, with trivial language composition in its projectional editor, but it stores files as XML rather than human-readable text, has no VS Code or LSP integration, and is architecturally incompatible with a textual DSL. ## Decision Outcome Chosen option: "Langium", primarily for its own ergonomics: a smaller, more approachable toolchain than Xtext's, and native access to the existing TypeScript BPMN tooling this project builds on: `bpmn-moddle` to read and write BPMN 2.0 XML, and `bpmn-auto-layout` to generate the Diagram Interchange (DI) data a textual syntax has no coordinate system to produce by hand. -The IDE target followed from this choice rather than preceding it: Langium's TypeScript toolchain generates an LSP server and VS Code extension directly from the grammar, which made Visual Studio Code the easier of the two viable targets to reach, whereas an IntelliJ plugin for Xtext would not have benefited from that same generation path. +The IDE target followed from this choice rather than preceding it: `langium-cli` generates the AST types and a TextMate grammar from the grammar, the language server builds its parser, completion, scoping and validation on the same grammar at run time, and a one-time project template supplies the VS Code extension that hosts the server. +That made Visual Studio Code the easier of the two viable targets to reach, whereas an IntelliJ plugin for Xtext would not have benefited from the same path. Browser deployment for a future playground was a secondary factor, all within a timeline-feasible learning curve. ### Consequences - Good, because the entire stack is TypeScript, eliminating context-switching between languages -- Good, because Langium generates parser, AST, syntax highlighting, completion, go-to-definition, and diagnostics from the grammar +- Good, because the grammar yields the AST types and syntax highlighting at build time, and the parser, completion, go-to-definition, and diagnostics at run time - Good, because the language server can run in a web worker, enabling a browser-based playground with approximately 95% code reuse - Good, because `bpmn-moddle` (the standard TypeScript library for BPMN 2.0 XML) integrates naturally - Good, because Langium is actively maintained by TypeFox as an Eclipse Foundation mature project @@ -56,3 +58,4 @@ Sources for the comparison: - [Langium 4.0 Release](https://www.typefox.io/blog/langium-release-4.0/) - [Call To Action: Secure the future maintenance of Xtext (GitHub #1721)](https://github.com/eclipse-xtext/xtext/issues/1721) - [Jordan & Zib: A Langium-based approach to BigER (TU Wien, 2024)](https://model-engineering.info/publications/theses/thesis-jordan-zib.pdf) +- [Charles, Hellwig & Rumpe: Generating Language Servers for MontiCore-based DSLs (Modellierung 2026)](https://doi.org/10.18420/modellierung2026-08) diff --git a/docs/decisions/0008-fork-camunda-moddle-extension.md b/docs/decisions/0008-fork-camunda-moddle-extension.md index 256ad1b..c14c52c 100644 --- a/docs/decisions/0008-fork-camunda-moddle-extension.md +++ b/docs/decisions/0008-fork-camunda-moddle-extension.md @@ -31,7 +31,7 @@ How should BPMNscript register Operaton's extension attributes with `bpmn-moddle ## Decision Outcome Chosen option: "Ship a trimmed fork", because it avoids namespace collisions, eliminates fragile string-replacement post-processing, and keeps the descriptor small and auditable. -The fork is based on `camunda-bpmn-moddle/resources/camunda.json` from the bpmn-io GitHub organization, with the prefix and namespace URI replaced and only the attributes BPMNscript uses kept. +The fork is based on `camunda-bpmn-moddle/resources/camunda.json` from the `camunda/camunda-bpmn-moddle` repository on GitHub, with the prefix and namespace URI replaced and only the attributes BPMNscript uses kept. ### Consequences diff --git a/docs/decisions/0010-deterministic-synthesized-ids.md b/docs/decisions/0010-deterministic-synthesized-ids.md index d2f8179..b88d88d 100644 --- a/docs/decisions/0010-deterministic-synthesized-ids.md +++ b/docs/decisions/0010-deterministic-synthesized-ids.md @@ -9,7 +9,7 @@ decision-makers: Marlon Kranz ## Context and Problem Statement The `astToIr` desugarer gives an id to every BPMN element the DSL source leaves unnamed: the exclusive gateway pair enclosing an `if`/`else`, the loop-head gateway for `while`/`do...while`, the fork/join pair for `parallel`, the event-based gateway and exclusive merge for a multi-branch `await`, the catch event each `await` waits at, the boundary event a hosted handler attaches with, the event sub-process a host-less handler becomes, an `emit` or `throw` written without a name, and implicit start/end events. -The `irToDsl` restructurer also synthesizes flow ids when emitting the DSL. +It also gives every sequence flow its id, since the script never writes a flow: `irToDsl` prints none of the flow ids an imported document carries, and the compiler mints them again when the printed source is compiled. How should these ids be generated, and what does an author's own name have to stay clear of so that the compiler never mints a second element under an id the document already carries? The spelling of an author's id is the other half of the same question. @@ -24,7 +24,7 @@ Renaming such an element is not a fix the user can apply without cost: the engin - The round-trip normalizer (`tests/helpers/normalize-ir.ts`) must be able to recognize and re-key synthesized ids, which is only practical if the id scheme is stable and documented. - Sequential counters depend on traversal order, making ids sensitive to unrelated source changes. - Ids derived from structural position are self-documenting and survive refactoring of unrelated parts of the process. -- The import contract (ADR-0012): a document that deploys imports, and every change to what runs is reported. +- The import contract ([ADR-0012](0012-honest-bpmn-import.md#decision-outcome)): a document that deploys imports, unless it marks more than one process executable, holds an event without an id that the engine deploys but cannot run, or uses a construct this surface cannot spell, and every change to what runs is reported. - An activity id is the key the engine's history and migration match on, so a changed id is a changed process, and the report has to say so. - One fact, one home: the shape of a name is the `ID` terminal, and the transform spells it once for both directions. @@ -90,9 +90,20 @@ The printer builds one map per print, from the process id and every element id t An id the `ID` terminal accepts maps to itself. Any other maps to the id with every non-word character replaced by `_`, a leading `_` where the result would open on a digit or read as a keyword, resolved against every id in the document and every name minted before it. Every site that writes an id reads the map: the process head, every statement head, a `goto`, a boundary's host, and a named throw or catch. -A gateway, a boundary and an event sub-process never write their id and keep it. +A gateway of any kind, a boundary event, an event sub-process, the catch event of a race branch and a sequence flow have no id slot in the script, so the map leaves them out. +Their authored ids do not survive a rebuild: the compiler synthesizes each one from the templates above, and no warning reports it. +An element with a name slot keeps its id: a task, a sub-process, a start, an end, a single `await`, and a named `throw` or `emit`. Each rename draws one `renamedId` warning naming the printed name and saying that the rebuilt document carries it as the activity id. +An element the document gives no id gets one on import, before anything keys it. +A sequence flow gets `Flow__`, and any other element `_`, from the local name of its BPMN type and the id of the process or sub-process holding it, such as `UserTask_invoice`; both resolve against every id in the document the way the compiler's ids do. +An end event also skips `EndEvent_` for every id in the document and so usually imports as `EndEvent__2`, since the printer would take such an id for the implicit end the compiler gives a container or a boundary escape and leave the event out. +Operaton deploys such a flow as an unnamed transition and such a task, end or catch as an activity nothing can flow into, since a `sourceRef` or `targetRef` needs an id, so the step prints and the validator reports that it can never run. +A start event, a timer event, an event-based gateway, and a message or signal boundary event without an id refuse the import instead, since Operaton fails the deployment on each. +An error, escalation, cancel or conditional boundary event without an id refuses too: Operaton deploys it and fails the run when an error is thrown inside its step, an escalation reaches it, its transaction is cancelled, or its step runs. +A minted id would make the rebuilt document deploy and run where the source does not. +A compensation boundary event deploys and runs without an id, so it takes a minted one like a task, and is then refused like every compensation boundary event ([ADR-0012](0012-honest-bpmn-import.md)). + A form field id or an `operaton:inputParameter`/`operaton:outputParameter` name outside the terminal refuses the import. The engine sets the variable under that exact name (`FormFieldHandler.handleSubmit`, `InputParameter.execute`, `OutputParameter.execute`), so a minted name would change which variable is set rather than how it is written. @@ -106,6 +117,8 @@ The engine sets the variable under that exact name (`FormFieldHandler.handleSubm - Bad, because renaming a process id or reordering top-level statements changes all synthesized ids in that process, which breaks deployed BPMN definitions. That is a concern for production use and acceptable for a DSL-authoring workflow where recompile is expected to replace the definition. - Bad, because a respelled id is a changed activity id in the rebuilt document, so a process rebuilt from such a script is not the process the history and migration APIs know; the warning says so, and the remedy is a rename in the model. +- Bad, because an imported gateway, boundary event, event sub-process or race-branch catch comes back under a synthesized id without a warning, and the engine records these in history under their activity id as it does a task. + A migration plan written against the original document has to map them anew. - Bad, because a statement written under another container's minted id, such as `user EndEvent_S` beside `subprocess S`, is legal and makes the compiler mint `EndEvent_S_2` for `S`, which the printer then writes as an ordinary `end EndEvent_S_2`; the round trip stays stable from the second print on, and nothing runs differently. ### Confirmation @@ -114,6 +127,7 @@ The engine sets the variable under that exact name (`FormFieldHandler.handleSubm The reserved-name table in `packages/language/test/validating.test.ts` pins the exact rule per container and the freed `StartEvent_1`. The respelling table in `packages/transform/test/ir-to-dsl.test.ts` prints every id site under a minted name, asserts the whole warning list, and re-parses and validates the source; the Modeler-default row asserts the printed start, end and initiator with no warning. The refusal matrix in `packages/transform/test/xml-to-ir.test.ts` pins the two import refusals. +The two id-less tables in the same file pin the minted ids and the id-less events that refuse. The round-trip normalizer (`tests/helpers/normalize-ir.ts`) uses the id patterns to re-key synthesized ids before comparing IR snapshots, and the golden pair suites pass unchanged. ## More Information diff --git a/docs/decisions/0011-structured-grammar-one-bracket-shape.md b/docs/decisions/0011-structured-grammar-one-bracket-shape.md index 6db3cf0..4e992c0 100644 --- a/docs/decisions/0011-structured-grammar-one-bracket-shape.md +++ b/docs/decisions/0011-structured-grammar-one-bracket-shape.md @@ -76,7 +76,7 @@ The parens therefore hold a payload, then keyed settings, then bare flags, told - Good, because the DSL reads and writes like program code (`if`/`while`/`parallel`), and an author who has seen one element can write any other. - Good, because conditions are a first-class expression AST, enabling type-check validation and jump-to-definition for variable references. - Good, because `parallel { { } { } }` maps directly to AND fork/join pairs, making parallel-gateway support natural to author. -- Good, because `goto` as a residual form keeps decompilation total: every valid BPMN graph has a valid DSL representation. +- Good, because `goto` as a residual form keeps decompilation total: every imported graph prints as source, and an edge with no name to jump to is dropped with a marker line and a warning (ADR-0014). - Good, because the printer emits one shape rather than choosing a spelling per position, which removes a class of difference between an authored file and a generated one. - Good, because a `{` opens children and nothing else, so the delimiter alone says whether a construct nests. - Bad, because settings lists are longer and wrap. diff --git a/docs/decisions/0012-honest-bpmn-import.md b/docs/decisions/0012-honest-bpmn-import.md index f4d0362..fe374bc 100644 --- a/docs/decisions/0012-honest-bpmn-import.md +++ b/docs/decisions/0012-honest-bpmn-import.md @@ -42,17 +42,20 @@ Document shapes the engine rejects before `BpmnParse` sees an element pass throu ## Decision Outcome -Chosen option: two tiers, drawn where `BpmnParse` draws them, because it is the only option consistent with the no-silent-semantic-loss claim that accepts exactly what the engine deploys and says what the engine does with what it drops. +Chosen option: two tiers, drawn where `BpmnParse` draws them, because it is the only option consistent with the no-silent-semantic-loss claim that accepts what the engine deploys, within the scope bounds below (one executable process per file, no event without an id that the engine deploys but cannot run, and no construct this surface cannot spell), and says what the engine does with what it drops. The rule has three clauses. A shape `BpmnParse` fails the deployment on refuses, and the message names the method and quotes its sentence, since there is nothing that runs to write back. A shape the engine deploys and this surface can spell is carried, with a warning where the spelling changes. -A shape the engine deploys and this surface cannot spell drops with a warning that names the method that reads it and says the document written back runs without it. -The one drop that refuses instead is a thrown message's result variable beside an `expression` binding, where the engine stores a value the throw has no slot for. +A shape the engine deploys and this surface cannot spell either drops with a warning that names the method that reads it and says the document written back runs without it, or is refused with its reason. +`packages/transform/README.md` lists the refused ones, among them a `bpmn:complexGateway`, a compensation boundary event, two input parameters sharing a name, a script body no fence can enclose, and a thrown message's result variable beside an `expression` binding, where the engine stores a value the throw has no slot for. A refusal throws a subclass of `UnsupportedConstructError` before any IR is produced, so there is never a partial IR, and a consumer classifies the whole family with a single `instanceof` check while each subclass still carries construct-specific metadata for a tailored message. A warning is returned in a `warnings: ImportWarning[]` array alongside the IR, so `xmlToIr` returns `{ ir, warnings }` rather than a bare `BpmnProcess`, which makes the channel unignorable at the type level: every call site must destructure or explicitly discard `warnings`, where an optional collector parameter (`xmlToIr(xml, sink?)`) or a second `xmlToIrWithWarnings` function leaves it easy to skip. What the engine deploys and then fails on at run time is carried as written, with a warning that the printed script draws an error there. +The exception is an event without an id that the engine deploys and cannot run, which is an error, escalation, cancel or conditional boundary event. +The IR names every node, so such an event cannot be carried as written, and an id minted for it would make the handler run where the source fails, so the import refuses it ([ADR-0010](0010-deterministic-synthesized-ids.md)). +The import contract is therefore that a document the engine deploys imports, unless it marks more than one process executable, holds an event without an id that the engine deploys but cannot run, or uses a construct this surface cannot spell; each of these is refused with its reason, and nothing the engine runs is dropped without a warning. No element leaves the transform unreported: whatever it cannot carry is named by the tag the document spells and by its own id, and attributed to the element it sat on, a construct being reported whole so that a dropped `bpmn:ioSpecification` names itself rather than each data input inside it. What is dropped without a warning is the diagram interchange data ADR-0009 settles, a BPMN attribute the transform neither reads nor reports, such as a process's `processType` or `isClosed`, and an attribute in a foreign namespace written directly on a mapped BPMN element, which is where an editor parks its own bookkeeping; the same foreign attribute on an extension child the IR reads is reported. @@ -60,6 +63,7 @@ What is dropped without a warning is the diagram interchange data ADR-0009 settl Three document-level shapes follow from the same rule rather than from any one element. `selectProcess` imports the one process marked `isExecutable="true"` and pushes one warning per other process, since `parseProcessDefinitions` does not deploy those either. +A file with two or more processes marked executable is refused, although `parseProcessDefinitions` deploys each of them as a definition of its own: the IR and a script hold one process, so one executable process per file is a scope bound of this tool, not an engine rule. A collaboration is diagram data to the engine, since `parseCollaboration` records a participant's `processRef` only so that `parseBPMNShape` can attach the pool's bounds and no method in `BpmnParse` reads a `bpmn:messageFlow`, so a pool warns rather than refuses. The camunda namespace is read as the operaton one by replacing the URI in the document text before `moddle.fromXML`, with one warning per document: `BpmnParse.OPERATON_BPMN_EXTENSIONS_NS` falls back to the camunda URI only when the operaton lookup finds nothing, and moddle-xml binds a prefix to a package by URI rather than by spelling, so after the swap every `camunda:` element arrives typed `operaton:*` and every `camunda:` attribute reads through the one `operaton:` reader. @@ -77,7 +81,7 @@ Every one of them is warned rather than refused, because a refusal has no meanin ### Consequences -- Good, because a document the engine deploys imports, among them the Modeler's default collaboration file, the Operaton invoice example, and the MIWG files with one executable process, and one the engine refuses is refused with the engine's own sentence rather than imported into a script that cannot deploy. +- Good, because a document the engine deploys imports, provided it marks at most one process executable, holds no event without an id that the engine cannot run, and uses no construct this surface cannot spell, among them the Modeler's default collaboration file, the Operaton invoice example, and the MIWG files with one executable process, and one the engine refuses is refused with the engine's own sentence rather than imported into a script that cannot deploy. - Good, because every caller, the CLI, the VS Code extension, and the round-trip suite, surfaces both the refusal and the warning channel instead of only one or neither, and the shared `UnsupportedConstructError` base keeps classification to one `instanceof` check as new refusal categories are added. - Good, because every warning on a setting the engine reads says which method reads it, so the reader knows whether the drop changes a run. - Bad, because the engine's sentences are quoted in the importer and drift with the engine's wording. diff --git a/docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md b/docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md index 073b60e..cd287eb 100644 --- a/docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md +++ b/docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md @@ -71,6 +71,11 @@ A host-less `on timer(...)` handler lowers `jobPriority`, `retryCycle` and `excl The printer lifts the three back into the `on timer` head as long as the start prints no statement of its own, and leaves them on the `start` line when it does, so an authored start inside the body keeps its settings where it wrote them. Every other host-less handler writes its settings on the event sub-process. +The process header's settings follow the same naming rule, and `historyTimeToLive` is the one with a default. +`HistoryTimeToLiveParser.parseAndValidate` refuses a process without one under the engine's default `enforceHistoryTimeToLive`, so `irToXml` writes `P30D` when the IR names none, and the importer reads exactly that value back as absent to keep the round trip stable. +An imported document that sets none is warned about, since its rebuilt process deploys where the source did not. +`isStartableInTasklist` is the one that takes a boolean, and the importer reads it as `BpmnParse.isStartable` does, so any value but a case-insensitive `true` imports as `false`. + ### Consequences - Good, because the validator can restrict attribute keys per element kind, and the vendor prefix stays on the serialization boundary, so the IR reads the same regardless of whether the reader knows Operaton. diff --git a/docs/decisions/0024-event-trigger-payloads-paren-slot.md b/docs/decisions/0024-event-trigger-payloads-paren-slot.md index cb59621..4a99baa 100644 --- a/docs/decisions/0024-event-trigger-payloads-paren-slot.md +++ b/docs/decisions/0024-event-trigger-payloads-paren-slot.md @@ -33,7 +33,7 @@ Two narrower questions ride along with it: how a timer's payload text becomes on - A binding-marker keyword freeing the parens for bindings only, writing them after a marker such as `as (...)`, which spends a new reserved word on a collision structural lookahead already dissolves for free and breaks the parameter-list reading (`on error(X, code: c, message: m)`, read like `catch (Exception e)`) that motivated the binding shape in the first place. - A string-wrapped condition, written as a quoted literal instead of a real expression, which reads worse than the language's own `if (...)` and throws away everything a real expression AST buys, including undeclared-variable checking, type checking, symbol participation, and precise highlighting. - Reserving `condition` as a keyword to dispatch the parens, which would be a lexer-global reservation, unusable as an ordinary variable name anywhere in a file, for exactly the reason the trigger and binding words already stay soft identifiers. -- Timer particles, `after`, `at` and `every`, as validated identifiers mapping one-to-one to BPMN's three timer forms, which read as the English they mean and cannot silently resolve to the wrong form. +- Timer particles, a bare string for a duration and the keys `at` and `every` for a date and a cycle, as validated identifiers mapping one-to-one to BPMN's three timer forms, which read as the English they mean and cannot silently resolve to the wrong form. - A single particle with the timer form inferred from the value's shape, where an ISO date looks like a date and a duration string looks like a duration, which shifts a scheduling decision onto pattern-matching the value's text: a mistyped ISO string, or an expression the engine evaluates at runtime rather than a literal the compiler can inspect, becomes a different timer kind than the one the author meant, with no diagnostic pointing at the mismatch. - Deferring the conditional narrowing attributes and refusing them on import rather than dropping them. - Surfacing them as a fourth payload shape, narrower than the other three and carrying nothing but an engine-side optimization hint the audience has no reason to know about. @@ -46,12 +46,15 @@ Two narrower questions ride along with it: how a timer's payload text becomes on Chosen: one paren slot disambiguated structurally, timer particles mapping one-to-one with no inference, conditional narrowing deferred and refused rather than dropped, and message and signal roots derived from usage. Each keeps the same principle: spend a new payload shape or a new reserved word only where the alternative loses something the surface cannot get back, whether round-trip fidelity, execution-accurate scheduling, or truthful runtime semantics, and nowhere else. -The parentheses after a trigger's code stay a single grammar position that holds either a binding list or a condition expression, and which one is present is decided structurally, not by which trigger word precedes it. -A binding list is a field name followed by a variable name, two identifiers in a row, and the expression sub-language never places two identifiers adjacently, since every accessor starts with `.` or `[` and every operator level requires an operator token in between. -The parser therefore tells the two apart by looking at the second token after the opening parenthesis, which settles it before any per-trigger legality is considered, and whether a given trigger may use bindings, a condition, or neither is the kind of position rule the validator already owns for every other soft word. +The parentheses after a trigger word are the settings parens every statement carries, and the code or name, the catch bindings, a condition and the engine settings all sit in them. +Which one an item is gets decided structurally, not by which trigger word precedes it. +An item written `key: value` is a setting, and a catch binding is one, the binding field as its key and the variable as its value: `on error(PAYMENT_FAILED, code: c, message: m)`. +An item with no key is the payload, a code, a quoted name or a condition expression such as `condition(ready)`. +No expression opens with an identifier followed by `:`, since the expression sub-language uses `:` only after the `?` of a conditional, so the parser settles the item's kind before any per-trigger legality is considered, and whether a given trigger may use bindings, a condition, or neither is the kind of position rule the validator already owns for every other soft word. -A timer's payload is a particle word followed by a time expression, each particle mapping to exactly one of BPMN's timer forms, a duration, a fixed date, or a repeating cycle. -The particle is a plain identifier, checked in position by the validator like every other soft trigger word, not a keyword. +A timer's payload picks one of BPMN's timer forms, a duration, a fixed date, or a repeating cycle, by how the time string is written: bare it is a duration, `timer("PT1H")`, and under the keys `at` and `every` a date or a cycle, `timer(at: "2026-08-01T09:00:00")`. +The two keys are plain identifiers, checked in position by the validator like every other soft trigger word, not keywords. +A task listener's `timeout` writes the same choice as a word before the string, `after`, `at` or `every`, as in `on timeout after "PT8H"`. BPMN's conditional event definition can additionally narrow when a condition is re-evaluated, to a named variable or a specific kind of variable change, rather than re-checking on every change. This narrowing is an optional, engine-side evaluation optimization: a handler behaves correctly without it, re-checking on any variable change instead of a specific one, so the surface does not expose it. diff --git a/docs/decisions/0025-events-at-the-process-boundary.md b/docs/decisions/0025-events-at-the-process-boundary.md index 10c68a2..25a5504 100644 --- a/docs/decisions/0025-events-at-the-process-boundary.md +++ b/docs/decisions/0025-events-at-the-process-boundary.md @@ -59,6 +59,10 @@ The terminate is the one place here where the option costing no grammar was not A kind belonging on `throw` earns a validator message naming the statement to write, instead of a parse error (`endTriggerMessage`), and the validator refuses the two illegal start positions the same way, each with its own wording (`checkStartEvent`, `packages/language/src/bpmn-script-validator.ts`). The accepted trigger set is `START_TRIGGERS` (`packages/language/src/vocabulary.ts`), and import refuses what lies outside it with wording about the degradation rather than about the element type (`IGNORED_START_SUBJECTS` and `readStartTrigger`). A conditional start is accepted rather than refused, because the engine dispatches on it where it ignores an error, escalation, or compensation one, so the set that stops where the engine stops includes it. +Operaton also ignores a `bpmn:standardLoopCharacteristics`, and ADR-0022 imports that as a step that runs once with a warning, so the two ignored elements are handled differently on purpose. +An error, escalation or compensation trigger on a process's own start event is not valid BPMN: the specification allows only none, message, timer, conditional, signal, multiple and parallel multiple there (BPMN 2.0.2, Table 10.84), and keeps the other three for an event sub-process start (Table 10.86). +Such a document says something no conforming reading can give it, so the import refuses it and the fix belongs in the model. +A standard loop is valid BPMN that the engine does not implement, and refusing it would reject a conforming document that deploys, against the import contract of ADR-0012. Neither clause needs a new reserved word. `name=ID` is mandatory and comes first in both the `StartEvent` and `EndEvent` rules (`packages/language/src/bpmn-script.langium`), so the second token decides everything: an `ID` is the trigger, a `(` the settings, and a `{` the members. diff --git a/docs/decisions/0027-boundary-events-attached-to-an-activity.md b/docs/decisions/0027-boundary-events-attached-to-an-activity.md index 4213901..b926e2c 100644 --- a/docs/decisions/0027-boundary-events-attached-to-an-activity.md +++ b/docs/decisions/0027-boundary-events-attached-to-an-activity.md @@ -8,7 +8,7 @@ decision-makers: Marlon Kranz ## Context and Problem Statement -Every `on` handler compiles to an event sub-process that guards its whole enclosing process or subprocess body (ADR-0023). +A host-less `on` handler compiles to an event sub-process that guards its whole enclosing process or subprocess body, as the decision outcome below records. BPMN has a second, narrower attachment for the same catch vocabulary: a `bpmn:boundaryEvent` docked directly on one activity, which only catches while that activity is running and, depending on `cancelActivity`, either cancels it the moment the trigger fires or lets it keep running alongside the catch. Giving `on` an optional host turns it into this second attachment form without introducing a competing keyword or a second handler construct. Four questions settle together: how a host is spelled without colliding with the handler shape `on timer("PT2H")` that already parses; which of the language's catchable triggers may attach; what a hosted body compiles to, given that the host-less form's meaning must not move; and how a token that appears directly at a boundary event, without ever traversing the main flow, is represented in the dominator-based restructuring analysis (ADR-0014). @@ -40,6 +40,12 @@ Four questions settle together: how a host is spelled without colliding with the Chosen options: the colon separator, a scope read from the engine, a self-contained body, virtual-entry wiring, and host-derived ids. +The host-less form keeps its meaning. +A host-less `on` compiles to a `bpmn:subProcess` with `triggeredByEvent="true"` in the container whose body holds the handler, the process, a `subprocess` or an `attempt` block, and so catches while any part of that container runs. +Its trigger sits on the start event the event sub-process opens with, `alongside` writes that start as `isInterrupting="false"`, and the handler's body lowers as the event sub-process's own body, with its own implicit start and end. +Its id is the positional `EventSubProcess_` (ADR-0010), and the decompiler prints every event sub-process back as a host-less handler after the container's other statements, in model order, since the compiler numbers each one by its statement index. +`on compensation` is the host-less handler with a record of its own (ADR-0028), and `cancel` has no host-less form (ADR-0029). + The scope is message, timer, signal, conditional, error, and escalation. Escalation is restricted to a subprocess, an `attempt` block, a call, or a user task because Operaton gates that boundary on `attachedActivity.isSubProcessScope()`, and `parseTransaction` sets that flag exactly as `parseSubProcess` does, so an `attempt` block hosts an escalation on the same terms an ordinary block does. Error reuses the same `alongside: false` restriction already enforced for a host-less error handler rather than adding a boundary-specific rule. diff --git a/examples/README.md b/examples/README.md index fcbc20d..98ce191 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,16 +43,17 @@ The DSL sources under `spring-boot/processes/` cover one construct or construct | `plan-selection` | A task form with a required enum and a length-bounded text field | | `nightly-report` | An async join, per-run jobs on a repetition, and a shell task | | `outage-notice` | A mail task, deployed and never started | +| `expense-claim` | A `while` loop, an `if`/`else`, and a `parallel` block in one process | [Running processes on Operaton](spring-boot/README.md#running-processes-on-operaton-demo) is a hands-on tour of the two loan-approval processes. ### Testcontainers harness The E2E tests in `tests/e2e/` use [testcontainers-node](https://testcontainers.com/) to start the Docker image, deploy compiled BPMN over the Operaton REST API, start instances, and assert what the engine does with what the compiled document only declares. -Each file names the example it drives, or the examples it deploys in one container boot. -`deploy-sweep` deploys every golden under `tests/golden/`, every fixture under `tests/fixtures/`, and every example here in one container boot, once as written and once rebuilt from the tool's own print of it. +The suite builds the image and starts one engine once, and every file runs against it; each file names the example it drives or the examples it deploys. +`deploy-sweep` deploys every golden under `tests/golden/`, every fixture under `tests/fixtures/`, and every example here, once as written and once rebuilt from the tool's own print of it. -Docker tests run by default and are skipped only when `SKIP_DOCKER_TESTS=true`, which is what CI sets. +Docker tests run by default, in CI too, and are skipped only when `SKIP_DOCKER_TESTS=true`. ## Adding a new deployment mode diff --git a/examples/spring-boot/processes/expense-claim.bpmnscript b/examples/spring-boot/processes/expense-claim.bpmnscript new file mode 100644 index 0000000..2f59d95 --- /dev/null +++ b/examples/spring-boot/processes/expense-claim.bpmnscript @@ -0,0 +1,38 @@ +process expense-claim { + var amount: number + var compliant: boolean + + start ClaimSubmitted { + form { + amount: number "Claimed amount" + } + } + + service CheckPolicy(label: "Check the expense policy", class: "com.example.expense.CheckPolicyDelegate") + + while (!compliant) { + user ReviseClaim(label: "Revise the claim", assignee: "demo") { + form { + amount: number "Corrected amount" + compliant: boolean "Claim now complies" + } + } + } + + if (amount > 500) { + user ApproveClaim(label: "Approve the claim", assignee: "manager") + } else { + service AutoApprove(label: "Approve automatically", class: "com.example.expense.AutoApproveDelegate") + } + + parallel { + { + service BookPayment(label: "Book the payment", class: "com.example.expense.BookPaymentDelegate") + } + { + service NotifyClaimant(label: "Notify the claimant", class: "com.example.expense.NotifyDelegate") + } + } + + end ClaimSettled +} diff --git a/examples/spring-boot/src/main/java/com/example/expense/AutoApproveDelegate.java b/examples/spring-boot/src/main/java/com/example/expense/AutoApproveDelegate.java new file mode 100644 index 0000000..4237cf0 --- /dev/null +++ b/examples/spring-boot/src/main/java/com/example/expense/AutoApproveDelegate.java @@ -0,0 +1,17 @@ +package com.example.expense; + +import org.operaton.bpm.engine.delegate.DelegateExecution; +import org.operaton.bpm.engine.delegate.JavaDelegate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AutoApproveDelegate implements JavaDelegate { + + private static final Logger LOG = LoggerFactory.getLogger(AutoApproveDelegate.class); + + @Override + public void execute(DelegateExecution execution) { + execution.setVariable("autoApproved", true); + LOG.info("AutoApprove [{}]: autoApproved=true", execution.getProcessInstanceId()); + } +} diff --git a/examples/spring-boot/src/main/java/com/example/expense/BookPaymentDelegate.java b/examples/spring-boot/src/main/java/com/example/expense/BookPaymentDelegate.java new file mode 100644 index 0000000..cbd02ba --- /dev/null +++ b/examples/spring-boot/src/main/java/com/example/expense/BookPaymentDelegate.java @@ -0,0 +1,17 @@ +package com.example.expense; + +import org.operaton.bpm.engine.delegate.DelegateExecution; +import org.operaton.bpm.engine.delegate.JavaDelegate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class BookPaymentDelegate implements JavaDelegate { + + private static final Logger LOG = LoggerFactory.getLogger(BookPaymentDelegate.class); + + @Override + public void execute(DelegateExecution execution) { + LOG.info("BookPayment [{}]: amount={}", + execution.getProcessInstanceId(), execution.getVariable("amount")); + } +} diff --git a/examples/spring-boot/src/main/java/com/example/expense/CheckPolicyDelegate.java b/examples/spring-boot/src/main/java/com/example/expense/CheckPolicyDelegate.java new file mode 100644 index 0000000..2521f8f --- /dev/null +++ b/examples/spring-boot/src/main/java/com/example/expense/CheckPolicyDelegate.java @@ -0,0 +1,25 @@ +package com.example.expense; + +import org.operaton.bpm.engine.delegate.DelegateExecution; +import org.operaton.bpm.engine.delegate.JavaDelegate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class CheckPolicyDelegate implements JavaDelegate { + + private static final Logger LOG = LoggerFactory.getLogger(CheckPolicyDelegate.class); + + private static final double POLICY_LIMIT = 2000; + + @Override + public void execute(DelegateExecution execution) { + Number claimed = (Number) execution.getVariable("amount"); + double amount = claimed == null ? 0 : claimed.doubleValue(); + + boolean compliant = amount > 0 && amount <= POLICY_LIMIT; + execution.setVariable("compliant", compliant); + + LOG.info("CheckPolicy [{}]: amount={} -> compliant={}", + execution.getProcessInstanceId(), amount, compliant); + } +} diff --git a/examples/spring-boot/src/main/java/com/example/expense/NotifyDelegate.java b/examples/spring-boot/src/main/java/com/example/expense/NotifyDelegate.java new file mode 100644 index 0000000..beb1936 --- /dev/null +++ b/examples/spring-boot/src/main/java/com/example/expense/NotifyDelegate.java @@ -0,0 +1,17 @@ +package com.example.expense; + +import org.operaton.bpm.engine.delegate.DelegateExecution; +import org.operaton.bpm.engine.delegate.JavaDelegate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class NotifyDelegate implements JavaDelegate { + + private static final Logger LOG = LoggerFactory.getLogger(NotifyDelegate.class); + + @Override + public void execute(DelegateExecution execution) { + LOG.info("Notify [{}]: the claimant is told the claim is settled", + execution.getProcessInstanceId()); + } +}