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/.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/.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/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 9251d08..cbcca93 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,11 +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, 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 -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 @@ -48,29 +58,38 @@ 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 -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 ``` -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. +`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. ### 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) @@ -86,9 +105,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 cffd055..08442b0 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. @@ -61,7 +61,17 @@ 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 + +- [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 @@ -73,25 +83,29 @@ 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. +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. ## 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 -- 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 +- 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 +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. @@ -100,53 +114,14 @@ 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 | - -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 `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. - -[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. +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. + +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. + +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 @@ -198,7 +173,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 @@ -248,26 +223,31 @@ 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. +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-0014](docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.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)). -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)). +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-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, 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 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 @@ -294,10 +274,10 @@ 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 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-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 | | --------------------------------------------------------------- | --------------------------------------------------- | @@ -318,8 +298,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-0009](docs/decisions/0009-auto-layout-and-expansion-hint.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..387c992 --- /dev/null +++ b/docs/bpmn-coverage.md @@ -0,0 +1,157 @@ +# 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, 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 + +| BPMN element | BPMNscript | Import | +| ------------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bpmn:task` | `step X` | imported | +| `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 (`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 | +| `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, 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 | +| `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`); 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`); 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 + +| 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 (`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 | +| 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; 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`) | +| `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`) | +| 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, 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 + +| 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 (`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 | +| `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/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 8c9b1ab..0000000 --- a/docs/decisions/0002-use-langium-as-language-workbench.md +++ /dev/null @@ -1,119 +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 - -- VS Code must be the primary IDE target, 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", 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. - -### 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 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 cce4917..0000000 --- a/docs/decisions/0003-auto-layout-for-diagram-interchange.md +++ /dev/null @@ -1,35 +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 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..02294a0 --- /dev/null +++ b/docs/decisions/0003-use-langium-as-language-workbench.md @@ -0,0 +1,61 @@ +--- +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). + 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-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 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 +- 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) +- [Charles, Hellwig & Rumpe: Generating Language Servers for MontiCore-based DSLs (Modellierung 2026)](https://doi.org/10.18420/modellierung2026-08) 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 86% rename from docs/decisions/0011-in-editor-conversion-sidebar.md rename to docs/decisions/0005-in-editor-conversion-webview-sidebar.md index c680e6d..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, `DiagnosticCollection` updates, 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 adapter owns only VS Code I/O: URI resolution, file reads and writes, focusing the Problems panel, overwrite confirmation, and opening the result. +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-engine-agnostic-intermediate-representation.md b/docs/decisions/0007-intermediate-representation-ast-bpmn.md similarity index 84% rename from docs/decisions/0006-engine-agnostic-intermediate-representation.md rename to docs/decisions/0007-intermediate-representation-ast-bpmn.md index f939ece..637ed83 100644 --- a/docs/decisions/0006-engine-agnostic-intermediate-representation.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..c14c52c 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 `camunda/camunda-bpmn-moddle` repository on GitHub, 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 2a7b17d..0000000 --- a/docs/decisions/0008-structured-grammar.md +++ /dev/null @@ -1,54 +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`. 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 6b43c7b..0000000 --- a/docs/decisions/0009-dominator-based-restructuring.md +++ /dev/null @@ -1,84 +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`. - -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 3b45ad3..0000000 --- a/docs/decisions/0010-deterministic-structural-ids.md +++ /dev/null @@ -1,94 +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`. diff --git a/docs/decisions/0010-deterministic-synthesized-ids.md b/docs/decisions/0010-deterministic-synthesized-ids.md new file mode 100644 index 0000000..b88d88d --- /dev/null +++ b/docs/decisions/0010-deterministic-synthesized-ids.md @@ -0,0 +1,138 @@ +--- +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. +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. +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](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. + +## 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 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. + +### 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 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 + +`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 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 + +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..4e992c0 --- /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 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. +- 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..fe374bc --- /dev/null +++ b/docs/decisions/0012-honest-bpmn-import.md @@ -0,0 +1,105 @@ +--- +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 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 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. +`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 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. + +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, 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. +- 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 efe4573..0000000 --- a/docs/decisions/0013-target-users-without-bpmn-knowledge.md +++ /dev/null @@ -1,56 +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. diff --git a/docs/decisions/0013-validator-mirrors-parse-time-rules.md b/docs/decisions/0013-validator-mirrors-parse-time-rules.md new file mode 100644 index 0000000..cb645d7 --- /dev/null +++ b/docs/decisions/0013-validator-mirrors-parse-time-rules.md @@ -0,0 +1,70 @@ +--- +status: accepted +date: 2026-09-14 +decision-makers: Marlon Kranz +--- + +# The validator mirrors the engine's parse-time rules + +## 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. +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: 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 + +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. +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. +`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 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 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 2992f0d..0000000 --- a/docs/decisions/0014-honest-bpmn-import-contract.md +++ /dev/null @@ -1,145 +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` -- a collaboration (pools/message flows) -> `UnsupportedCollaborationError` -- 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` -- 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) -- 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 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 `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, 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 `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 -- 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 sequence flow's `name` or a process's `processType` or `isClosed`. - -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, 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. - 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 or an input/output parameter found on a gateway still warns, since no gateway kind reads either. - -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. - -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..4e403f4 --- /dev/null +++ b/docs/decisions/0014-restructure-the-ir-into-a-dsl-with-dominator-analysis.md @@ -0,0 +1,149 @@ +--- +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 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. +- 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, 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. +- 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 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, 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. + +`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 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. + 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/0015-the-print-declares-every-variable-it-reads.md b/docs/decisions/0015-the-print-declares-every-variable-it-reads.md new file mode 100644 index 0000000..323b61e --- /dev/null +++ b/docs/decisions/0015-the-print-declares-every-variable-it-reads.md @@ -0,0 +1,65 @@ +--- +status: accepted +date: 2026-09-14 +decision-makers: Marlon Kranz +--- + +# 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. +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 + +- 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`: 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, 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: 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 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. +- 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, 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. + +## 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`. 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 3681e0c..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 always 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 9c40b24..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 (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. -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..cd287eb --- /dev/null +++ b/docs/decisions/0021-operaton-engine-attributes-as-named-ir-fields.md @@ -0,0 +1,104 @@ +--- +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. + +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. +- 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 50a6598..0000000 --- a/docs/decisions/0022-engine-attributes-as-named-ir-fields.md +++ /dev/null @@ -1,74 +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. 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 040fff5..0000000 --- a/docs/decisions/0023-listeners-on-the-attribute-block.md +++ /dev/null @@ -1,87 +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. - -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..4a99baa --- /dev/null +++ b/docs/decisions/0024-event-trigger-payloads-paren-slot.md @@ -0,0 +1,76 @@ +--- +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, 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. +- 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 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 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. +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 67c0f64..0000000 --- a/docs/decisions/0024-event-triggers-on-start-end-and-throw.md +++ /dev/null @@ -1,154 +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 - -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. - -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 ca62b85..0000000 --- a/docs/decisions/0025-authoring-inclusive-and-event-based-gateways.md +++ /dev/null @@ -1,169 +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 (`InclusiveGatewayActivityBehavior.java:64-68`), and adds the default only when that set comes out empty (lines 71-83). -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`). -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.java:1549-1594`). -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. - -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.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 - -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.java:30-46`), 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, with line numbers as of the time of writing. -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. 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..25a5504 --- /dev/null +++ b/docs/decisions/0025-events-at-the-process-boundary.md @@ -0,0 +1,117 @@ +--- +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. +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. +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; 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. + +## 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..b926e2c --- /dev/null +++ b/docs/decisions/0027-boundary-events-attached-to-an-activity.md @@ -0,0 +1,96 @@ +--- +status: accepted +date: 2026-07-23 +decision-makers: Marlon Kranz +--- + +# Boundary events attached to an activity + +## Context and Problem Statement + +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). + +## 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 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. +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. +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. + +### 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 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 + +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 8d9b558..0000000 --- a/docs/decisions/0027-repetition-on-the-authoring-surface.md +++ /dev/null @@ -1,136 +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. - -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 6b28919..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 `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 9747d6e..0000000 --- a/docs/decisions/0031-carry-bpmn-documentation.md +++ /dev/null @@ -1,77 +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. - -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 97dfab5..0000000 --- a/docs/decisions/0032-field-injection-as-a-parameter-direction.md +++ /dev/null @@ -1,172 +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 - -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 788fbb4..0000000 --- a/docs/decisions/0033-call-activity-variable-mapping.md +++ /dev/null @@ -1,158 +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. - -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 e8797b8..0000000 --- a/docs/decisions/0034-multiple-start-events-on-a-process.md +++ /dev/null @@ -1,106 +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, 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. -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 the authored one: every start after the first chain prints after that chain's `end`, followed by a `goto` onto the shared 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. - -### 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. - -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 82ec762..0000000 --- a/docs/decisions/0035-link-events-for-import-round-trip-symmetry.md +++ /dev/null @@ -1,173 +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. - -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, 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. 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 132b44d..0000000 --- a/docs/decisions/0036-elide-a-synthesized-end-only-at-its-block-tail.md +++ /dev/null @@ -1,143 +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. - -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 cfbd363..0000000 --- a/docs/decisions/0037-form-field-constraints-values-and-properties.md +++ /dev/null @@ -1,131 +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-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 f20809f..0000000 --- a/docs/decisions/0038-external-task-extras-ride-the-topic-binding.md +++ /dev/null @@ -1,129 +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. - -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 aac8b90..0000000 --- a/docs/decisions/0039-import-bpmn-resource-assignment-and-report-the-quantity-attributes.md +++ /dev/null @@ -1,80 +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). - -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 d36da91..0000000 --- a/docs/decisions/0041-per-run-job-settings-on-a-repetition.md +++ /dev/null @@ -1,112 +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. - -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 f6b7468..0000000 --- a/docs/decisions/0042-mail-and-shell-as-a-type-binding.md +++ /dev/null @@ -1,120 +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. - -Related decisions: ADR-0006 (the engine-agnostic IR 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 1f662ef..0000000 --- a/docs/decisions/0043-result-variable-beside-an-expression-binding.md +++ /dev/null @@ -1,87 +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. - -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/README.md b/docs/decisions/README.md new file mode 100644 index 0000000..88f587e --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +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), 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-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 590003b..d75f13c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -11,12 +11,20 @@ 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. - **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. @@ -26,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. @@ -35,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-engine-agnostic-intermediate-representation.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. @@ -44,11 +52,13 @@ 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. ## The repository - **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/README.md b/examples/README.md index f56a776..98ce191 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 | | ----------------------- | -------------------------------------------------------------------------- | @@ -45,31 +43,20 @@ Running `bpmns build` on any of them produces the deployable `.bpmn`. | `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 -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. +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. +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. -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. +Docker tests run by default, in CI too, and are skipped only when `SKIP_DOCKER_TESTS=true`. ## 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/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/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/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/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 c41318b..0f58453 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", @@ -21,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 8199acb..4f71f8c 100644 --- a/examples/spring-boot/processes/order-rework.bpmnscript +++ b/examples/spring-boot/processes/order-rework.bpmnscript @@ -1,12 +1,7 @@ -// 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(label: "Order Rework") { +process order-rework { var approved: boolean start Received 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/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()); + } +} 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 3ceb2c3..bcdea8d 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", @@ -17,12 +18,245 @@ "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", "prettier": "^3.9.6", "shx": "~0.4.0", "typescript": "~6.0.0" + }, + "engines": { + "node": ">=22.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/@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": { @@ -32,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 @@ -625,9 +869,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": { @@ -706,9 +950,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": { @@ -902,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", @@ -909,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", @@ -930,6 +1195,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 +1681,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1204,9 +1698,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1224,9 +1715,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1244,9 +1732,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1264,9 +1749,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1284,9 +1766,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1354,6 +1833,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 +2172,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", @@ -1970,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", @@ -2058,29 +2653,225 @@ "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==", + "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": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "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": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "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": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "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 +2910,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", @@ -2381,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", @@ -2421,6 +3241,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 +3405,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 +3473,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 +3570,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 +3587,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 +3813,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 +4199,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 +4247,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 +4430,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 +4474,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", @@ -4005,9 +4975,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": { @@ -4076,9 +5046,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": { @@ -4759,9 +5729,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": { @@ -4928,6 +5898,67 @@ "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/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", + "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 +6184,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", @@ -5222,6 +6269,25 @@ "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-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -5430,6 +6496,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 +6526,63 @@ "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", + "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 +6668,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 +6698,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 +6737,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 +7047,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5879,9 +7068,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5903,9 +7089,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5927,9 +7110,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6021,6 +7201,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 +7250,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 +7294,47 @@ "@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", + "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 +7369,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 +7478,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 +7733,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 +7850,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 +7914,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 +8110,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 +8162,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", @@ -6851,9 +8210,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": { @@ -7060,6 +8419,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 +8549,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 +8569,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 +8702,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 +9133,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 +9248,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 +9392,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 +9500,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 +9545,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 +9627,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 +9641,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 +10175,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 +10225,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 +10322,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", @@ -8849,6 +10386,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": "*", @@ -8882,6 +10420,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,17 +10428,19 @@ }, "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": { "name": "@bpmn-script/language", "version": "0.0.1", + "license": "Apache-2.0", "dependencies": { "langium": "^4.3.1", "vscode-languageserver-types": "^3.18.3" @@ -8922,6 +10463,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", @@ -8938,6 +10480,7 @@ }, "tests": { "version": "0.0.1", + "license": "Apache-2.0", "devDependencies": { "@bpmn-script/language": "0.0.1", "@bpmn-script/transform": "0.0.1", @@ -8947,7 +10490,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..0ea6c49 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", @@ -19,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/README.md b/packages/cli/README.md index 546f99a..8721bb7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -25,14 +25,22 @@ 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. +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. 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. +`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. @@ -55,8 +63,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..6f49527 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,13 @@ { "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": { + "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 225ad7a..0cb26be 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -1,136 +1,103 @@ import { - createBpmnScriptServices, BpmnScriptLanguageMetaData, + Diagnostic, + DiagnosticSeverity, } 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'; - -export type BuildOptions = { - output?: string; -}; - -const SEVERITY_ERROR = 1; -const SEVERITY_WARNING = 2; +import { + astToIr, + irToXml, + LayoutError, + NO_PROCESS_MESSAGE, +} from '@bpmn-script/transform'; +import { + CLI_VERSION, + type CommandOptions, + buildDocument, + fail, + formatDiagnostic, + guardOutputPath, + resolveInputPath, + resolveOutputPath, + warn, + writeOutput, +} from './util.js'; export async function buildAction( fileName: string, - opts: BuildOptions, + opts: CommandOptions, ): Promise { - const resolvedInput = path.resolve(fileName); - - if (!fsSync.existsSync(resolvedInput)) { - console.error(chalk.red(`Error: file not found: ${fileName}`)); - process.exit(2); - } + const resolvedInput = resolveInputPath(fileName); + // 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))) { - console.error( - chalk.yellow( - `Warning: expected a file with one of these extensions: ${extensions.join(', ')}`, - ), + fail( + 2, + `Error: expected a file with one of these extensions: ${extensions.join(', ')}; ` + + 'a .bpmn file is decompiled with `bpmns parse`', ); } 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( - `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( - (d) => d.severity === SEVERITY_ERROR, + (d) => d.severity === DiagnosticSeverity.Error, ); 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); } + // 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) { - console.error( - chalk.yellow( - `Warning: line ${diag.range.start.line + 1}: ${diagnosticMessage(diag)}`, - ), + warn( + `Warning: line ${diag.range.start.line + 1}: ${Diagnostic.getMessageString(diag)}`, ); } - 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}`, - ), - ); - process.exit(1); + fail(1, `Error: ${(err as Error).message}`); } - 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) { - console.error( - chalk.red( - `Error: IR to XML conversion failed: ${(err as Error).message}`, - ), - ); - process.exit(1); - } - - 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}`, - ), + if (!(err instanceof LayoutError)) { + fail(1, `Error: ${(err as Error).message}`); + } + // 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`, ); - process.exit(2); } + await writeOutput(outPath, xml); console.log(chalk.green(`Built: ${outPath}`)); } 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..b01a83f 100644 --- a/packages/cli/src/parse.ts +++ b/packages/cli/src/parse.ts @@ -1,43 +1,53 @@ +import { DiagnosticSeverity } 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 { xmlToIr, irToDsl, + 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 { resolveOutputPath } from './util.js'; - -export type ParseOptions = { - output?: string; -}; +import { + type CommandOptions, + buildDocument, + fail, + formatDiagnostic, + guardOutputPath, + resolveInputPath, + resolveOutputPath, + warn, + writeOutput, +} from './util.js'; export async function parseAction( fileName: string, - opts: ParseOptions, + opts: CommandOptions, ): Promise { - const resolvedInput = path.resolve(fileName); - - if (!fsSync.existsSync(resolvedInput)) { - console.error(chalk.red(`Error: file not found: ${fileName}`)); - process.exit(2); - } - + const resolvedInput = resolveInputPath(fileName); const outPath = resolveOutputPath(resolvedInput, '.bpmnscript', opts.output); + guardOutputPath(resolvedInput, outPath, opts); let xml: string; try { xml = await fs.readFile(resolvedInput, 'utf-8'); } catch (err) { - console.error( - chalk.red(`Error: could not read ${fileName}: ${(err as Error).message}`), + fail(2, `Error: could not read ${fileName}: ${(err as Error).message}`); + } + + const problem = xmlInputProblem(xml); + if (problem === EMPTY_INPUT_MESSAGE) fail(2, `Error: ${problem}`); + if (problem !== undefined) { + fail( + 2, + `Error: ${problem}; a .bpmnscript file is built with \`bpmns build\``, ); - process.exit(2); } let ir; @@ -47,43 +57,30 @@ 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 ${SERVICE_TASK_FORM_ATTRIBUTES}.`, ); - 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}: ${(err as Error).message}`, - ), + fail( + 2, + `Error: failed to parse ${fileName}: ` + + readableParseError((err as Error).message, xml), ); - process.exit(2); } let dsl: string; @@ -91,33 +88,29 @@ 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}`, - ), - ); - 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. + // Several messages do not name their step, so the id tells them apart. for (const w of [...warnings, ...printWarnings]) { - console.error(chalk.yellow(`Warning: ${w.elementId}: ${w.message}`)); + warn(`Warning: ${w.elementId}: ${w.message}`); + } + + // 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 === DiagnosticSeverity.Error, + ); + if (buildErrors.length > 0) { + warn( + `Warning: the printed script draws ${buildErrors.length} error(s) ` + + 'when built; hand-repair is needed:', + ); + for (const diag of buildErrors) { + warn(formatDiagnostic(rebuilt, diag)); + } } } diff --git a/packages/cli/src/util.ts b/packages/cli/src/util.ts index 2388ad7..3d67449 100644 --- a/packages/cli/src/util.ts +++ b/packages/cli/src/util.ts @@ -1,41 +1,107 @@ -import { readFileSync } from 'node:fs'; +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'; +import { URI } from 'langium'; +import { NodeFileSystem } from 'langium/node'; +import { createBpmnScriptServices, Diagnostic } from '@bpmn-script/language'; -/** Read off the document so the shape is Langium's own, not a restatement. */ -type Diagnostic = NonNullable[number]; - -/** - * 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. - */ -export function diagnosticMessage(diagnostic: Diagnostic): string { - return typeof diagnostic.message === 'string' - ? diagnostic.message - : 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. - */ +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)); +} + +// 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'), ) 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; +} + export function resolveOutputPath( resolvedInput: string, 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 for the file. + return existsSync(resolved) && statSync(resolved).isDirectory() + ? path.join(resolved, `${base}${defaultExt}`) + : resolved; +} + +export type CommandOptions = { output?: string; force?: boolean }; + +export function guardOutputPath( + resolvedInput: string, + outPath: string, + opts: CommandOptions, +): void { + if (outPath === resolvedInput) { + fail(2, 'Error: the input and the output are the same file'); + } + if (!opts.force && existsSync(outPath)) { + 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}`, + ); + } +} + +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; +} + +export function formatDiagnostic( + document: LangiumDocument, + diagnostic: Diagnostic, +): string { + return ( + ` 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 20456d4..a4b4a95 100644 --- a/packages/cli/test/build-parse.smoke.test.ts +++ b/packages/cli/test/build-parse.smoke.test.ts @@ -1,12 +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'; @@ -16,15 +13,24 @@ import { xmlToIr } from '@bpmn-script/transform'; import { expectMentions, + runActionAt, runBuild, 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 = fileURLToPath(new URL('../../..', import.meta.url)); -const REPO_ROOT = path.resolve(__dirname, '../../..'); +// Read here rather than imported: util.ts's export is what this 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, @@ -53,13 +59,12 @@ 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 = ` - + @@ -73,14 +78,13 @@ 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 = ` - + @@ -97,15 +101,36 @@ const TWO_DROPPED_CONDITIONS_BPMN = ` `; +// 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[]; - /** Substrings the warnings must carry; the wording is the transform's own. */ mentions?: string[]; - /** Substrings the written script must carry. */ script?: string[]; /** Set where the warnings differ in nothing but the element they name. */ sameMessage?: boolean; + /** The re-validation lines after the warnings, whole; a RegExp where the line number is not pinned. */ + buildErrorLines?: (string | RegExp)[]; }; type ParseRow = readonly [ @@ -136,34 +161,56 @@ describe('bpmns parse', () => { { 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'], }, ], + [ + '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( + 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); 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); }); @@ -181,6 +228,9 @@ const WARNING_ONLY_SOURCE = `process warning-only { } `; +// 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. const TYPE_MISMATCH_SOURCE = `process type-mismatch { var amount: string @@ -194,11 +244,8 @@ 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[]; }; @@ -230,6 +277,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); @@ -243,25 +299,288 @@ 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}"`, + ]); + }); +}); + +const VALID_DSL = 'process guard { start S end E }'; + +const VALID_BPMN = ` + + + + + + +`; + +// Over 40 characters, so the preview's cutoff shows, and accented, which the +// preview keeps. +const SWAPPED_DSL = + 'process rechnung_prüfen { start S user Prüfung(assignee: "demo") end E }'; + +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: 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: Action, + make: (dir: string) => GuardCase, +]; + +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', + 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, outputPath } = writeInput(dir, 'build', true); + return { + inputPath, + opts: { force: true }, + exit: undefined, + check: () => + expect(fs.readFileSync(outputPath, 'utf-8')).toContain( + WRITTEN.build, + ), + }; + }, + ], + [ + 'build: an -o equal to the input path is refused, and the source is left untouched', + 'build', + (dir) => { + 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(source), + }; + }, + ], + [ + '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 } = writeInput(dir, 'build'); + 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(WRITTEN.build), + }; + }, + ], + refusedFile( + 'build: a wrong extension is refused outright, not warned about then crashed on', + 'build', + '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', + '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', + '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', + 'empty.bpmn', + '', + 2, + 'Error: the file is empty', + ), + refusedFile( + 'parse: binary input is reported, not dumped onto stderr', + 'parse', + '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', + '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', + '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', + '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', + 'unclosed.bpmn', + UNCLOSED_TAG, + 2, + (inputPath) => + `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 { + 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', () => { + 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).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 new file mode 100644 index 0000000..37815a2 --- /dev/null +++ b/packages/cli/test/build.layout-guard.test.ts @@ -0,0 +1,25 @@ +// Its own file because `vi.mock` applies to the whole file. +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..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,22 +31,22 @@ 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, and the shape the desugarer reserves for -// the ids it generates, so a script cannot spell either one back. -const GENERATED_ID_LABELS_BPMN = ` +const labeledTerminalsBpmn = ( + startId: string, + endId: string, +): string => ` - + - - - + + + `; -// 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 { @@ -66,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', @@ -89,143 +79,113 @@ 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); - }); - - 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 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", '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); - }); - - 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'); - }); + [ + 'start StartEvent_1(label: "Order received")', + 'end EndEvent_1(label: "Order filed")', + ], + [], + 0, + ], + [ + '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', + ['end EndEvent_generated-id-labels(label: "Order filed")'], + ['StartEvent_generated-id-labels'], + 1, + ], + ])( + 'the DSL produced from a diagram where %s', + async ( + _title, + startId, + endId, + printedLines, + warnedIds, + diagnosticCount, + ) => { + const { ir, warnings } = await xmlToIr( + labeledTerminalsBpmn(startId, endId), + ); + const dsl = irToDsl(ir).source; + + const { document, diagnostics } = await validate(dsl); + expect(document.parseResult.parserErrors).toHaveLength(0); + expect(diagnostics).toHaveLength(diagnosticCount); + 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); + } + }, + ); }); describe('decompile contract: language integrity', () => { @@ -247,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 94a8988..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 { @@ -26,20 +22,44 @@ 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 }; type Action = ( fileName: string, - opts: { output?: string }, + opts: CommandOptions, ) => Promise; +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(() => {}), + // 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); + }), + ]; + + 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 +75,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,15 +91,22 @@ async function run( } } -/** Compiles a `.bpmnscript` source to BPMN, as `bpmns build` does. */ +/** Runs on caller-owned paths: nothing is invented or cleaned up. */ +export const runActionAt = ( + action: 'build' | 'parse', + inputPath: string, + opts: CommandOptions, +): Promise> => + capture(() => + (action === 'build' ? buildAction : parseAction)(inputPath, opts), + ); + 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 deleted file mode 100644 index 509413d..0000000 --- a/packages/cli/test/util.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as path from 'node:path'; -import { - diagnosticMessage, - resolveOutputPath, - CLI_VERSION, -} from '../src/util.js'; - -describe('resolveOutputPath', () => { - it.each([ - // No override: the default extension replaces the input's own. - [ - '/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) => { - expect(resolveOutputPath(path.resolve(input), '.bpmn', override)).toBe( - path.resolve(expected), - ); - }); -}); - -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'); - }); -}); - -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/.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..aba0fe4 100644 --- a/packages/extension/README.md +++ b/packages/extension/README.md @@ -1,27 +1,30 @@ # 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-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 -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. 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), 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. @@ -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. @@ -45,12 +49,23 @@ 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). +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. 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/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/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/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 0000000..02a4ba0 Binary files /dev/null and b/packages/extension/media/icon.png differ 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 8af5959..3131c43 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -2,10 +2,18 @@ "name": "vscode-bpmnscript", "description": "VS Code extension for BPMNscript, bundling the Langium language server", "version": "0.0.1", - "displayName": "bpmnscript", + "displayName": "BPMNscript", + "publisher": "bpmnscript", + "license": "Apache-2.0", + "icon": "media/icon.png", + "repository": { + "type": "git", + "url": "https://github.com/BPMNscript/bpmnscript.git", + "directory": "packages/extension" + }, "type": "module", "engines": { - "vscode": "^1.100.0" + "vscode": "^1.134.0" }, "categories": [ "Programming Languages" @@ -16,7 +24,7 @@ { "id": "bpmn-script", "aliases": [ - "BpmnScript", + "BPMNscript", "bpmn-script" ], "extensions": [ @@ -52,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" } ], @@ -95,13 +103,14 @@ "scripts": { "clean": "shx rm -fr *.tsbuildinfo out syntaxes", "vscode:prepublish": "npm run build", - "build:prepare": "shx mkdir -p ./syntaxes/ && shx cp -f ../language/syntaxes/bpmn-script.tmLanguage.json ./syntaxes/bpmn-script.tmLanguage.json && shx cp -f ./injection/bpmn-script.injection.tmLanguage.json ./syntaxes/bpmn-script.injection.tmLanguage.json", + "build:prepare": "shx mkdir -p ./syntaxes/ && shx cp -f ../language/syntaxes/bpmn-script.tmLanguage.json ./syntaxes/bpmn-script.tmLanguage.json && shx cp -f ./injection/bpmn-script.injection.tmLanguage.json ./syntaxes/bpmn-script.injection.tmLanguage.json && shx cp -f ../../LICENSE ./LICENSE && shx cp -f ../../CHANGELOG.md ./CHANGELOG.md", "build": "npm run build:prepare && tsc -b tsconfig.json && node esbuild.mjs", "build:post": "npm run build:prepare && node esbuild.mjs", "build:clean": "npm run clean && npm run build", "watch": "npm run build:prepare && concurrently -n tsc,esbuild -c blue,yellow \"tsc -b tsconfig.json --watch\" \"node esbuild.mjs --watch\"", "typecheck": "tsc -b tsconfig.test.json", - "test": "vitest run" + "test": "vitest run", + "package": "vsce package" }, "dependencies": { "@bpmn-script/language": "0.0.1", @@ -110,6 +119,7 @@ }, "devDependencies": { "@types/vscode": "~1.134.0", + "@vscode/vsce": "^4.0.0", "concurrently": "^10.0.5", "esbuild": "~0.28.2", "vitest": "^4.1.11" diff --git a/packages/extension/src/extension/conversion-core.ts b/packages/extension/src/extension/conversion-core.ts index f8f6939..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'; @@ -10,11 +13,14 @@ import { irToXml, xmlToIr, irToDsl, + NO_PROCESS_MESSAGE, + readableParseError, + xmlInputProblem, UnsupportedConstructError, + LayoutError, } 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; @@ -26,7 +32,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 }; @@ -35,22 +41,17 @@ export type DecompileResult = | { ok: false; kind: 'unsupported'; message: string } | { ok: false; kind: 'error'; message: string }; -const SEVERITY_ERROR = 1; - -// Built once per module load: creating the services is expensive. const { shared } = createBpmnScriptServices(EmptyFileSystem); let nextDocId = 0; export async function compileDslToBpmn( source: string, - sourceFileName: string, exporterVersion: string, ): 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 cross-references resolve, removed so the index does not grow. const doc = shared.workspace.LangiumDocumentFactory.fromString( source, uri, @@ -61,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) => ({ @@ -69,17 +70,21 @@ 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 }; } - const ast = doc.parseResult.value as Model; + // 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 }; + } + let ir; try { - ir = astToIr(ast); + ir = astToIr(doc.parseResult.value); } catch (err) { return { ok: false, @@ -89,17 +94,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,8 +128,12 @@ export async function compileDslToBpmn( export async function decompileBpmnToDsl( xml: string, - _sourceFileName: string, // unused; keeps the signature parallel to compile ): Promise { + const problem = xmlInputProblem(xml); + if (problem !== undefined) { + return { ok: false, kind: 'error', message: problem }; + } + let ir; let warnings: ImportWarning[]; try { @@ -123,10 +142,11 @@ export async function decompileBpmnToDsl( if (err instanceof UnsupportedConstructError) { return { ok: false, kind: 'unsupported', message: err.message }; } + // A parser error quotes the rest of the document, too long for a notification. return { ok: false, kind: 'error', - message: (err as Error).message, + message: readableParseError((err as Error).message, xml), }; } @@ -145,8 +165,7 @@ 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. +// 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 4b43ea5..64d6710 100644 --- a/packages/extension/src/extension/conversion.ts +++ b/packages/extension/src/extension/conversion.ts @@ -5,13 +5,16 @@ import { decompileBpmnToDsl, swapExtension, } from './conversion-core.js'; -import type { ConvDiagnostic } 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. +// 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(), @@ -23,11 +26,32 @@ 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); } catch { - // stat throws when the target does not exist: nothing to overwrite. return true; } const answer = await vscode.window.showWarningMessage( @@ -38,143 +62,109 @@ async function confirmOverwrite(outputUri: vscode.Uri): Promise { 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, +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; } -export function compileCommand( - diagnostics: vscode.DiagnosticCollection, - 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.', - ); +export function compileCommand(extensionVersion: string): ConvertHandler { + return async (uri) => { + const source = await readSource( + uri, + '.bpmnscript', + 'Decompile to BPMNscript', + ); + if (!source) { return undefined; } - const sourceFileName = path.basename(sourceUri.fsPath); - const text = await readText(sourceUri); - - const result = await compileDslToBpmn( - text, - sourceFileName, - 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); - - if (!(await confirmOverwrite(outputUri))) { - return undefined; + const result = await compileDslToBpmn(source.text, extensionVersion); + if (!result.ok) { + if (result.kind === 'validation') { + // 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( + `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}`, + ); } - - 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)}"`, - ); - - return outputUri; - } else if (result.kind === 'validation') { - diagnostics.set(sourceUri, result.diagnostics.map(toVsDiagnostic)); - 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( - diagnostics: vscode.DiagnosticCollection, -): (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); - const text = await readText(sourceUri); - - const result = await decompileBpmnToDsl(text, sourceFileName); - - diagnostics.delete(sourceUri); - - 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), - ); - - 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') { + const result = await decompileBpmnToDsl(source.text); + if (!result.ok) { await vscode.window.showErrorMessage( - `BPMNscript: "${sourceFileName}" contains an unsupported construct: ${result.message}`, + result.kind === 'unsupported' + ? `BPMNscript: "${source.name}" contains an unsupported construct: ${result.message}` + : `BPMNscript: Failed to decompile "${source.name}": ${result.message}`, ); return undefined; - } else { - await vscode.window.showErrorMessage( - `BPMNscript: Failed to decompile "${sourceFileName}": ${result.message}`, + } + + const outputUri = await writeOutput( + source, + '.bpmnscript', + 'Decompiled', + result.output, + ); + if (outputUri && result.warnings.length > 0) { + // Several messages do not name their step, so the id tells them apart. + 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}`, ); - 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/main.ts b/packages/extension/src/extension/main.ts index b560d27..f313bfa 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( @@ -51,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); @@ -92,7 +88,7 @@ async function startLanguageClient( const client = new LanguageClient( 'bpmn-script', - 'BpmnScript', + 'BPMNscript', serverOptions, clientOptions, ); diff --git a/packages/extension/src/extension/sidebar-view-provider.ts b/packages/extension/src/extension/sidebar-view-provider.ts index a21a3a9..a2319ec 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). - +// media/sidebar.js reads this shape untyped: change both. interface Counterpart { uri: string; name: string; @@ -41,7 +40,6 @@ 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. const disposables: vscode.Disposable[] = []; webviewView.webview.onDidReceiveMessage( (message: unknown) => void this._handleMessage(message), @@ -50,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; }); } @@ -158,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 1790125..74b34c6 100644 --- a/packages/extension/test/bundled-conversion.e2e.test.ts +++ b/packages/extension/test/bundled-conversion.e2e.test.ts @@ -1,10 +1,6 @@ -// 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. 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'; @@ -17,30 +13,20 @@ 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. -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 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 +35,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', @@ -70,7 +58,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); });', @@ -78,8 +66,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 +77,6 @@ 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. 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 bdc25bb..1ab55fb 100644 --- a/packages/extension/test/conversion-core.test.ts +++ b/packages/extension/test/conversion-core.test.ts @@ -1,12 +1,7 @@ -// 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. - 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'; @@ -17,40 +12,21 @@ 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', ); -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 +34,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 +42,7 @@ 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. - */ +// The wording belongs to the validator, the rest is this module's mapping. type DiagnosticPosition = Omit; type CompileOutcome = @@ -91,7 +63,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 +70,6 @@ describe('compileDslToBpmn', () => { kind: 'validation', diagnostics: [ { - // 0-based, LSP convention. line: 2, character: 6, endLine: 2, @@ -111,13 +81,12 @@ 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' }, ], ])('%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) { @@ -129,8 +98,6 @@ 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. expect('output' in result).toBe(false); if (result.kind !== 'validation') return; expect( @@ -140,15 +107,53 @@ describe('compileDslToBpmn', () => { expected.diagnostics.map(() => true), ); }); + + // `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) { + 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(' { + 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', + }); + }); }); -// `formHandlerClass` and the lane are both dropped without loss of -// behavior, so `xmlToIr` warns instead of refusing. const LANE_AND_ASYNC_ATTR_BPMN = ` - + S @@ -170,7 +175,7 @@ const CONDITIONAL_START_BPMN = ` - + \${stockLevel < 5} @@ -198,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, @@ -230,7 +230,7 @@ describe('decompileBpmnToDsl', () => { { 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) { @@ -242,11 +242,8 @@ describe('decompileBpmnToDsl', () => { 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. 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), @@ -258,16 +255,29 @@ describe('decompileBpmnToDsl', () => { expect(result.kind).toBe(expected.kind); expectMentions(result.message, expected.mentions); }); -}); -describe('swapExtension', () => { + // The file picker offers every file, and the message goes into a + // notification whole. + const UNPARSABLE_TAG = ' { - expect(swapExtension(input, newExt)).toBe(expected); - }, - ); + [ + 'a long file that is not XML at all is named by a short preview of its start', + 'PK\u0003\u0004' + 'x'.repeat(300_000), + `not an XML document (starts with "PK${'x'.repeat(38)}")`, + ], + [ + 'a document the parser chokes on is cut to its first line, capped, and stripped of control characters', + UNPARSABLE_TAG, + ( + `unparsable content ${UNPARSABLE_TAG} detected`.slice(0, 200) + '...' + ).replace(/\p{C}/gu, ''), + ], + ])('%s', async (_title, xml, message) => { + expect(await decompileBpmnToDsl(xml)).toEqual({ + ok: false, + kind: 'error', + message, + }); + }); }); diff --git a/packages/extension/test/conversion.test.ts b/packages/extension/test/conversion.test.ts index d01d7aa..583c2f4 100644 --- a/packages/extension/test/conversion.test.ts +++ b/packages/extension/test/conversion.test.ts @@ -1,70 +1,50 @@ -// `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. 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 a plain top-level const. const mocks = vi.hoisted(() => ({ showWarningMessage: vi.fn(), 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(), - }, - 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('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')), }, - }; -}); + }, +})); -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'; @@ -81,27 +61,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; -} - -/** 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])); @@ -112,8 +71,16 @@ function notifications(): Record<'info' | 'warning' | 'error', string[]> { }; } -// Neither message names its own element: they say "here" and leave the id to -// the caller, so unrendered they arrive as one line repeated. +function shownDocuments(): string[] { + return mocks.showTextDocument.mock.calls.map( + (call) => (call[0] as vscode.Uri).fsPath, + ); +} + +function executedCommands(): string[] { + return mocks.executeCommand.mock.calls.map((call) => String(call[0])); +} + const SAME_WORDING = 'The model weighs the route on from here with a condition, ' + 'which the script leaves out.'; @@ -122,24 +89,21 @@ type Expected = { info?: string[]; warning?: string[]; 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[][]; + shown?: string[]; + executed?: string[]; }; type Row = readonly [ title: string, command: 'compile' | 'decompile', - result: CompileResult | DecompileResult, + // undefined: no uri and no active editor. + sourceName: string | undefined, + // undefined: refused before the core 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(); @@ -147,32 +111,9 @@ 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', - { - 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', - }, - ], - [ - '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', { ok: true, output: 'process P { start S end E }', @@ -196,11 +137,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 +158,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,57 +181,91 @@ 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(); + [ + '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 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', + '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 returned = await handler( + sourceName === undefined + ? undefined + : 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 ?? []); - - // 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); - } + const core = command === 'compile' ? compileDslToBpmn : decompileBpmnToDsl; + expect(core).toHaveBeenCalledTimes(result === undefined ? 0 : 1); }); }); diff --git a/packages/extension/test/injection-grammar.test.ts b/packages/extension/test/injection-grammar.test.ts index 3d4e336..2e880cb 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,6 @@ 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. const EMBEDDED_SCOPE_BY_FORMAT: Readonly> = { javascript: 'source.js', python: 'source.python', @@ -18,8 +15,8 @@ const EMBEDDED_SCOPE_BY_FORMAT: Readonly> = { groovy: 'source.groovy', }; -// Tags deliberately left unhighlighted: VS Code ships no grammar for them. -const NO_INSTALLED_GRAMMAR = new Set(['feel']); +// VS Code ships no grammar for these. +const NO_INSTALLED_GRAMMAR = new Set(['feel', 'juel']); const EXTENSION_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -54,70 +51,51 @@ 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(entry.injectTo).toEqual(['source.bpmn-script']); expect(pkg.scripts['build:prepare']).toContain( - './injection/bpmn-script.injection.tmLanguage.json', - ); - 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(); }); // 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/language-configuration.test.ts b/packages/extension/test/language-configuration.test.ts new file mode 100644 index 0000000..f9d8c01 --- /dev/null +++ b/packages/extension/test/language-configuration.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +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'), + 'utf8', + ); + return JSON.parse(raw.replace(/^\s*\/\/.*$/gm, '')).wordPattern; +} + +describe('language configuration', () => { + it("the editor's word pattern is the grammar's ID terminal", () => { + expect(readWordPattern()).toBe(ID_TERMINAL.source); + }); +}); 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 174ac5a..8c95cb7 100644 --- a/packages/language/README.md +++ b/packages/language/README.md @@ -4,20 +4,21 @@ 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 -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`, `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. ```bpmnscript process invoice-approval { @@ -34,40 +35,52 @@ 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")`. -A `documentation` setting carries free-form text alongside it, spelled the same way: `user ReviewInvoice(documentation: "Escalate to the senior approver above 1000.")`. +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. +A `documentation` setting carries free-form text alongside `label`, spelled the same way: `user ReviewInvoice(documentation: "Escalate to the senior approver above 1000.")`. ### 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 @@ -76,7 +89,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` | @@ -87,57 +100,28 @@ 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. +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. - -#### 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, joinJobPriority: 5) { - user AuditReview(assignee: "auditor") - } else { - user SkipAudit(assignee: "clerk") - } - - parallel (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. +`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. #### 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 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 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-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. @@ -145,22 +129,28 @@ 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 | -| ---------------------------------- | ----------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------ | -| `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. +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. @@ -198,9 +188,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 +205,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 @@ -238,16 +231,20 @@ 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. -`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 +287,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 +310,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 +336,9 @@ 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`. +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 @@ -357,7 +357,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 +388,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. @@ -396,8 +398,9 @@ 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. -`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. +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. ```bpmnscript @@ -413,10 +416,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 +444,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 { @@ -468,7 +473,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. @@ -543,7 +547,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. @@ -585,7 +589,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 @@ -613,13 +617,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 @@ -662,6 +666,8 @@ 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. @@ -682,6 +688,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. @@ -781,10 +789,47 @@ 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. -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 +837,33 @@ 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. + 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. 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`, 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. -- 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-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`), 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 = `. @@ -848,7 +898,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'; ``` @@ -868,26 +918,30 @@ 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 -| 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` | Unquotes a timer's `"${...}"` time so both `time` alternatives carry the same shape | -| `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..82fc36f 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,7 +1,13 @@ { "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": { + "type": "git", + "url": "https://github.com/BPMNscript/bpmnscript.git", + "directory": "packages/language" + }, "type": "module", "engines": { "node": ">=22.0.0", @@ -28,9 +34,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..6660ea5 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,52 @@ 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, + CodeDecl, + ErrorMapping, + Listener, + LiteralBool, + OnHandler, + VarRef, + type Block, + type ParenItem, +} 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_SETTING_KEYS, + BOUNDARY_TRIGGERS, CALL_BINDING_VALUES, CATCH_TRIGGERS, DECLARED_CODE_TRIGGERS, @@ -22,17 +75,26 @@ import { ENGINE_KEYS, ERROR_MAPPING_HEAD, ERROR_MAPPING_WHEN, - EVENT_BINDING_FIELDS, + EVENT_CODE_FIELD, + EVENT_MESSAGE_FIELD, + eventBindingFieldsFor, EXECUTION_LISTENER_EVENTS, + EXTERNAL_BINDING_KEY, + FIELD_BINDING_KEYS, FIELD_DIRECTION, FORM_FIELD_SETTING_KEYS, + FORM_FIELD_TYPES, gatewayStatementRuleOf, + HANDLER_START_TRIGGERS, + INPUT_DIRECTION, JOIN_ENGINE_KEYS, joinSettingKey, + JUEL_LITERAL_WORDS, LISTENER_BINDING_KEYS, listenerEventsFor, namesACode, ON_TRIGGERS, + OUTPUT_DIRECTION, parameterDirectionsFor, PROCESS_HEADER_KEYS, PROPERTY_DIRECTION, @@ -41,11 +103,14 @@ import { runSettingKey, SCRIPT_FORMAT_ALIASES, START_TRIGGERS, + THROW_BINDING_TRIGGER, THROW_TRIGGERS, TIMER_PARTICLE_BY_KIND, TRIGGER_PAYLOAD, TYPE_BINDING_KEY, TYPE_BINDING_VALUES, + VAR_TYPES, + type EngineKey, } from './vocabulary.js'; interface StructureForm { @@ -57,36 +122,20 @@ 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. - */ +/** 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}")', @@ -112,11 +161,8 @@ 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 is written bare, a subscription name quoted; timer and condition are + // scaffolded at the trigger word. on: [ { label: 'on', @@ -157,8 +203,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', @@ -176,15 +220,13 @@ 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}' }, ], }; -/** 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|}', @@ -192,10 +234,9 @@ 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 string[] = ENGINE_KEYS, + keys: readonly EngineKey[] = ENGINE_KEYS, ): Record => Object.fromEntries( keys.map((key) => [ @@ -205,10 +246,8 @@ 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 IDs; the `\$` escapes keep an EL `${...}` from + * opening a nested placeholder. */ const SETTING_SNIPPETS: Readonly> = { label: 'label: "${1:label}"', @@ -232,7 +271,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}"', @@ -248,9 +287,9 @@ 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}"', - // A form field's parens. The two 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}', @@ -261,69 +300,127 @@ const SETTING_SNIPPETS: Readonly> = { pattern: 'pattern: "${1:dd/MM/yyyy}"', }; -/** - * 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, 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}`, }; -const settingForms = (keys: readonly string[]): StructureForm[] => - keys.map((key) => ({ label: key, insertText: SETTING_SNIPPETS[key] })); +const DECLARATION_FIELD_SNIPPETS: Readonly> = { + [EVENT_CODE_FIELD]: `${EVENT_CODE_FIELD}: "\${1:code}"`, + [EVENT_MESSAGE_FIELD]: `${EVENT_MESSAGE_FIELD}: "\${1:message}"`, +}; -/** - * 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. - */ +const settingForms = ( + keys: readonly string[], + snippets: Readonly> = SETTING_SNIPPETS, +): StructureForm[] => + keys.map((key) => ({ label: key, insertText: snippets[key] })); + +const SETTING_VALUE_CHOICES: Readonly> = { + [TYPE_BINDING_KEY]: TYPE_BINDING_VALUES.map((value) => `"${value}"`), + binding: CALL_BINDING_VALUES, + mapDecisionResult: DECISION_RESULT_MAPPINGS, +}; + +const LITERAL_WORDS: ReadonlySet = new Set(JUEL_LITERAL_WORDS); + +/** `null` has a rule of its own. */ +const BOOLEAN_LITERAL_RULE = LiteralBool.$type; + +/** 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}"`, }; -/** The keys a timer trigger takes, wherever one is written. */ -function timerKeyForms(node: AstNode): StructureForm[] { - if (!('trigger' in node) || node.trigger !== '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) || 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, + ); +} + +const itemsOf = (owner: AstNode): ParenItem[] => + (owner as { items?: ParenItem[] }).items ?? []; + +/** 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; + const rule = TRIGGER_PAYLOAD[trigger]; + return ( + rule !== undefined && + (rule.code === 'required' || + rule.timer || + rule.parens === 'condition' || + ((isThrowStatement(owner) || isEmitStatement(owner)) && + namesACode(trigger))) + ); } /** - * 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. + * 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); + 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); + +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 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 + ? (attributeBlockRuleOf(node)?.flags ?? []) + : []; +} + +/** `undefined` where `node` has no parens of its own, which `owningElement` keys on. */ 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)) { + return settingForms( + eventBindingFieldsFor(node.kind), + DECLARATION_FIELD_SNIPPETS, + ); + } const gateway = gatewayStatementRuleOf(node); if (gateway) { return settingForms([ @@ -332,57 +429,186 @@ 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; + // The implementation bindings belong to a thrown or emitted message alone. + 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)); + +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)); } +/** Its worker reads property lines and mappings. */ +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 `node`'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 listener's block holds fields and a form field's properties alone, so + * neither has a rule row. */ -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, + ); +} + +/** `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; + +/** 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) && + !BOOLEAN_SETTING_KEYS.has(key) && + !isFormField(owner) && + !isStructuralParenKey(owner, key) && + !isCodeDecl(owner) + ); +} + +/** Mirrors the stand-in Langium's default builds for an untyped reference. */ +const referenceStandInAt = (node: AstNode): VarRef => ({ + $type: 'VarRef', + $container: node as VarRef['$container'], + $containerProperty: 'ref', + ref: { $refText: '', ref: undefined }, + accessors: [], +}); + +/** 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), + )?.[1]; + +/** 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 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. + * 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) + .filter(isBlock) + .filter((block) => { + const cst = block.$cstNode; + return ( + cst !== undefined && + cst.offset < context.offset && + (cst.end > context.offset || !closesWithBrace(cst)) + ); + }) + .toArray() + .at(-1); +} + +/** + * Host-less, `compensation` belongs in a subprocess; hosted, `cancel` only on a + * transaction. 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( + innermostOpenBlockAt(context)?.$container, + ); + return HANDLER_START_TRIGGERS.filter( + (word) => word !== 'compensation' || inSubProcess, + ); + } + const host = handler.host.ref; + if (host === undefined) return words; + return BOUNDARY_TRIGGERS.filter( + (word) => + (word !== 'cancel' || isAttemptBlock(host)) && + (word !== 'escalation' || isEscalationLegalHost(host)), + ); +} + +/** + * 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. */ -const SETTING_KEY_RULE = 'ParenKey'; +/** 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'; -/** The rules whose keywords are a setting key or a flag, never a construct. */ -const SETTING_WORD_RULES = [SETTING_KEY_RULE, 'FlagWord']; +const atParenItemStart = (context: CompletionContext): boolean => + context.features.some( + (next) => ruleNameOf(next.feature) === SETTING_KEY_RULE, + ); /** - * 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. + * 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; + 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 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; @@ -402,11 +628,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 > = { @@ -420,7 +641,6 @@ const TRIGGER_PAYLOAD_SNIPPETS: Readonly< }, }; -/** The trigger words each statement takes, with the captions a word earns. */ const STATEMENT_TRIGGERS: Readonly< Record< string, @@ -462,30 +682,40 @@ const STATEMENT_TRIGGERS: Readonly< }, }; -/** Captions replacing the default one, keyed as {@link STRUCTURE_SNIPPETS} is. */ +/** 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, + detail: `declare an ${kind} code`, + insertText: + `${kind} \${1:NAME}` + + (TRIGGER_PAYLOAD[kind]?.message ? '(message: "${2:message}")' : ''), + })); + const STRUCTURE_DETAILS: Readonly> = { call: 'call another process like a function', for: 'how often the preceding step runs', }; -/** - * Snippet completions for the structural keywords and for the soft words the - * grammar leaves as plain identifiers; everything else keeps Langium's default. - */ +const FLAG_DETAIL = 'BPMNscript flag'; +const FORM_TYPE_DETAIL = 'BPMNscript form type'; +const SETTING_VALUE_DETAIL = 'BPMNscript setting value'; + export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { - /** - * Offers items for the soft words at the `trigger`, `particle`, `key`, - * `direction`, and `event` positions, which lex as plain `ID`s. - * `OnHandler.host` is absent because it is a real cross-reference, already - * offered by the inherited `completionForCrossReference`. - */ + private readonly variables: VariableSymbolProvider; + + constructor(services: BpmnScriptServices) { + super(services); + this.variables = services.references.VariableSymbolProvider; + } + 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 ( @@ -493,10 +723,41 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { ruleNameOf(next.feature) === SETTING_KEY_RULE && owner ) { - const forms = settingFormsFor(owner); - if (forms) { - this.acceptSettingSnippets(context, acceptor, forms); - return; + // 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) ?? []); + for (const form of forms) { + this.acceptSnippet(context, acceptor, { + ...form, + detail: 'BPMNscript setting', + }); + } + return; + } + // 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) + ) { + 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. + 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 ( @@ -504,7 +765,9 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { !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,17 +777,18 @@ export class BpmnScriptCompletionProvider extends DefaultCompletionProvider { this.acceptListenerEvents(context, acceptor, owner); return; } - // A mapping is offered where its row 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) { - acceptor(context, { + if (next.property === 'kind' && next.type === CodeDecl.$type) { + for (const form of CODE_DECLARATION_FORMS) { + this.acceptSnippet(context, acceptor, form); + } + return; + } + if (next.property === 'trigger' && next.type === ErrorMapping.$type) { + if (owner && mappingOffered(owner)) { + 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; @@ -532,19 +796,174 @@ 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 plain keyword item per word, or a snippet where the word carries a payload. */ + /** + * 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 { + // 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 read the + // scope where it was typed. + const placed: ReferenceInfo = { + ...refInfo, + container: { + $type: OnHandler.$type, + $container: + innermostOpenBlockAt(context) ?? 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.$type && next.property === 'code') { + // Not `owningElement`: with only the head word written the member block + // is dropped from the CST. + 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.$type && + next.property === 'ref' && + this.variablesOffered(context) + ) { + this.acceptVariables(context, acceptor); + return; + } + return super.completionForCrossReference(context, next, acceptor); + } + + /** + * Neither the default's stand-in nor the parsed mapping holds the head word + * yet, so it is supplied. + */ + private acceptMappingCodes( + context: CompletionContext, + acceptor: CompletionAcceptor, + ): void { + const standIn: AstNode & { trigger: string } = { + $type: ErrorMapping.$type, + $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), + ); + } + } + + 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)) + ); + } + + 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', + }); + } + } + + 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', + }); + } + } + + 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, + ): boolean { + if (atParenItemStart(context)) { + return boolean && conditionSlotOpen(context); + } + const key = settingKeyAt(context); + return key === undefined || (boolean && BOOLEAN_SETTING_KEYS.has(key)); + } + private acceptEventWords( context: CompletionContext, acceptor: CompletionAcceptor, @@ -567,27 +986,10 @@ 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', - 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]: @@ -595,10 +997,8 @@ 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 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}"', @@ -614,40 +1014,32 @@ 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; } 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:'; - 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', }); } } @@ -657,24 +1049,45 @@ 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; } - // 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) ?? '')) { + // 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) { + void super.completionForKeyword(context, keyword, acceptor); + } + return; + } + const rule = ruleNameOf(keyword); + // The element's vocabulary answers which keys and flags it takes, not the grammar. + 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]; @@ -682,22 +1095,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..b11ac93 100644 --- a/packages/language/src/bpmn-script-document-validator.ts +++ b/packages/language/src/bpmn-script-document-validator.ts @@ -1,18 +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, 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 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 { @@ -25,11 +15,6 @@ 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. - */ function isExpectedToResolve(source: AstNode): boolean { return !isVarRef(source) || isCodePosition(source); } @@ -40,9 +25,7 @@ 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. + // 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 new file mode 100644 index 0000000..c90cf43 --- /dev/null +++ b/packages/language/src/bpmn-script-hover.ts @@ -0,0 +1,70 @@ +/** + * Langium resolves the caret through `findDeclarations` first, so every + * reference shows its declaration with no case of its own. + */ + +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, + EVENT_CODE_FIELD, + EVENT_MESSAGE_FIELD, +} from './vocabulary.js'; +import { isVariableSymbolNode } from './bpmn-script-references.js'; +import { declaredCodeOf, settingsOf } from './paren-items.js'; + +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 name; hover quotes only a written setting. + const hasCodeSetting = settingsOf(decl.items).some( + (item) => item.key === EVENT_CODE_FIELD, + ); + const code = hasCodeSetting ? declaredCodeOf(decl) : undefined; + if (code !== undefined) line += ` with code "${code}"`; + const message = literalSetting(decl.items, EVENT_MESSAGE_FIELD); + 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..6e20fc4 100644 --- a/packages/language/src/bpmn-script-linker.ts +++ b/packages/language/src/bpmn-script-linker.ts @@ -1,13 +1,7 @@ /** - * 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. - * - * 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`. + * 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 { @@ -47,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; @@ -62,27 +52,17 @@ 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) ?? []; 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`, called once the two are - * known to differ. A handler carrying a host is never reported: the container - * walk passes through it. - */ function locateTarget( target: NamedStatement, sourceContainer: FlowContainer, @@ -94,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'); } @@ -130,10 +109,7 @@ 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. + // Advising a second declaration under the other kind would lead to the duplicate-name error. const declaredKind = declaredCodeKind(source, name); const message = declaredKind === undefined @@ -145,10 +121,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..2efa864 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,8 +27,13 @@ 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 = { references: { VariableSymbolProvider: VariableSymbolProvider; @@ -50,6 +56,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 +69,19 @@ export const BpmnScriptModule: Module< new BpmnScriptCompletionProvider(services), SemanticTokenProvider: (services) => new BpmnScriptSemanticTokenProvider(services), + HoverProvider: (services) => new BpmnScriptHoverProvider(services), + DocumentSymbolProvider: (services) => + new BpmnScriptDocumentSymbolProvider(services), + }, +}; + +/** `NodeKindProvider` is a shared service, so overriding it takes a second module. */ +export const BpmnScriptSharedModule: Module< + LangiumSharedServices, + PartialLangiumSharedServices +> = { + lsp: { + NodeKindProvider: () => new BpmnScriptNodeKindProvider(), }, }; @@ -72,6 +92,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..d9de744 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,7 @@ /** - * 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; 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 { @@ -21,41 +12,44 @@ import { import { DECLARED_CODE_TRIGGERS, formatWordList, + IO_DIRECTIONS, reservedWordsOf, } from './vocabulary.js'; 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 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'; - -/** After a statement that takes no repeat clause, the same "expecting `}`". */ 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 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. + * 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'; -/** 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. + * 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. */ function bareRuleName(ruleName: string): string { return ruleName.replace(/\u200b+$/, ''); } @@ -78,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(); @@ -85,18 +97,27 @@ 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 ( + 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); } @@ -117,13 +138,16 @@ 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. ` + '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}` ); } @@ -152,12 +176,7 @@ export class BpmnScriptParserErrorMessageProvider extends LangiumParserErrorMess return `'${word}' is not a word this position takes; write ${formatWordList(alternatives)}.`; } - /** - * 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. - */ + /** `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 new file mode 100644 index 0000000..6be8925 --- /dev/null +++ b/packages/language/src/bpmn-script-references.ts @@ -0,0 +1,145 @@ +/** + * `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 { + 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'; + +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), + ); + } + 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), + ); + } + + /** 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; + 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-scope-provider.ts b/packages/language/src/bpmn-script-scope-provider.ts index 25053b1..7758510 100644 --- a/packages/language/src/bpmn-script-scope-provider.ts +++ b/packages/language/src/bpmn-script-scope-provider.ts @@ -1,28 +1,9 @@ /** - * 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. - * - * 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. + * 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 { @@ -42,7 +23,6 @@ import { isEndEvent, isErrorMapping, isGenericTask, - isGotoStatement, isIntermediateCatchEvent, isOnHandler, isProcess, @@ -74,10 +54,8 @@ 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. + * An unnamed `throw`/`emit`/`await` gets a synthesized id, so it cannot be + * referenced or collide. */ export type NamedStatement = | StartEvent @@ -114,18 +92,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. + * Starts at `node.$container`. A hosted handler is skipped: its body compiles + * into its host's container. */ export function enclosingFlowContainer( node: AstNode, @@ -140,54 +115,34 @@ export function enclosingFlowContainer( return container; } -function isContainerScoped(context: ReferenceInfo): boolean { - return ( - (isGotoStatement(context.container) && context.property === 'target') || - (isOnHandler(context.container) && context.property === 'host') - ); -} - +/** 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; - 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; - } - 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); - } + // No outer scope: `escalation(X)` naming an error must fail, not become a second root. + const process = AstUtils.getContainerOfType( + context.container, + isProcess, + )!; + return this.createScopeForNodes( + process.decls.filter( + (decl) => isCodeDecl(decl) && decl.kind === trigger, + ), + ); } - return super.getScope(context); + // From the reference's container: a handler is itself a container. + const container = enclosingFlowContainer(context.container)!; + // 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) && + // Isolates a nested `subprocess` or host-less handler body. + enclosingFlowContainer(node) === container, + ); + 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..d093f61 100644 --- a/packages/language/src/bpmn-script-semantic-tokens.ts +++ b/packages/language/src/bpmn-script-semantic-tokens.ts @@ -1,14 +1,7 @@ /** - * 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. + * 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'; @@ -38,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 new file mode 100644 index 0000000..ac895cd --- /dev/null +++ b/packages/language/src/bpmn-script-symbols.ts @@ -0,0 +1,58 @@ +/** + * 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'; +import { + DefaultDocumentSymbolProvider, + DefaultNodeKindProvider, +} from 'langium/lsp'; +import { SymbolKind, type DocumentSymbol } from 'vscode-languageserver-types'; +import { isOnHandler } from './generated/ast.js'; + +// 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); + } +} + +/** A handler is named by its header; `alongside` tells two 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 b23eb8c..b7674fa 100644 --- a/packages/language/src/bpmn-script-validator.ts +++ b/packages/language/src/bpmn-script-validator.ts @@ -1,3 +1,9 @@ +/** + * 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 { AstUtils, type AstNode, @@ -5,6 +11,7 @@ import { type ValidationChecks, } from 'langium'; import type { + Accessor, Additive, Block, BpmnScriptAstType, @@ -67,20 +74,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 +102,20 @@ import { isOnHandler, isParallelBranch, isParallelStatement, + isParen, isParenValue, isProcess, isRaceBranch, isRaceStatement, isRawExpr, isRelational, + isScriptLiteral, isServiceTask, isStartEvent, isSubProcess, + isTernary, isThrowStatement, + isUnary, isUserTask, isVarDecl, isVariableMapping, @@ -106,13 +124,17 @@ 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, + BOOLEAN_SETTING_KEYS, BUILTIN_FIELD_NAMES, BUILTIN_FIELD_VALIDATOR, BUILTIN_REQUIRED_FIELDS, @@ -122,14 +144,21 @@ import { CATCH_TRIGGERS, DATE_PATTERN_KEY, DECLARED_CODE_TRIGGERS, + DECISION_MODIFIER_KEYS, DECISION_RESULT_MAPPINGS, EMIT_TRIGGERS, END_TRIGGERS, ENGINE_KEYS, + engineSpellings, ERROR_MAPPING_HEAD, ERROR_MAPPING_WHEN, + EVENT_BINDING_FIELD_SET, EVENT_BINDING_FIELDS, + EVENT_MESSAGE_FIELD, EXECUTION_LISTENER_EVENTS, + EXPRESSION_ANYWHERE, + EXPRESSION_OPEN, + EXTERNAL_BINDING_KEY, EXTERNAL_TASK_EL_NAME, FIELD_BINDING_KEYS, FIELD_DIRECTION, @@ -142,19 +171,25 @@ import { formatPlainWordList, formatWordList, gatewayStatementRuleOf, + ID_TEXT, IO_DIRECTIONS, JOIN_ENGINE_KEYS, JOIN_KEY_BY_ENGINE_KEY, joinSettingKey, + JUEL_LITERAL_WORDS, + JUEL_RESERVED_WORDS, LISTENER_BINDING_KEYS, listenerEventsFor, ON_TRIGGERS, + OUTPUT_DIRECTION, parameterDirectionsFor, PROCESS_HEADER_KEYS, + STARTABLE_KEY, PROPERTY_DIRECTION, RUN_ENGINE_KEYS, runSettingKey, SCRIPT_FORMAT_ALIASES, + scriptFormatOf, SERVICE_TASK_BINDING_KEYS, SHELL_FLAG_FIELDS, SHELL_FLAG_LITERALS, @@ -163,11 +198,15 @@ import { TASK_LISTENER_EVENTS, TASK_PRIORITY_KEY, THROW_BINDING_KEYS, + THROW_BINDING_TRIGGER, THROW_TRIGGERS, + TIMER_JOB_KEYS, TIMER_PARTICLES, + type TimerParticle, TRIGGER_PAYLOAD, TYPE_BINDING_KEY, TYPE_BINDING_VALUES, + USER_TASK_VERBATIM_KEYS, type AttributeBlockRule, type AttributeOwner, type BuiltinTaskType, @@ -182,6 +221,7 @@ import { import { isRepeated, type VariableSymbolProvider, + type VariableTable, } from './variable-symbol-provider.js'; export function registerValidationChecks(services: BpmnScriptServices) { @@ -212,17 +252,16 @@ export function registerValidationChecks(services: BpmnScriptServices) { EmitStatement: validator.checkEmitStatement, IntermediateCatchEvent: validator.checkIntermediateCatchEvent, ParenValue: validator.checkParenValue, + Block: validator.checkBlock, }; registry.register(checks, validator); } 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 @@ -230,75 +269,124 @@ 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), -]; +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. `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. + * 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 `${...}`. */ -const NON_VARIABLE_ATTR_KEYS: ReadonlySet = new Set([ - 'class', - 'formKey', +export const NON_VARIABLE_ATTR_KEYS: ReadonlySet = new Set([ + ...PROCESS_HEADER_KEYS, 'formRef', - 'expression', - 'delegate', - 'topic', - TYPE_BINDING_KEY, + ...BUSINESS_RULE_BINDING_KEYS, + ...DECISION_MODIFIER_KEYS, 'process', - 'binding', - 'version', - 'mapDecisionResult', - 'candidateGroups', - 'candidateUsers', - 'dueDate', - 'followUpDate', + ...USER_TASK_VERBATIM_KEYS.filter((key) => key !== USER_PRIORITY_KEY), ...engineSpellings('retryCycle'), 'resultVariable', - 'historyTimeToLive', - 'candidateStarterUsers', - 'candidateStarterGroups', 'initiator', 'validator', ...Object.values(CALL_MAPPER_KEY_BY_KIND), ]); -const BOOLEAN_ATTR_KEYS: ReadonlySet = new Set( - ['asyncBefore', 'asyncAfter', 'exclusive'].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); -/** - * Keys `BpmnParse.parsePriority` reads: a constant there must parse as an - * integer or the deployment fails, so anything else has to be an expression. - */ +const DUE_DATE_KEYS: readonly string[] = ['dueDate', 'followUpDate']; +const DUE_DATE_KEY_SET: ReadonlySet = new Set(DUE_DATE_KEYS); + +/** Parsed with `Integer.parseInt`, at deployment or at task creation. */ const PRIORITY_ATTR_KEYS: ReadonlySet = new Set([ ...engineSpellings('jobPriority'), TASK_PRIORITY_KEY, + USER_PRIORITY_KEY, ]); -/** - * 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. - */ +const RETRY_CYCLE_ATTR_KEYS: ReadonlySet = new Set( + engineSpellings('retryCycle'), +); + +/** Parsed by the engine, so a bareword or number there is unreadable to it. */ const TEXT_ATTR_KEYS: ReadonlySet = new Set([ - 'versionTag', - 'historyTimeToLive', - ...engineSpellings('retryCycle'), - 'dueDate', - 'followUpDate', + ...RETRY_CYCLE_ATTR_KEYS, + ...DUE_DATE_KEYS, ]); +/** + * `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})/; + +/** `version` names no variable, so a bare name is refused; a raw template computes one. */ +const VERSION_TEXT = /^[1-9]\d*$/; + +/** `ParseUtil.parseHistoryTimeToLive`: `PD`, else `Integer.parseInt`. */ +const HISTORY_TIME_TO_LIVE_TEXT = /^(P\d+D|\d+)$/; + +/** `ACT_RE_PROCDEF.VERSION_TAG_` is 64 wide and nothing checks the length before the insert. */ +const VERSION_TAG_MAX_LENGTH = 64; + +/** + * `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)?)?)`; + +/** 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)?)?`; + +/** + * 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})?)?)?`; + +/** + * `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}`; + +/** + * 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})$`); + +/** A point in time, or a period from now. */ +const AT_TIME_TEXT = new RegExp( + `^(${JODA_PERIOD_BODY}|${ISO_DATE_TIME_BODY})$`, +); + +/** + * 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))`; + +const EVERY_TIME_TEXT = new RegExp(`^(${REPEAT_BODY}|${CRON_BODY})$`); + +/** `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})*)$`, +); + +const NON_NEGATIVE_INT_TEXT = /^\d+$/; + +/** The letters `SimpleDateFormat` reads, a quoted run, or a non-letter. */ +const PATTERN_LETTERS_TEXT = + /^(?:'[^']*'|[^A-Za-z]|[GyYMLwWDdFEuaHkKhmsSzZX])*$/; + +/** 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); const END_TRIGGERS_SET: ReadonlySet = new Set(END_TRIGGERS); const CATCH_TRIGGERS_SET: ReadonlySet = new Set(CATCH_TRIGGERS); @@ -311,34 +399,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 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. */ 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); @@ -358,33 +437,23 @@ 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. + * Read off the block rules so the message matches what is enforced; `attempt` + * has no row of its own. */ -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.`; +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. 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 - * 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, ) @@ -392,21 +461,14 @@ 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. + * Names only the fieldless bindings written, since a listener takes neither + * `topic` nor `decision`. */ -const fieldBindingMessage = ( +export const fieldBindingMessage = ( subject: string, written: readonly string[], takes: { keys: readonly string[]; targets: string }, @@ -417,46 +479,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. - */ +/** `parseExternalServiceTask`, their one reader, runs for `operaton:type="external"` alone. */ 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[], @@ -467,41 +516,83 @@ 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} '.`; + +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.`; + +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.'; + +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.'; + +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).'; + +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.`; + +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)."; + +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).`; + +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).'; + +export const VERSION_TAG_LITERAL_MESSAGE = + "Setting 'versionTag' takes a quoted string; the engine stores the tag as written, never evaluated (BpmnParse.parseProcess)."; + +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 priorityShapeMessage = (key: string) => - `Setting '${key}' takes an integer or a "\${...}" expression; the engine refuses to deploy a constant that is not an integer.`; +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.`; -/** @param description Noun phrase with article, e.g. `'a service task'`. */ -const runWithoutClauseMessage = (key: string, description: 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).`; + +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.`; + +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. + * `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> > = { @@ -510,147 +601,257 @@ 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.'; -const unknownDirectionMessage = (word: string, legal: readonly string[]) => - `Unknown parameter direction '${word}'; write ${formatWordList(legal)}.`; +/** + * 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.`; + +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.`; + +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.'; -const FORM_KEY_AND_REF_MESSAGE = +const JUEL_RESERVED_WORD_SET: ReadonlySet = new Set( + JUEL_RESERVED_WORDS, +); + +const JUEL_RAW_TEMPLATE_WORD_SET: ReadonlySet = new Set([ + ...JUEL_RESERVED_WORDS, + ...JUEL_LITERAL_WORDS, +]); + +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. */ +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}}".`; + +type ConditionSlot = 'condition' | 'until'; + +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).`; + +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."; + +const EL_BINDING_KEYS: ReadonlySet = new Set([ + 'expression', + 'delegate', + CALL_MAPPER_KEY_BY_KIND.delegateExpression, +]); + +const CLASS_BINDING_ALTERNATIVE: Readonly> = { + class: 'delegate', + [CALL_MAPPER_KEY_BY_KIND.class]: CALL_MAPPER_KEY_BY_KIND.delegateExpression, +}; + +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', +}; + +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}: '.`; + +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 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]; + 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; +} + +export const unknownDirectionMessage = ( + word: string, + legal: readonly string[], +) => `Unknown parameter direction '${word}'; write ${formatWordList(legal)}.`; + +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[], ) => `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 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'.`; -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 valuesOnNonEnumMessage = (field: FormField) => +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).'; + +export const VALIDATOR_EMPTY_MESSAGE = + "Setting 'validator' cannot be empty; name the class or the expression it " + + 'resolves (FormValidators.createValidator).'; + +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 = ( +/** The default is converted on every render, so it deploys and the form never opens. */ +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)}.`; -const formFieldDirectionMessage = ( +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.'; + +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 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\' ' + + 'setting, or write the date to fit it.'; + +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.'; 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' @@ -658,20 +859,30 @@ 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 : integerBoundMessage(key); +/** A blank literal is the one shape `FormValidators.createValidator` refuses to deploy. */ const nonEmptyText: ValueShapeRule = (_key, value) => - isLiteralString(value) && value.value.length > 0 + isLiteralString(value) && value.value.length === 0 + ? VALIDATOR_EMPTY_MESSAGE + : undefined; + +/** 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; + } + return PATTERN_LETTERS_TEXT.test(value.value) ? undefined - : PATTERN_VALUE_MESSAGE; - -/** `validator` reads as `class:` does: a quoted class, a bare dotted name, or an expression. */ -const anyText: ValueShapeRule = () => 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> = { required: literalTrue, readonly: literalTrue, @@ -679,22 +890,50 @@ 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 >; -/** @param subject The clause or noun phrase that does take one, quoted as written. */ +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; only a template is refused. */ +const candidateStarters: ValueShapeRule = (key, value) => + isRawExpr(value) || + (isLiteralString(value) && EXPRESSION_ANYWHERE.test(value.value)) + ? candidateStarterMessage(key) + : undefined; + +/** `BpmnParse.parseProcess` reads each as text, unlike a task's `label`, which is evaluated. */ +const PROCESS_HEADER_VALUE_RULES: Readonly> = { + label: headerLiteral, + documentation: headerLiteral, + versionTag, + historyTimeToLive, + candidateStarterUsers: candidateStarters, + candidateStarterGroups: candidateStarters, + [STARTABLE_KEY]: unquotedBoolean, +}; + 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.`; } @@ -702,25 +941,49 @@ 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"'.`; -/** - * 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}. - */ +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}).`; + +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', +); + +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', +); + +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', +); + +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 }, +}; + +/** `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', @@ -741,48 +1004,100 @@ 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."; +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.'; + +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.'; + +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.'; + +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.'; + +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.`; +} + +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.`; +} + +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.`; +} + +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.`; +} + +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."; + +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.`; +} + +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.`; +} + 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 ` + @@ -790,7 +1105,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.', @@ -799,120 +1114,172 @@ 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 gatewaySettingsOnlyMessage = (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.`; +export 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).'; + +export 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: 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; + 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 + ); +} + +/** + * 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 ( + (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. + * 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)$/, /^Flow_.+_.+$/, - /^StartEvent_/, - /^EndEvent_/, /^Throw_/, /^EventSubProcess_/, /^Boundary_/, /^Catch_/, + /^EndEvent_Boundary_/, + /^StartEvent_EventSubProcess_/, + /^EndEvent_EventSubProcess_/, + /_di$/, + /^BPMNDiagram_/, + /^BPMNPlane_/, ]; -/** - * The patterns as the diagnostic spells them, so the sentence an author reads - * cannot drift from the list that rejected them. - */ +function mintedTerminalRole( + name: string, + containerId: string, +): 'start' | 'end' | undefined { + if (name === `StartEvent_${containerId}`) return 'start'; + if (name === `EndEvent_${containerId}`) return 'end'; + return undefined; +} + const RESERVED_ID_SHAPE_LIST = RESERVED_ID_PATTERNS.map( (pattern) => `'${pattern.source.replace(/^\^/, '').replace(/\$$/, '').replaceAll('.+', '...')}'`, @@ -963,11 +1330,31 @@ function statementName(stmt: Statement): 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. + * 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') { + 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 slot empty and the render throws. + try { + return renderExpression(value); + } catch { + return undefined; + } +} + +/** Error recovery leaves mandatory slots `undefined` whatever the generated types say. */ function blockStatements(block: Block | undefined): Statement[] { return block?.statements ?? []; } @@ -992,7 +1379,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); } @@ -1006,12 +1393,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 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. + * 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 ( @@ -1022,31 +1406,17 @@ function statementTerminates(stmt: Statement): boolean { ) { return true; } - 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 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)) { - 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 { @@ -1065,7 +1435,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, @@ -1077,23 +1446,44 @@ function previousFlowStatement( return 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` where the unkeyed value is a payload or has its own message. */ +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; +} + +/** `undefined` when a part is unparsed, which would stringify into a self-colliding key. */ function duplicateKey( ...parts: ReadonlyArray ): string | undefined { 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) { @@ -1114,10 +1504,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, @@ -1133,17 +1520,9 @@ 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. + * 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, @@ -1195,36 +1574,54 @@ 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. - * `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. + * 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, 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) { @@ -1246,7 +1643,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( @@ -1258,6 +1654,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); } @@ -1279,24 +1676,10 @@ 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; 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, @@ -1352,17 +1735,10 @@ 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 - * (`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. + * 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[] = []; @@ -1443,18 +1819,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) && @@ -1468,16 +1845,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; } @@ -1485,43 +1859,50 @@ 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-pattern 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' }, ); } } } - /** 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, @@ -1540,25 +1921,30 @@ export class BpmnScriptValidator { ); } - /** - * The engine execution settings are per-flow-node and have no process-wide - * form, leaving {@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); } - /** A step name repeated anywhere in the process makes `goto ` ambiguous. */ private checkDuplicateStatementNames( process: Process, named: NamedStatement[], @@ -1577,16 +1963,40 @@ export class BpmnScriptValidator { { node, property: 'name' }, ), ); + + // A step named like the process duplicates the `bpmn:process` id. + 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' }, + ); + } + } + + 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 - // 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, @@ -1595,51 +2005,62 @@ 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) && - !readsExternalTask(expr) && - !symbols.has(expr.ref.$refText) + } else if (variableUse || readsExternalTask(expr)) { + // 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' || + (expr.$containerProperty === 'source' && + isVariableMapping(container))); + if (!isBareLookupName) { + checkRenderedNames(expr, accept); + } + if (variableUse && !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 surrounding expression; the top is rendered as written. + if ( + (isExpr(container) || isAccessor(container)) && + singleTemplateBody(expr.raw) === undefined ) { - accept( - 'warning', - `Variable '${expr.ref.$refText}' is not declared. Add 'var ${expr.ref.$refText}: ' to the process.`, - { node: expr, property: 'ref' }, - ); + 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, + }); } } - // 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) { @@ -1681,7 +2102,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) { @@ -1691,7 +2112,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( @@ -1707,14 +2128,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; @@ -1745,9 +2171,27 @@ export class BpmnScriptValidator { }; /** - * Mirrors {@link checkCatchPayload}. Neither gets the timer shape warnings: a - * repeating start is a legitimate schedule, not a one-shot mistake. + * 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, + 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', + }); + } + } + } + + /** No repeat warning: a repeating start is a legitimate schedule. */ private checkStartPayload( start: StartEvent, rule: TriggerPayloadRule, @@ -1762,10 +2206,11 @@ export class BpmnScriptValidator { ); } + // `RAW_TEMPLATE` is anchored at the quote, so an opener further in lexes as a plain string. if ( start.trigger === 'message' && name !== undefined && - EXPRESSION_IN_NAME.test(name) + EXPRESSION_ANYWHERE.test(name) ) { accept('error', startMessageExpressionMessage(name), { node: payloadItemOf(start.items)!, @@ -1796,8 +2241,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)) @@ -1829,14 +2274,7 @@ export class BpmnScriptValidator { this.checkFormReference(task, accept); }; - /** - * 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 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); @@ -1859,27 +2297,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 = ( @@ -1889,32 +2319,37 @@ 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); this.checkBindingVersionExclusion(task, 'A decision step', accept); 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. - * - * @param subject The message's leading noun phrase (`"Service task 'Notify'"`). + * `parseServiceTaskLike`'s mail and shell refusals; a field's own shape is + * {@link checkField}'s and comes first. */ private checkBuiltinBinding( task: ServiceTask | SendTask | BusinessRuleTask, @@ -1930,7 +2365,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, ); @@ -1967,47 +2401,84 @@ 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 only a warning. + */ 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 === EXTERNAL_BINDING_KEY || a.key === TYPE_BINDING_KEY, ); + if (unread !== undefined) { + accept('warning', resultVariableUnreadMessage(unread.key), target); + } + } + + 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', + }); + } + } } 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 => { @@ -2015,9 +2486,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 ` + @@ -2035,7 +2504,6 @@ export class BpmnScriptValidator { ); }; - /** Agreement with a `var` of the same name lives in {@link checkFormVariableAgreement}. */ private checkFormBlocks( forms: FormBlock[], ownerDescription: string, @@ -2072,13 +2540,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) { @@ -2100,7 +2563,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') { @@ -2124,6 +2586,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) { @@ -2144,8 +2614,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); @@ -2177,7 +2646,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, @@ -2190,7 +2659,6 @@ export class BpmnScriptValidator { } } - /** @param description Sentence-starting noun phrase, e.g. `'A service task'`. */ private rejectFormBlock( forms: FormBlock[], description: string, @@ -2205,13 +2673,7 @@ 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'`. - */ + /** The caller checks duplicates over every setting in the parens. */ private checkAttributeKeys( attrs: readonly Setting[], allowed: ReadonlySet, @@ -2222,15 +2684,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. - * - * @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. - */ + /** Counts distinct keys; returns whether exactly one binding is written. */ private checkExactlyOneBinding( attrs: readonly Setting[], keys: readonly string[], @@ -2268,12 +2722,8 @@ 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` are keywords elsewhere, so they lex in any + * parens and would lower to nothing. */ private checkFlags( items: ParenItem[], @@ -2311,11 +2761,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 - * `versionTag = 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[], @@ -2326,13 +2773,11 @@ export class BpmnScriptValidator { if (!allowed.has(attr.key)) { continue; } - if (BOOLEAN_ATTR_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' }, - ); + if (BOOLEAN_SETTING_KEYS.has(attr.key) && !isLiteralBool(attr.value)) { + accept('error', booleanShapeMessage(attr.key), { + node: attr, + property: 'value', + }); } else if ( TEXT_ATTR_KEYS.has(attr.key) && !isLiteralString(attr.value) && @@ -2344,6 +2789,24 @@ export class BpmnScriptValidator { '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 +2815,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,8 +2845,8 @@ 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) { if (rule.forms) { this.checkFormBlocks(owner.forms, rule.description, accept); @@ -2386,11 +2859,6 @@ 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. - */ private checkRunSettings( owner: AttributeOwner, rule: AttributeBlockRule, @@ -2398,6 +2866,13 @@ export class BpmnScriptValidator { accept: ValidationAcceptor, ): void { if (!rule.repeats) return; + 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) { @@ -2419,11 +2894,70 @@ 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. + * 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, + 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', + }); + } + } + private checkIoParameters( owner: AttributeOwner, rule: AttributeBlockRule, @@ -2498,15 +3032,10 @@ export class BpmnScriptValidator { ); this.checkRepeatedOutput(owner, directed, accept); for (const param of directed) { - this.checkMapKeys(param.value, accept); + this.checkIoValueShapes(param, accept); } } - /** - * 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, @@ -2520,7 +3049,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 @@ -2548,7 +3076,6 @@ export class BpmnScriptValidator { } } - /** The key is namespaced by direction, so the three do not collide. */ private checkDuplicateParameters( params: readonly IoParameter[], accept: ValidationAcceptor, @@ -2565,16 +3092,7 @@ 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, placement before shape; the caller words `refusal`. */ private checkField( field: IoParameter, refusal: string | undefined, @@ -2587,15 +3105,22 @@ 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', + }); } } - /** - * 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[], @@ -2604,7 +3129,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, @@ -2614,52 +3141,53 @@ 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. + * 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 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, + ); } } } - /** An unrecognized event word stops that listener's own checks: one mistake, one diagnostic. */ private checkListeners( owner: AttributeOwner, rule: AttributeBlockRule, accept: ValidationAcceptor, ): void { - const recognized: Listener[] = []; + // 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; } - 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( @@ -2693,11 +3221,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, @@ -2705,8 +3230,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) @@ -2735,7 +3259,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, @@ -2768,10 +3291,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; 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, @@ -2781,7 +3302,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,30 +3337,54 @@ 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. */ 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, ); @@ -2851,7 +3396,12 @@ export class BpmnScriptValidator { accept: ValidationAcceptor, ): void => { this.checkGatewaySettings(stmt, accept); - this.warnIfEmptyBlock(stmt.body, "The 'while' body has no steps.", accept); + this.checkEmptyBlock( + stmt.body, + 'error', + emptyLoopBodyMessage('while'), + accept, + ); }; checkDoWhileStatement = ( @@ -2859,7 +3409,36 @@ export class BpmnScriptValidator { accept: ValidationAcceptor, ): void => { this.checkGatewaySettings(stmt, accept); - this.warnIfEmptyBlock(stmt.body, "The 'do' body has no steps.", accept); + this.checkEmptyBlock( + stmt.body, + 'error', + emptyLoopBodyMessage('do'), + accept, + ); + // 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 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) { + 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 => { @@ -2877,11 +3456,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; @@ -2907,8 +3481,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, ); @@ -2916,13 +3491,6 @@ 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. - */ private checkFallbackBranch( stmt: ParallelStatement, accept: ValidationAcceptor, @@ -2948,7 +3516,6 @@ export class BpmnScriptValidator { } } - /** A branch header carries exactly what a plain `await` does. */ checkRaceStatement = ( stmt: RaceStatement, accept: ValidationAcceptor, @@ -2956,33 +3523,39 @@ 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, ); 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( + 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. - */ checkGotoStatement = ( goto: GotoStatement, accept: ValidationAcceptor, @@ -3026,37 +3599,19 @@ export class BpmnScriptValidator { this.checkCallMappingDuplicates(call, accept); }; - /** A missing `process` has no node to attach to, so the diagnostic lands on `name`. */ 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' }, + ); } - /** - * `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, @@ -3086,11 +3641,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, @@ -3107,10 +3657,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. - */ + /** The engine's if/else-if would drop one of the two silently. */ private checkCallMapperExclusion( call: CallActivity, accept: ValidationAcceptor, @@ -3124,10 +3671,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, @@ -3149,10 +3693,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); @@ -3187,14 +3727,15 @@ export class BpmnScriptValidator { this.checkHandlerBindings(handler, accept); } - this.warnIfEmptyBlock( + this.checkEmptyBlock( handler.body, + 'warning', 'The event handler has no steps.', accept, ); }; - /** 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, @@ -3212,8 +3753,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', @@ -3248,13 +3789,6 @@ 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. - */ private checkConditionPayload( node: CatchHeader | OnHandler | StartEvent, rule: TriggerPayloadRule, @@ -3289,12 +3823,8 @@ 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. - * - * @param particleOnly The message for a keyed time where the kind takes none. + * 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, @@ -3312,15 +3842,23 @@ 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. + * `timeout` has no lifecycle transition, so its timer follows the event word; + * it fires at task creation. */ private checkListenerTimer( listener: Listener, @@ -3336,46 +3874,55 @@ 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)}.`, { 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, + known, + { node: listener, property: 'time' }, + 'warning', + accept, + ); } } - /** The shape checks are warnings because they guess at intent from the spelling. */ private checkTimerShape( - handler: OnHandler, - timer: TimerPayload, + time: string, + particle: TimerParticle, + 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.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')) { + 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: ' + @@ -3385,13 +3932,6 @@ 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. - */ private checkHandlerPlacement( handler: OnHandler, accept: ValidationAcceptor, @@ -3435,23 +3975,21 @@ 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, ): void { for (const binding of caughtBindingsOf(handler.items)) { - if (binding.field === 'message' && handler.trigger === 'escalation') { + if ( + binding.field === EVENT_MESSAGE_FIELD && + !TRIGGER_PAYLOAD[handler.trigger]!.message + ) { accept('error', ESCALATION_NO_MESSAGE_MESSAGE, { node: binding.node, 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}: '.`, @@ -3462,13 +4000,8 @@ 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`. + * 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, @@ -3530,9 +4063,9 @@ 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 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, @@ -3541,24 +4074,24 @@ export class BpmnScriptValidator { const candidates: OnHandler[] = []; for (const node of AstUtils.streamAst(process)) { if (!isOnHandler(node)) 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 (!ON_TRIGGERS_SET.has(node.trigger)) continue; + // 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); } - // 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 +4100,25 @@ export class BpmnScriptValidator { 'error', handler.trigger === 'compensation' ? COMPENSATION_DUPLICATE_MESSAGE - : handlerDuplicateMessage(handler), + : handlerDuplicateMessage(handler, scope), { node: handler, property: 'trigger' }, ), ); + // 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) { + 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); + } } } @@ -3615,11 +4163,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, @@ -3640,7 +4184,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, @@ -3650,13 +4193,19 @@ 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. + * 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 => { - 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 }); } }; @@ -3688,7 +4237,6 @@ export class BpmnScriptValidator { ); } - /** Mirrors {@link checkHandlerPayload} without bindings and `alongside`. */ private checkCatchPayload( catchEvent: CatchHeader, rule: TriggerPayloadRule, @@ -3716,15 +4264,8 @@ 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; a name a step also uses stays legal, since + * references resolve by type. */ private checkCodeDecls(process: Process, accept: ValidationAcceptor): void { const declaredNames = new Set(); @@ -3743,10 +4284,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', @@ -3764,9 +4302,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', @@ -3776,10 +4311,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, @@ -3801,7 +4332,10 @@ 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 === EVENT_MESSAGE_FIELD && + !TRIGGER_PAYLOAD[decl.kind]!.message + ) { accept('error', ESCALATION_NO_MESSAGE_MESSAGE, { node: setting, property: 'key', @@ -3827,19 +4361,28 @@ function collectExpressions(process: Process): Expr[] { return AstUtils.streamAst(process).filter(isExpr).toArray(); } -/** - * 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. - */ -export function isReservedName(name: string): boolean { - return RESERVED_ID_PATTERNS.some((re) => re.test(name)); +/** 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)) || + (containerId !== undefined && + mintedTerminalRole(name, containerId) !== undefined) + ); +} + +/** 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, + (n): n is Process | SubProcess => isProcess(n) || isSubProcess(n), + ); + return container?.name; } 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, @@ -3848,11 +4391,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, ); } @@ -3862,17 +4404,8 @@ 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 { + const spellable = ID_TEXT.test(text); const declaration = spellable ? `${trigger} ${text}` : `${trigger} (code: ${JSON.stringify(text)})`; @@ -3883,11 +4416,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 ` + @@ -3900,11 +4428,10 @@ function unknownTriggerMessage(word: string, legal: readonly string[]): string { } /** - * 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. + * A mistyped step keyword followed by a name parses as a declaration, so the + * message names both readings. */ -function unknownDeclarationKindMessage(word: string): string { +export function unknownDeclarationKindMessage(word: string): string { return ( `Unknown declaration kind '${word}'; write ` + `${formatWordList([...DECLARED_CODE_TRIGGERS])}, or a step keyword if a ` + @@ -3912,7 +4439,7 @@ function unknownDeclarationKindMessage(word: string): string { ); } -function onTriggerMessage(word: string): string { +export function onTriggerMessage(word: string): string { if (word === 'conditional') { return CONDITIONAL_TYPO_MESSAGE; } @@ -3922,7 +4449,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 ` + @@ -3943,8 +4470,7 @@ 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. */ -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 ( @@ -3953,7 +4479,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 ( @@ -3974,14 +4500,7 @@ 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. - */ -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 ' + @@ -3991,7 +4510,7 @@ function startMessageExpressionMessage(name: string): string { ); } -function throwTriggerMessage(word: string): string { +export function throwTriggerMessage(word: string): string { if (word === 'compensate') { return COMPENSATE_TYPO_MESSAGE; } @@ -4004,7 +4523,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'."; } @@ -4017,7 +4536,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; } @@ -4033,13 +4552,10 @@ 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. */ -function isActivityStatement(stmt: Statement): boolean { +export function isActivityStatement(stmt: Statement): boolean { return ( isNamedStatement(stmt) && !isStartEvent(stmt) && @@ -4051,23 +4567,17 @@ 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. */ -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; } -/** - * 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 ( @@ -4103,7 +4613,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 ` + @@ -4111,7 +4621,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 ` + @@ -4119,14 +4629,9 @@ 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. - */ +/** 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 ?? 'not an activity'; + return attributeBlockRuleOf(stmt)!.description; } function startTriggerInBlockMessage(block: SubProcess): string { @@ -4154,7 +4659,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 ` + @@ -4162,7 +4667,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, " + @@ -4171,7 +4676,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 ' + @@ -4179,27 +4684,81 @@ 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.'; -function handlerHostKey(handler: OnHandler): string { - return handler.host?.ref ? targetStatementName(handler.host.ref) : ''; +const NAME_SCOPED_TRIGGERS: ReadonlySet = new Set([ + 'message', + 'signal', +]); + +export 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 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 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'; + 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( @@ -4218,15 +4777,34 @@ 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 && + ref.ref?.$refText === EXTERNAL_TASK_EL_NAME && AstUtils.getContainerOfType(ref, isErrorMapping) !== undefined ); } -/** An integer, bare or quoted, or a value lowering to `${...}`: a bare name or a raw template. */ +/** + * 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; + 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) + ); +} + function isPriorityValue(value: Expr): boolean { return ( isIntegerValue(value) || @@ -4236,18 +4814,63 @@ function isPriorityValue(value: Expr): boolean { ); } -/** - * 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 isRepeatCountValue(value: Expr): boolean { + return ( + isLiteralInt(value) || + isQuotedMatching(value, NON_NEGATIVE_INT_TEXT) || + isVarRef(value) || + isRawExpr(value) + ); +} + +function isVersionValue(value: Expr): boolean { + const text = integerLiteralText(value); + return ( + isRawExpr(value) || + (text === undefined + ? isQuotedMatching(value, VERSION_TEXT) + : VERSION_TEXT.test(text)) + ); +} + +/** Only a literal default is checked; anything else is evaluated at render. */ +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; + } +} + function topicRefusalOf( rule: AttributeBlockRule, 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), @@ -4256,21 +4879,15 @@ 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)); } -/** - * 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)); } -/** The distinct binding keys written on an element, in document order. */ function bindingKeysOf( attrs: readonly Setting[], keys: readonly string[], @@ -4282,7 +4899,6 @@ function bindingKeysOf( ]; } -/** @param subject The message's leading noun phrase (`"Service task 'total'"`). */ function checkAtMostOneBinding( attrs: readonly Setting[], keys: readonly string[], @@ -4300,12 +4916,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', @@ -4314,7 +4924,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( @@ -4337,13 +4947,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'`). - */ +/** 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', @@ -4388,11 +4992,143 @@ function bindingValueText(expr: Expr): string | undefined { return undefined; } +const variableKeySpelling = (name: string) => + `execution.getVariable('${name}')`; + +/** `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}']`; +} + +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); + } +} + +/** A `}` inside a JUEL string literal is string text. */ +const TEMPLATE_BODIES = + /[$#]\{((?:'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|[^'"}])*)\}/g; + +const STRING_OR_PROPERTY_READ = + /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|\.\s*([A-Za-z_$][\w$]*)/g; + /** - * 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. + * 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, + 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_RAW_TEMPLATE_WORD_SET.has(word)) continue; + const object = /[\w$.[\]'"]*$/.exec( + body!.slice(0, match.index).trimEnd(), + )![0]; + accept('error', juelKeywordMessage(word, `${object}['${word}']`), target); + } + } +} + +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 already `checkConditionPayload`'s. + 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; +} + +/** `undefined` where Boolean or unknown: accessors, undeclared names and templates. */ +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 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; +} + function findEnclosingBranch( node: AstNode, ): { body: Block; keyword: 'parallel' | 'await' } | undefined { @@ -4413,7 +5149,7 @@ function findEnclosingBranch( return undefined; } -function intoBranchMessage( +export function intoBranchMessage( subject: string, jump: string, keyword: 'parallel' | 'await', @@ -4422,7 +5158,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 " + @@ -4430,7 +5166,6 @@ 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 84fab2b..610f50a 100644 --- a/packages/language/src/bpmn-script-value-converter.ts +++ b/packages/language/src/bpmn-script-value-converter.ts @@ -1,15 +1,9 @@ /** - * 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}"`. + * 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 { - AstUtils, DefaultValueConverter, GrammarAST, ValueConverter, @@ -18,7 +12,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 +19,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..a7f602d 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,106 +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, 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. +// 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} @@ -366,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)?; @@ -421,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; @@ -435,7 +358,6 @@ Primary infers Expr: | Paren | RawExpr; -// A dedicated terminal, so `1000` is a LiteralInt rather than a VarRef. LiteralInt: value=INT; @@ -451,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)*; @@ -473,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` -// 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. +// 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 @@ -491,18 +408,15 @@ 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. The downstream split takes everything up to the first whitespace as +// the tag. 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: /"\$\{(\\.|[^"\\])*"|'\$\{(\\.|[^'\\])*'/; +// 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 35c59b6..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 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. + * 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, 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. - */ +/** 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); @@ -39,22 +33,19 @@ export function integerLiteralText(node: Expr): string | undefined { return undefined; } -/** A {@link RawExpr} body is already a complete one and comes back verbatim. */ export function renderExpression(node: Expr): string { if (isRawExpr(node)) { - return unquoteRaw(node.raw); + return node.raw; } 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. - */ +/** Parentheses are emitted only where the author wrote them. */ export function renderExpressionInner(node: Expr): string { if (isRawExpr(node)) { - return unquoteRaw(node.raw); + // 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})`; } if (isTernary(node)) { return ( @@ -63,7 +54,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) || @@ -86,9 +76,8 @@ 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 only two escapes JUEL's `Scanner.nextString` accepts; backslash first. + return `"${node.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; } if (isLiteralBool(node) || isLiteralNull(node)) { return node.value; @@ -103,8 +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; - // guard so a third accessor form throws instead of rendering `undefined`. if (accessor.index === undefined) { throw new Error( 'renderAccessor: accessor has neither a `prop` nor an `index` (unexpected accessor shape)', @@ -114,16 +101,12 @@ 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. + * `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 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/index.ts b/packages/language/src/index.ts index b5f625e..afeea65 100644 --- a/packages/language/src/index.ts +++ b/packages/language/src/index.ts @@ -1,14 +1,15 @@ 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'; 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 68381a1..02e75a8 100644 --- a/packages/language/src/paren-items.ts +++ b/packages/language/src/paren-items.ts @@ -1,19 +1,14 @@ -/** - * 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. - */ +/** The payload, settings and flags share one list, told apart by node type. */ import { DECLARED_CODE_TRIGGERS, - EVENT_BINDING_FIELDS, + EVENT_BINDING_FIELD_SET, + EVENT_CODE_FIELD, namesACode, TIMER_PARTICLE_BY_KIND, + type TimerParticle, } from './vocabulary.js'; import type { AstNode } from 'langium'; -import { unquoteRaw } from './expression-render.js'; import { isErrorMapping, isLiteralString, @@ -32,42 +27,26 @@ 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. - */ +/** A second one is a duplicate the validator reports. */ 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. - */ -export interface CaughtBinding { +/** Only a bare name declares a variable; `code: "LITERAL"` reads rather than binds. */ +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,24 +59,20 @@ 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. - */ -function isStructuralParenKey(owner: AstNode, key: string): boolean { +/** 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 '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, -]); +]; -/** An element's engine settings, which is every setting bar the structural keys. */ export function configuredSettingsOf( owner: AstNode & { items?: ParenItem[] }, ): Setting[] { @@ -107,24 +82,12 @@ 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. + * 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 { - // 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. + // 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); @@ -135,18 +98,13 @@ function payloadTriggerOf(node: AstNode): string | undefined { : undefined; } -function triggerWordOf(owner: AstNode): string | undefined { +export function triggerWordOf(owner: AstNode): string | undefined { return 'trigger' in owner && typeof owner.trigger === 'string' ? owner.trigger : 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 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) @@ -157,14 +115,10 @@ 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 is held by the mapping being typed, not an element, + * 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 && @@ -176,12 +130,7 @@ function mappingTriggerOf(mapping: ErrorMapping): string | undefined { return mapping.trigger; } -/** - * 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. - */ +/** 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 && @@ -191,20 +140,18 @@ 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` 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((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; @@ -212,58 +159,43 @@ export function declaredCodeOf(decl: CodeDecl): string | undefined { return setting.value.value; } -/** - * 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 - * 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; 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; } -/** - * 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. - */ +/** 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: string; + readonly particle: TimerParticle; readonly time: string; 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 { 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; } -/** - * 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, so callers read one shape. */ export function timerPayloadOf(items: ParenItem[]): TimerPayload | undefined { const keyed = timerParticleOf(items); if (keyed !== undefined) return keyed; @@ -275,28 +207,20 @@ 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. */ 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; } -/** 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. - */ 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 f588826..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'; @@ -9,6 +6,7 @@ import { caughtBindingsOf } from './paren-items.js'; import { FIELD_DIRECTION, formFieldVariableType, + LOOP_VARIABLES, PROPERTY_DIRECTION, } from './vocabulary.js'; import { @@ -26,23 +24,26 @@ export interface VariableSymbol { export type VariableTable = Map; -/** Injected as `references.VariableSymbolProvider`. */ +/** Node and property so a rename finds the name's own leaf. */ +export interface DeclaringSite { + node: AstNode; + property: string; + name: string; + type: VarType; +} + export interface VariableSymbolProvider { collect(process: Process): VariableTable; + declaringSites(process: Process): DeclaringSite[]; } -/** The repeat-clause slots read here, off whichever statement carries them. */ interface RepeatSlots { cardinality?: unknown; collection?: unknown; 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` 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) || @@ -51,79 +52,85 @@ 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. + * In precedence order: `var`, form field, catch binding, then parameters and + * repeat elements in source order. */ -const LOOP_VARIABLES = [ - 'nrOfInstances', - 'nrOfActiveInstances', - 'nrOfCompletedInstances', - 'loopCounter', -] as const; - -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 }); - } +export function declaringSites(process: Process): DeclaringSite[] { + const declared: DeclaringSite[] = []; + for (const decl of process.decls) { + if (isVarDecl(decl)) { + declared.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; + } + const formFields: DeclaringSite[] = []; + const catchBindings: DeclaringSite[] = []; + const mapped: DeclaringSite[] = []; + for (const node of AstUtils.streamAst(process)) { + 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 && !table.has(field.id)) { - table.set(field.id, { name: field.id, type }); + if (type !== undefined) { + formFields.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; + } else if (isOnHandler(node)) { + for (const { variable, node: setting } of caughtBindingsOf(node.items)) { if (variable === undefined) continue; - if (!table.has(variable)) { - table.set(variable, { name: variable, type: 'string' }); - } + catchBindings.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' }); + if (isIoParameter(node)) { + // A field or property is not a process variable. + if ( + node.direction !== FIELD_DIRECTION && + node.direction !== PROPERTY_DIRECTION + ) { + mapped.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) { + mapped.push({ + node, + property: 'element', + name: node.element, + type: 'any', + }); + } + } + return [...declared, ...formFields, ...catchBindings, ...mapped]; +} + +export class DefaultVariableSymbolProvider implements VariableSymbolProvider { + declaringSites(process: Process): DeclaringSite[] { + return declaringSites(process); + } + + collect(process: Process): VariableTable { + const table: VariableTable = new Map(); + 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 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)) { table.set(name, { name, type: 'number' }); diff --git a/packages/language/src/vocabulary.ts b/packages/language/src/vocabulary.ts index ff36d2c..a7b131d 100644 --- a/packages/language/src/vocabulary.ts +++ b/packages/language/src/vocabulary.ts @@ -1,15 +1,12 @@ /** - * 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. - * - * Every trigger list is a literal tuple, so a table keyed by one either carries - * a row per word or 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'; -import { AstUtils, GrammarAST } from 'langium'; -import { isOnHandler, isSubProcess } from './generated/ast.js'; +import { AstUtils, GrammarAST, GrammarUtils } from 'langium'; +import { isSubProcess } from './generated/ast.js'; +import { BpmnScriptGrammar } from './generated/grammar.js'; import type { BusinessRuleTask, CallActivity, @@ -32,13 +29,7 @@ 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. - */ +/** 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) { @@ -54,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', @@ -64,105 +54,112 @@ 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}'`)); } +/** The flags that create a continuation job. */ +export const ASYNC_FLAG_KEYS = ['asyncBefore', 'asyncAfter'] as const; + /** - * The engine execution 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 - * `DefaultFailedJobParseListener.parseActivity` reads the retry cycle on the - * same four. + * Settings that configure a job something else declares; a timer job takes + * them off the element declaring the timer (`BpmnParse.parseTimer`). */ -export const ENGINE_KEYS: readonly string[] = [ - 'asyncBefore', - 'asyncAfter', +export const TIMER_JOB_KEYS = [ 'exclusive', 'jobPriority', 'retryCycle', +] as const; +export type TimerJobKey = (typeof TIMER_JOB_KEYS)[number]; + +export type EngineKey = (typeof ASYNC_FLAG_KEYS)[number] | TimerJobKey; + +/** Read with a case-sensitive `"true"` comparison; any other text deploys as off. */ +export const BOOLEAN_ENGINE_KEYS: readonly EngineKey[] = [ + ...ASYNC_FLAG_KEYS, + 'exclusive', +]; + +/** Operaton reads these off any flow node, gateways included. */ +export const ENGINE_KEYS: readonly EngineKey[] = [ + ...ASYNC_FLAG_KEYS, + ...TIMER_JOB_KEYS, ]; -/** `key` spelled for a second carrier in the same parens: `join` + `asyncBefore` is `joinAsyncBefore`. */ -export function prefixedSettingKey(prefix: string, key: string): string { +/** Set by `MultiInstanceActivityBehavior`, so in scope undeclared wherever something repeats. */ +export const LOOP_VARIABLES = [ + 'nrOfInstances', + 'nrOfActiveInstances', + 'nrOfCompletedInstances', + 'loopCounter', +] as const; + +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); } +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 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; `createActivityOnScope` reads a job priority off - * the activity element alone, which is why there is no `runJobPriority`. + * 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)); -/** - * The fifth binding: `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` 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 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', 'expression', 'delegate', - 'topic', + EXTERNAL_BINDING_KEY, 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 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); -/** The two `operaton:type` values `parseServiceTaskLike` routes to a built-in behaviour. */ +export const THROW_BINDING_TRIGGER = 'message'; + 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 on any other name. */ export const BUILTIN_FIELD_NAMES: Readonly< Record > = { @@ -183,20 +180,12 @@ 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; } -/** - * 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 > = { @@ -212,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 > = { @@ -221,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', @@ -232,22 +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']; -/** - * 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. - */ +/** One is required; `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. - */ +export const DECISION_MODIFIER_KEYS: readonly string[] = [ + 'binding', + 'version', + 'mapDecisionResult', +]; + export const DECISION_RESULT_MAPPINGS = [ 'singleEntry', 'singleResult', @@ -255,23 +239,15 @@ 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', -]; +/** 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 injects a field list into; an `expression`, a `topic` + * and a `decision` take none. */ export const FIELD_BINDING_KEYS: readonly string[] = [ 'class', @@ -279,112 +255,138 @@ export const FIELD_BINDING_KEYS: readonly string[] = [ TYPE_BINDING_KEY, ]; -/** - * 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. - */ +/** 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: readonly string[] = ['latest', 'deployment']; +export const CALL_BINDING_VALUES = ['latest', 'deployment'] as const; -/** - * 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. - */ -export const PROCESS_HEADER_KEYS: readonly string[] = [ - 'label', - 'documentation', +export type CallBindingValue = (typeof CALL_BINDING_VALUES)[number]; + +const LABEL_KEYS = ['label', 'documentation'] as const; + +export const PROCESS_ENGINE_HEADER_KEYS = [ 'versionTag', 'historyTimeToLive', 'candidateStarterUsers', 'candidateStarterGroups', -]; +] as const; -export const IO_DIRECTIONS: readonly string[] = ['input', 'output']; +/** The one header setting that takes a boolean rather than text. */ +export const STARTABLE_KEY = 'isStartableInTasklist'; /** - * 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. + * 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'; +export const OUTPUT_DIRECTION = 'output'; + +export const IO_DIRECTIONS: readonly string[] = [ + INPUT_DIRECTION, + OUTPUT_DIRECTION, +]; + +/** Written verbatim as the `operaton:` attribute of the same name, in print order. */ +export const USER_TASK_VERBATIM_KEYS = [ + 'assignee', + 'formKey', + 'candidateGroups', + 'candidateUsers', + 'dueDate', + 'followUpDate', + 'priority', +] as const; + +/** Set once on the bound implementation; reads and writes no process variable. */ 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, 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; the process header's own `operaton:taskPriority` is - * a separate site this surface does not carry. An integer or an expression. - */ +/** Read on an external service task alone; an integer or an expression. */ export const TASK_PRIORITY_KEY = 'taskPriority'; /** - * The name `VariableScopeElResolver` resolves 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. + * 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'; -/** - * 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. - */ -export const TIMER_PARTICLE_BY_KIND: Readonly< - Record<'duration' | 'date' | 'cycle', string> -> = { +export type TimerKind = 'duration' | 'date' | 'cycle'; + +/** Keyed by the timer definition each selects (`timeDuration`, `timeDate`, `timeCycle`). */ +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]; -/** The particles alone, in the order diagnostics and completion list them. */ -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, +); + +export function eventBindingFieldsFor(trigger: string): readonly string[] { + return EVENT_BINDING_FIELDS.filter( + (field) => + field !== EVENT_MESSAGE_FIELD || 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; -/** Legal on a user task alone; `timeout` is the one carrying a timer clause. */ +/** User tasks alone, spelled as `BpmnParse.parseTaskListeners` accepts them. */ export const TASK_LISTENER_EVENTS = [ 'create', - 'assign', + 'assignment', 'complete', 'update', 'delete', '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[]; + /** - * 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. + * `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', @@ -394,23 +396,15 @@ export const FORM_FIELD_TYPES = [ 'enum', ] 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 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 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 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', @@ -428,15 +422,7 @@ export function isFormConstraintName( return (FORM_CONSTRAINT_NAMES as readonly string[]).includes(name); } -/** - * 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. - */ +/** The bounds throw on a submitted value of another type; the rest ignore the type. */ export const FORM_CONSTRAINT_TYPES: Readonly< Record > = { @@ -453,40 +439,61 @@ 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`. + * 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+$/; +export const ID_TERMINAL: RegExp = GrammarUtils.terminalRegex( + BpmnScriptGrammar().rules.find( + (rule): rule is GrammarAST.TerminalRule => + GrammarAST.isTerminalRule(rule) && rule.name === 'ID', + )!, +); + +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*[$#]\{/; -/** - * 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}. - */ +/** JUEL evaluates a composite such as `a-${b}`, so an opening anywhere counts. */ +export const EXPRESSION_ANYWHERE = /[$#]\{/; + +/** + * 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', + 'div', + 'mod', + 'not', + 'and', + 'or', + 'le', + 'lt', + 'eq', + 'ne', + 'ge', + 'gt', + 'instanceof', +] as const; + +/** Keyword tokens to JUEL, so `${order.true}` fails to parse. */ +export const JUEL_LITERAL_WORDS = ['true', 'false', 'null'] as const; + +/** Written to `datePattern`, read on a `date` field alone; a type parameter, not a constraint. */ 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, ]; -/** - * 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. - */ +/** Soft words: an unknown one is a validator diagnostic, not a parse error. */ export const ON_TRIGGERS = [ 'error', 'escalation', @@ -498,11 +505,7 @@ export const ON_TRIGGERS = [ 'cancel', ] 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 or condition has nothing to throw; a cancel is written on an `end`. */ export const THROW_TRIGGERS = [ 'error', 'escalation', @@ -511,11 +514,7 @@ export const THROW_TRIGGERS = [ 'compensation', ] 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; a link throw is intermediate, so `throw` has no link. */ export const EMIT_TRIGGERS = [ 'escalation', 'message', @@ -524,11 +523,7 @@ export const EMIT_TRIGGERS = [ 'link', ] 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. - */ +/** No `link`: Operaton refuses a link catch behind an event-based gateway. */ export const RACE_TRIGGERS = [ 'message', 'timer', @@ -536,18 +531,12 @@ export const RACE_TRIGGERS = [ 'condition', ] 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, escalation, compensation and cancel are caught by an `on` handler alone. */ 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 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', @@ -556,44 +545,27 @@ export const START_TRIGGERS = [ 'condition', ] 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. - */ 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 a non-interrupting `alongside` handler is legal. */ + /** Whether the event carries a message text beside its code. */ + readonly message: boolean; 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; } -/** - * 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> = { error: { code: 'optional', timer: false, parens: 'bindings', + message: true, alongside: false, boundary: true, hostless: true, @@ -602,6 +574,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { code: 'optional', timer: false, parens: 'bindings', + message: false, alongside: true, boundary: true, hostless: true, @@ -610,6 +583,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { code: 'required', timer: false, parens: 'forbidden', + message: false, alongside: true, boundary: true, hostless: true, @@ -618,6 +592,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { code: 'required', timer: false, parens: 'forbidden', + message: false, alongside: true, boundary: true, hostless: true, @@ -626,6 +601,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { code: 'forbidden', timer: true, parens: 'forbidden', + message: false, alongside: true, boundary: true, hostless: true, @@ -634,6 +610,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { code: 'forbidden', timer: false, parens: 'condition', + message: false, alongside: true, boundary: true, hostless: true, @@ -642,6 +619,7 @@ export const TRIGGER_PAYLOAD: Readonly> = { code: 'forbidden', timer: false, parens: 'forbidden', + message: false, alongside: false, boundary: false, hostless: true, @@ -650,50 +628,51 @@ export const TRIGGER_PAYLOAD: Readonly> = { code: 'forbidden', timer: false, parens: 'forbidden', + message: false, alongside: false, boundary: true, hostless: false, }, + // The engine's link table is keyed by name alone, so a link opens no handler. link: { code: 'required', timer: false, parens: 'forbidden', + message: false, alongside: false, boundary: false, hostless: false, }, } 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. - */ +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', ]); -/** - * 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`. + * 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', js: 'javascript', javascript: 'javascript', + ecmascript: 'javascript', groovy: 'groovy', py: 'python', python: 'python', @@ -702,26 +681,29 @@ export const SCRIPT_FORMAT_ALIASES: Readonly> = { feel: 'feel', }; +/** Case-insensitive, as Operaton lowercases the language before the engine lookup. */ +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. + * 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); // strip the opening/closing ``` delimiters - const tag = /^[a-zA-Z]+/.exec(inner)?.[0] ?? ''; + const inner = raw.slice(3, -3); + const tag = /^\S*/.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`. */ export type AttributeOwner = | StartEvent | EndEvent @@ -741,220 +723,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, 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. + * `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. 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. - */ + /** 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}: the settings of the job each run gets, written - * on the `multiInstanceLoopCharacteristics` element the clause lowers to. - */ 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. - */ + /** 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 actually written. A thrown message can bind a - * topic too and takes none of them. + * `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; } -/** Derive `keys` from `own` and `repeats` so they cannot disagree. */ -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 : []), ]), }; } -/** - * `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. - */ +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', + // In print order. own: [ - 'label', - 'documentation', - 'assignee', - 'formKey', - 'formRef', - 'binding', - 'version', - 'candidateGroups', - 'candidateUsers', - 'dueDate', - 'followUpDate', - 'priority', + ...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', @@ -962,96 +869,39 @@ export const ATTRIBUTE_BLOCK_RULES: Readonly< 'mapper', 'mapperDelegate', ], - flags: [], - forms: false, repeats: true, parameters: true, - fields: false, - taskListeners: false, - externalExtras: false, }), + // 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 { - /** 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. - */ + /** 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: the validator refuses - * them and completion leaves them out. - */ 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< +const GATEWAY_STATEMENT_RULES: Readonly< Record< | 'IfStatement' | 'WhileStatement' @@ -1073,7 +923,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, @@ -1089,23 +939,12 @@ 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] : EXECUTION_LISTENER_EVENTS; } -/** The directions a member of this kind's block is written with. */ export function parameterDirectionsFor( rule: AttributeBlockRule, ): readonly string[] { @@ -1116,12 +955,7 @@ 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. - */ +/** 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', @@ -1133,9 +967,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..fef9463 100644 --- a/packages/language/test/completion.test.ts +++ b/packages/language/test/completion.test.ts @@ -1,15 +1,3 @@ -/** - * 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. - */ - import { beforeAll, describe, expect, test } from 'vitest'; import { EmptyFileSystem, URI, type LangiumDocument } from 'langium'; import { @@ -22,9 +10,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'; @@ -67,7 +59,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; @@ -75,12 +67,7 @@ function inserted(item: CompletionItem): string { return item.insertText ?? item.label; } -/** - * 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. */ function accepted( item: CompletionItem, fillStop: (stop: string) => string = () => '', @@ -92,12 +79,7 @@ function accepted( .replace(/\\\$/g, '$'); } -/** - * 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 bare tab stop opens a body, complete only once a step runs in it. */ const typedInto = (stop: string) => `user Step${stop}`; let parseCounter = 0; @@ -115,11 +97,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. - */ +/** 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( @@ -127,11 +105,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 not yet declared. */ async function validationErrors(text: string): Promise { const document: LangiumDocument = await build(text, true); return withTextMessages(document.diagnostics ?? []) @@ -139,7 +113,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'); @@ -150,12 +123,7 @@ 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. - */ +/** The kind defaults to `Snippet` when the item inserts more than its label, else `Keyword`. */ type Item = readonly [ label: string, detail: string, @@ -167,13 +135,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 every settings position also offers: an unkeyed item there is an - * event payload or a condition, which is an expression like any other. - */ const LITERALS: Item[] = [ ['true', KEYWORD, 'true'], ['false', KEYWORD, 'false'], @@ -190,16 +154,14 @@ 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. */ const START: Item = ['start', CONSTRUCT, 'start ${1:name}']; -/** Every statement a body position opens, in the order they are offered. */ const STATEMENTS: Item[] = [ START, ['end', CONSTRUCT, 'end ${1:name}'], @@ -208,7 +170,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}")'], @@ -245,20 +207,28 @@ 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', 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]; +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 +238,7 @@ const REPEAT_FORMS: Item[] = [ ['for', 'how often the preceding step runs', 'for ${1:3}'], ]; +/** `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'], @@ -275,8 +246,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[] = [ @@ -306,7 +275,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|}'], @@ -315,11 +283,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|}'], @@ -327,21 +291,15 @@ 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, - ...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]; +/** 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, - ...LITERALS, ]; const PARAMETERS: Item[] = [ @@ -363,7 +321,6 @@ const LISTENER_KEYWORD: Item = [ const FORM_KEYWORD: Item = ['form', KEYWORD, 'form']; -/** A form field's parens, in the order the constraints are registered. */ const FORM_FIELD_PARENS: Item[] = [ ['required', SETTING, 'required: true'], ['readonly', SETTING, 'readonly: true'], @@ -381,38 +338,28 @@ 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 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, +const CLASS_BLOCK_MEMBERS: Item[] = [...PARAMETERS, FIELD, LISTENER_KEYWORD]; + +const TOPIC_BLOCK_MEMBERS: Item[] = [ ...PARAMETERS, - FIELD, PROPERTY, LISTENER_KEYWORD, 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}"'], ['expression', SETTING, 'expression: "${1:\\${bean.method(execution)}}"'], @@ -444,10 +391,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, @@ -458,14 +404,7 @@ 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, @@ -481,7 +420,6 @@ const CALL_PARENS: Item[] = [ ['mapper', SETTING, 'mapper: "${1:com.example.CallMapper}"'], ['mapperDelegate', SETTING, 'mapperDelegate: "${1:\\${callMapperBean}}"'], ...ENGINE_SETTINGS, - ...LITERALS, ]; const RECEIVE_PARENS: Item[] = [ @@ -489,7 +427,6 @@ const RECEIVE_PARENS: Item[] = [ DOCUMENTATION, ['message', SETTING, 'message: "${1:MessageName}"'], ...ENGINE_SETTINGS, - ...LITERALS, ]; const DECIDE_PARENS: Item[] = [ @@ -509,15 +446,36 @@ 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}']; + 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 may run alongside. */ +const ESCALATION_HANDLER_PARENS: Item[] = [ + CODE_BINDING, + ...ENGINE_SETTINGS, + ALONGSIDE, +]; + +/** A declaration's fields carry 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'; + +const TYPE_VALUES: Item[] = [ + ['"mail"', SETTING_VALUE, '"mail"'], + ['"shell"', SETTING_VALUE, '"shell"'], ]; const PROCESS_PARENS: Item[] = [ @@ -535,7 +493,7 @@ const PROCESS_PARENS: Item[] = [ SETTING, 'candidateStarterGroups: "${1:adjusters}"', ], - ...LITERALS, + ['isStartableInTasklist', SETTING, 'isStartableInTasklist: ${1|false,true|}'], ]; const START_PARENS: Item[] = [ @@ -543,10 +501,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, @@ -559,7 +516,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'), @@ -638,9 +595,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', @@ -672,8 +644,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 }', @@ -719,8 +691,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}', @@ -781,7 +751,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}', @@ -793,27 +762,54 @@ 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, so the body position offers no member. + [ + '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", 'process p {\n user T {\n on create(class: "com.example.L") {\n |\n }\n }\n}', [FIELD], ], + // An event sub-process refuses a parameter; a boundary event carries none. [ - '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, ], @@ -859,8 +855,7 @@ 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. + // 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}', @@ -890,6 +885,89 @@ describe('the completions offered at a caret', () => { ], ], ], + // 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}', + [ + [ + 'PAYMENT_DECLINED', + 'CodeDecl', + 'PAYMENT_DECLINED', + CompletionItemKind.Reference, + ], + ], + ], + // 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}', + [ + [ + '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 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}`, + [...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. + [ + "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. + [ + "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}', @@ -898,7 +976,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", @@ -912,10 +990,6 @@ 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. const scaffolds = inserted(item) !== item.label; expect(item.insertTextFormat === InsertTextFormat.Snippet).toBe( scaffolds, @@ -929,22 +1003,27 @@ describe('the completions offered at a caret', () => { }); }); -/** The rows of `items` that scaffold something, with the program they land 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 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', @@ -960,21 +1039,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 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. - */ +/** 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); + +/** 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' @@ -985,177 +1061,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}`, ), - ...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( + // `taskPriority` rides a `topic` binding alone. + 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)), + 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( '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( - 'the parens of a handler', - HANDLER_PARENS, - (setting) => - `process p {\n start S\n on error(${setting}) {\n user Caught\n }\n}`, - ), - ...scaffolds( + 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( + 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}`, ), - // The code is declared in the header, where the mapping's scaffold resolves it. - ...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}`, ), - // Each setting lands on a field of the one type it fits. - ...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, which is the noun phrase a host row spells as well. */ const RULE_BY_DESCRIPTION = new Map( [...Object.values(ATTRIBUTE_BLOCK_RULES), ATTEMPT_BLOCK_RULE].map((rule) => [ rule.description, @@ -1166,17 +1233,251 @@ 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, ', ); + // An emitted signal 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); + +/** Each continues with a mandatory token, so it cannot parse alone. */ +const PREFIX_WORDS: ReadonlySet = new Set(['goto', 'form']); + +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/helpers/block-hosts.ts b/packages/language/test/helpers/block-hosts.ts index 5a7e2c1..f673f9a 100644 --- a/packages/language/test/helpers/block-hosts.ts +++ b/packages/language/test/helpers/block-hosts.ts @@ -1,21 +1,11 @@ -/** - * 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 } 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 - * 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> = { +const VALID_ENGINE_VALUE: Readonly> = { asyncBefore: 'true', asyncAfter: 'true', exclusive: 'false', @@ -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 string[], + 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,11 +138,7 @@ 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. - */ +/** 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', @@ -171,23 +150,14 @@ export const PARAMETER_HOSTS = new Set([ 'subprocess', 'attempt', 'call', - 'on', ]); -/** 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 d2729f2..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 new file mode 100644 index 0000000..a7e532a --- /dev/null +++ b/packages/language/test/hover.test.ts @@ -0,0 +1,87 @@ +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); +}); + +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", + ], + [ + '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"', + ], + [ + '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..0cd2bd6 --- /dev/null +++ b/packages/language/test/navigation.test.ts @@ -0,0 +1,240 @@ +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 the rename at `<|>`, or `REFUSED`. */ +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); +} + +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}', + ], +]; + +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 3a6adf3..3fae659 100644 --- a/packages/language/test/parsing.test.ts +++ b/packages/language/test/parsing.test.ts @@ -1,15 +1,3 @@ -/** - * 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 - * scoping suite's. - */ - import { beforeAll, describe, expect, test } from 'vitest'; import { EmptyFileSystem } from 'langium'; import { parseHelper } from 'langium/test'; @@ -32,12 +20,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 @@ -57,13 +43,7 @@ beforeAll(() => { parse = parseHelper(services.BpmnScript); }); -/** - * 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 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(', ')}]`; @@ -89,75 +69,48 @@ 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); } -/** `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); } -/** - * Parse a bare expression by wrapping it as the condition of an `if`, then - * return the parsed condition AST node. - */ 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]; +type BodyRow = readonly [ + title: string, + source: string, + expected: string | 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 +140,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") { @@ -205,111 +156,41 @@ process invoice(label: "Invoice Approval") { }); 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 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 { } }`, - '[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")]))]', - ], - [ - '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")]))]', + [ + '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, @@ -322,43 +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))])]', - ], - [ - '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") }`, - '[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 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 } }`, - '[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); @@ -366,33 +224,23 @@ 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', - `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); @@ -400,90 +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: the word before the colon is the host, the one after - // it the trigger. ...( [ 'error', @@ -500,148 +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 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") { } }`, - '[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); @@ -649,9 +386,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([ ...( [ @@ -709,9 +443,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 +489,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 +505,24 @@ describe('Parsing - expressions', () => { expect(shape(await parseCondition(expr))).toBe(expected); }); - // The RawExpr row is the one that strips the author's surrounding quotes. + // 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}'], ['"${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}', @@ -780,186 +538,88 @@ 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) }`, - '[ServiceTask(name="A", items=[Setting(key="class", value=VarRef(ref=->com, accessors=[Accessor(prop="example"), Accessor(prop="invoice"), Accessor(prop="AutoApproveDelegate")]))])]', - ], + test.each([ [ - '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"))])]', - ], - [ - '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")])])]', - ], - [ - '`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) }`, - '[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))])]', - ], - [ - '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")]))]', + [ + '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 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] } }`, - '[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```"))])]', - ], - [ - '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()}") } }`, - '[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', @@ -975,7 +635,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']], @@ -994,9 +653,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', @@ -1011,7 +669,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); @@ -1026,86 +684,34 @@ describe('Parsing - task kinds, service bindings and fenced scripts', () => { ['decide', 'BusinessRuleTask'], ] 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"))])]`, - ]), + 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 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") }`, - '[ServiceTask(name="ship", items=[Setting(key="topic", value=LiteralString(value="shipping"))])]', - ], - [ - 'a fenced script task captures the whole fence, tag and all', - `process p { - script total ${FENCE}js -x = 1 -${FENCE} -}`, - '[ScriptTask(name="total", body="```js\\nx = 1\\n```")]', - ], - [ - 'a script task carries its settings before its fence', + 'fenced scripts are captured verbatim alongside other binding shapes', `process p { - script S(resultVariable: "total") ${FENCE}js + service ship(topic: "shipping") + script 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 { - 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); @@ -1113,58 +719,38 @@ ${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))]', - ], - [ - '`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"))])]', - ], + test.each([ [ - '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 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. + // `script` and `subprocess` are followed by a brace-opened body, 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] > = [ @@ -1202,23 +788,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) => @@ -1231,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'.`; @@ -1245,9 +821,6 @@ 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. describe('Parsing - sources the parser rejects', () => { test.each([ [ @@ -1255,15 +828,14 @@ 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 } }`, [ "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.", ], ], [ @@ -1331,9 +903,40 @@ 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 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" }`, @@ -1372,7 +975,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}', @@ -1387,8 +989,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}', @@ -1404,7 +1004,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[] = [ @@ -1422,14 +1021,8 @@ const LABEL_CARRIERS: readonly LabelCarrier[] = [ ['call', 'process p { call C', '}'], ]; -/** - * 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. - */ -describe('Parsing - settings are written one way', () => { - test.each([ +describe('Parsing - a slot has one spelling', () => { + test.each([ ...LABEL_CARRIERS.map( ([keyword, head, tail]) => [ @@ -1468,19 +1061,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" }`, @@ -1536,8 +1116,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..2e77a42 100644 --- a/packages/language/test/recovery-fuzz.test.ts +++ b/packages/language/test/recovery-fuzz.test.ts @@ -1,20 +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. - * - * 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`. - */ +// 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'; @@ -27,30 +14,15 @@ import { withTextMessages } from './helpers/diagnostics.js'; 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. - */ +/** 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. 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. + * 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) }`, @@ -75,11 +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, 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: 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; @@ -98,9 +66,7 @@ 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. + // 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 25bc08c..a39f602 100644 --- a/packages/language/test/scoping.test.ts +++ b/packages/language/test/scoping.test.ts @@ -1,33 +1,5 @@ -/** - * 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 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. - * - * 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. - */ +// 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 { @@ -37,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, @@ -54,8 +27,6 @@ import { withTextMessages, } from './helpers/diagnostics.js'; -const SEVERITY_ERROR = 1; - let services: ReturnType; let parse: ReturnType>; @@ -64,17 +35,12 @@ 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. - */ function headerOf(handler: OnHandler): string { const head = handler.host ? `on ${handler.host.$refText}: ${handler.trigger}` @@ -83,11 +49,6 @@ 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. - */ function pathOf(node: AstNode): string { const segments: string[] = []; for (let n: AstNode | undefined = node.$container; n; n = n.$container) { @@ -105,11 +66,6 @@ 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. - */ async function resolutionsOf(source: string): Promise { const document = await parse(source, { validation: true }); expect(document.parseResult.parserErrors.map((e) => e.message)).toEqual([]); @@ -123,14 +79,13 @@ 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}`); } } return lines; } -/** The body every resolution table runs. */ async function checkRow( _title: string, source: string, @@ -139,13 +94,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 -> ...`. - */ +/** 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([]); @@ -162,13 +111,12 @@ 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; } -/** The body the code-resolution table runs. */ async function checkCodeRow( _title: string, source: string, @@ -177,7 +125,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: @@ -185,7 +132,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, @@ -194,12 +140,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}'.`; +function missingStep(name: string): string { + return `error: No step named '${name}' in this 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. */ const MULTI_PROCESS = 'error: Only one process is supported per file. Move additional processes into separate files.'; @@ -226,12 +174,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', @@ -282,11 +230,6 @@ describe('Scoping - container-scoped goto (subprocess boundary)', () => { boundary('Deep', `inside subprocess 'Inner'`, 'subprocess'), ], ], - [ - 'a goto to a name nowhere in the process keeps the stock message', - `process p { subprocess Sub { user Inner } goto Missing }`, - ['goto Missing -> unresolved', stockError('Missing')], - ], ])('%s', checkRow); }); @@ -358,11 +301,6 @@ describe('Scoping - container-scoped goto (event-handler boundary)', () => { ), ], ], - [ - 'a goto to a name nowhere in the process keeps the stock message', - `process p { error PAYMENT_FAILED goto Missing on error(PAYMENT_FAILED) { user Inner } }`, - ['goto Missing -> unresolved', stockError('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 } }`, @@ -431,7 +369,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 +390,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); }); @@ -590,8 +528,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', @@ -625,36 +562,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 78f2f51..718f1d1 100644 --- a/packages/language/test/semantic-tokens.test.ts +++ b/packages/language/test/semantic-tokens.test.ts @@ -1,15 +1,3 @@ -/** - * 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. - */ - import { beforeAll, describe, expect, test } from 'vitest'; import { EmptyFileSystem } from 'langium'; import { @@ -27,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[] { @@ -48,157 +31,111 @@ 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([ [ - '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], - ], - [ - '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|>")', - [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 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")', - [KEYWORD, KEYWORD], - ], - [ - 'a code declaration highlights its kind, not the name it declares', - '<|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|>', - [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], + } + 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), ], - ['a step named after a soft word stays plain', 'user <|input|>', [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 new file mode 100644 index 0000000..cd6476e --- /dev/null +++ b/packages/language/test/symbols.test.ts @@ -0,0 +1,84 @@ +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), + ]); +} + +// A mapping has no name, so `BookCarrier`'s mapping lines are no symbols. +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 0a01366..79ecb5d 100644 --- a/packages/language/test/validating.test.ts +++ b/packages/language/test/validating.test.ts @@ -1,23 +1,153 @@ -/** - * 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. - */ +// 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 { 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, + booleanShapeMessage, + 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, @@ -35,8 +165,6 @@ import { withTextMessages, } from './helpers/diagnostics.js'; -const SEVERITY_WARNING = 2; - let validate: (input: string) => Promise>; beforeAll(() => { @@ -44,20 +172,19 @@ 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) => - d.severity === SEVERITY_WARNING ? `warning: ${d.message}` : d.message, + d.severity === DiagnosticSeverity.Warning + ? `warning: ${d.message}` + : d.message, ); } -/** 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) => { @@ -68,23 +195,43 @@ 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. - */ +/** 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 ───────────────────────────────────────── +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, @@ -100,40 +247,34 @@ 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_..._...', '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) => `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}.`; 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) => - `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}: '.`; @@ -148,55 +289,19 @@ 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 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 emptyBinding = (key: string, noun: string) => + `Setting '${key}' cannot be empty; name the ${noun}.`; 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'.`; -// Forms. - const noFormBlock = (description: string) => `${capitalized(description)} cannot declare a 'form' block; forms belong on start events and user tasks.`; const oneFormBlock = (description: string) => @@ -204,82 +309,11 @@ 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.`; -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 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 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, an ' + - "attempt block, and an 'on' handler with no host."; 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 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.'; const EMPTY_MAP_KEY = "A map entry's key cannot be empty; name the key its value is looked up by."; const taskListenerOnly = (event: string, description: string) => @@ -287,101 +321,71 @@ const taskListenerOnly = (event: string, description: string) => `${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. - -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.`; -// Fenced scripts. - 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.'; -// Control flow. +/** `ScriptingEngines.getScriptEngineForLanguage` lowercases the tag, so a mixed-case alias resolves. */ +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)] : [], + ]); +} 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 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}'.`; +const blockParameter = (direction: string) => + 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) => + `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}}".`; -// Calls. - 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) => `${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) => `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. ' + @@ -390,109 +394,36 @@ 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 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 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 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 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 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 ' + @@ -500,12 +431,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 = @@ -513,26 +438,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.'; -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. */ +const handlerDuplicate = (trigger: string, caught: string, scope: string) => + `Another 'on ${trigger}' handler already catches ${caught} on scope '${scope}': ${HANDLER_DUPLICATE_RULE[trigger]}.`; const everyEvent = 'every event of this kind'; const code = (value: string) => `code '${value}'`; - -// Boundary hosts. +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 illegalHost = (name: string, kind: string) => 'A boundary event can only attach to an activity: a user, service, script, ' + @@ -541,77 +456,15 @@ 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, @@ -620,11 +473,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 = @@ -635,20 +483,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 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 ' + @@ -658,7 +492,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.'; @@ -668,17 +502,7 @@ 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) => @@ -687,83 +511,38 @@ 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 ─────────────────────────────────────────────────────────────────── 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 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 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) }`, - [], - ], - [ - '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 } }`, @@ -786,213 +565,363 @@ 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 resultVariable names the variable to fill, not one to read', - `process p { service V(topic: "t", 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(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(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 - 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], - ], - [ - '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], - ], - [ - 'two different keys are clean', - `process p { user T(assignee: "a", formKey: "f") }`, - [], - ], - [ - 'assignee on a service task is not valid there', - `process p { service S(assignee: "x") }`, +checks('Validation - a JUEL keyword or a hyphen in a rendered name', [ + ...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 } }`, [ - notValidOn('assignee', 'a service task'), - bindingRequired(`Service task 'S'`, SERVICE_BINDINGS), + juelKeywordMessage(word, `execution.getVariable('${word}')`), + juelKeywordMessage(word, `execution.getVariable('${word}')`), + juelKeywordMessage(word, `order['${word}']`), + juelKeywordMessage(word, `order['${word}']`), ], - ], + ]), [ - 'class on a user task is not valid there', - `process p { user T(class: com.example.X) }`, - [notValidOn('class', 'a user task')], + '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']"), + ], ], [ - 'formKey on a service task is not valid there', - `process p { service S(class: com.example.X, formKey: "f") }`, - [notValidOn('formKey', 'a service task')], + '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')")], ], [ - 'each kind writing only its own keys is clean', - `process p { user T(assignee: "a", formKey: "f") service S(class: com.example.X) }`, + '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 }`, [], ], [ - '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')], + '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 } }`, + [], ], +]); + +const CONDITION_POSITIONS: Array<[title: string, at: (c: string) => string]> = [ + ['if', (c) => `process p { var n: number if (${c}) { user A } }`], [ - 'assignee on a subprocess is not valid there', - `process p { subprocess S(assignee: "a") { user U } }`, - [notValidOn('assignee', 'a subprocess')], + '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 } }`], [ - 'topic on an end event is not valid there', - `process p { start S end E(topic: "t") }`, - [notValidOn('topic', 'an end event')], + 'do ... while', + (c) => `process p { var n: number do { user A } while (${c}) }`, ], [ - '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 parallel branch head', + (c) => + `process p { var n: number parallel { if (${c}) { user A } { user B } } }`, ], [ - 'a script task accepts resultVariable', - `process p { script T(resultVariable: "total") ${FENCE}js\n1 + 1\n${FENCE} }`, - [], + 'a condition handler', + (c) => `process p { var n: number user A on condition(${c}) { user B } }`, ], [ - 'a version-pinned call accepts version and businessKey', - `process p { call C(process: "q", version: 1, businessKey: "k") }`, - [], + 'a condition start', + (c) => `process p { var n: number start S condition(${c}) user A }`, ], [ - 'a binding-pinned call accepts binding', - `process p { call C(process: "q", binding: latest) }`, - [], + 'an awaited condition', + (c) => `process p { var n: number await condition(${c}) user A }`, ], [ - 'every boolean attribute takes an unquoted true or false', - `process p { user U(asyncBefore: true, asyncAfter: false, exclusive: true) }`, - [], + 'a race branch', + (c) => + `process p { var n: number await { condition(${c}) { user A } timer("PT1H") { user B } } }`, ], +]; + +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'")], + ]), [ - 'a quoted boolean is one error naming the unquoted form', - `process p { user U(exclusive: "true") }`, - [quotedBoolean('exclusive')], + '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).", + 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')), + ], ], +]); + +checks('Validation - a composite raw template cannot be an operand', [ [ - 'a number in a boolean attribute is one error', - `process p { user U(asyncBefore: 1) }`, - [quotedBoolean('asyncBefore')], + '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 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')], + "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'), + ], ], [ - 'an expression in a boolean attribute is one error, since the lowering drops it', - `process p { user U(asyncBefore: "\${flag}") }`, - [quotedBoolean('asyncBefore')], + 'a quoted expression on a thrown message evaluates to its own text', + `process p { start S throw message("Ack", expression: "b") }`, + [literalElBindingMessage('expression')], ], + ...( + [ + ['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)], + ]), +]); + +checks('Validation - an escaped literal as a field value', [ [ - '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}") + '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 number in versionTag asks for quotes, and reads as no variable', - `process p(versionTag: 3) { start S }`, - [unquotedText('versionTag')], + "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'), + ], ], [ - 'a number in retryCycle asks for quotes', - `process p { user U(retryCycle: 3) }`, - [unquotedText('retryCycle')], + 'the refusal counts one per extra value, whatever the element', + `process p { start S user T("a", "b", "c") }`, + [ + settingsOnlyMessage('a user task'), + SECOND_PAREN_VALUE_MESSAGE, + SECOND_PAREN_VALUE_MESSAGE, + ], ], + ...( + [ + [ + '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} }`, + [settingsOnlyMessage(description)], + ]), [ - 'a bareword in versionTag asks for quotes and still reads as a variable', - `process p(versionTag: deadline) { start S }`, - [warn(undeclared('deadline')), unquotedText('versionTag')], + 'an unkeyed value on the process header is refused the same way', + `process p("a") { user T }`, + [settingsOnlyMessage('a process header')], ], [ - 'a bareword in historyTimeToLive asks for quotes and names no variable', - `process p(historyTimeToLive: P90D) { start S }`, - [unquotedText('historyTimeToLive')], + '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), + ], ], + ...( + [ + ['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 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(jobPriority: "\${weight}") }`, - [], - ], [ 'a key the element does not own is reported once, not once per rule', `process p { start S(dueDate: 3) }`, @@ -1002,177 +931,223 @@ 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, 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, + ], ], [ - 'a service task with two bindings names both', - `process p { service S(class: com.example.X, expression: "\${bean.method(execution)}") }`, + '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), ], ], [ - 'an expression binding alone is enough', - `process p { service S(expression: "\${bean.method(execution)}") }`, - [], + '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', + DECISION_BINDINGS, + ), + ], ], +]); + +checks('Validation - settings the engine reads under one binding alone', [ [ - 'a delegate binding alone is enough', - `process p { service S(delegate: "\${beanName}") }`, - [], + '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'), + ], ], - [ - 'a topic binding alone is enough', - `process p { service S(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") }`, - [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 { } }`, - [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 decision step bound to both a decision and code names both', - `process p { decide D(decision: "riskRating", class: "com.example.Rate") }`, - [ - bindingConflict( - `Decision step 'D'`, - 'decision, class', - DECISION_BINDINGS, - ), + ...['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") }`, + [], ], - ], - [ - '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 needs no binding at all', `process p { receive R }`, []], - [ - 'a receive task takes a message name', - `process p { receive R(message: "OrderPaid") }`, - [], - ], + ), + ...[ + ['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}) }`, + [decisionModifierMessage(key!)], + ]), ]); -/** - * 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', - `process p { service N(type: "mail") { field to = "a@b" field text = "t" } }`, - [], - ], - [ - '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" } }`, - [], - ], +const userWith = (setting: string) => `process p { user U(${setting}) }`; + +type ValueShape = [ + key: string, + program: (value: string) => string, + parsed: string[], + refused: string[], + message: string, +]; + +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 shell decision step with a command alone is clean', - `process p { decide D(type: "shell") { field command = "true" } }`, - [], + 'dueDate', + (v) => userWith(`dueDate: ${v}`), + ['"P2D"', '"2026-01-01T00:00:00"', '"${d}"'], + ['"tomorrow"'], + dueDateShapeMessage('dueDate'), ], [ - 'the type is read bare as it is read quoted', - `process p { service N(type: mail) { field to = "a@b" field text = "t" } }`, - [], + 'followUpDate', + (v) => userWith(`followUpDate: ${v}`), + ['"P2D"'], + ['"next week"'], + dueDateShapeMessage('followUpDate'), ], [ - 'an upper-case type is refused: one printed form keeps the round trip stable', - `process p { service N(type: "MAIL") }`, - [TYPE_VALUE], + '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], + "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" } +}`, [ - missingBuiltinField( + missingBuiltinFieldMessage( `Service task 'N'`, 'mail', - "'to'", - 'No recipient is defined on the mail activity', - 'validateFieldDeclarationsForEmail', + BUILTIN_REQUIRED_FIELDS.mail[0]!, + ), + missingBuiltinFieldMessage( + `Send task 'S'`, + 'shell', + BUILTIN_REQUIRED_FIELDS.shell[0]!, ), ], ], @@ -1180,92 +1155,29 @@ 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]!, ), ], ], - [ - '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") }`, [ - 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', - ), - ], - ], - [ - 'a shell task without a command names the engine check', - `process p { send S(type: "shell") { field wait = "true" } }`, - [ - missingBuiltinField( - `Send task 'S'`, - 'shell', - "'command'", - 'No shell command is defined on the shell activity', - 'validateFieldDeclarationsForShell', - ), - ], - ], - [ - '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')], - ], - [ - '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')], - ], - [ - '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, - ), - ], - ], - [ - '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, + BUILTIN_REQUIRED_FIELDS.mail[1]!, ), ], ], - [ - "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')], - ], [ 'a type beside a code binding is the one-binding conflict and nothing more', `process p { service N(class: "com.example.X", type: "mail") }`, @@ -1276,42 +1188,19 @@ 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 is accepted and ignored, as beside a topic', - `process p { service R(type: "shell", resultVariable: "r") { field command = "ls" } }`, - [], - ], ]); -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', `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} }`, @@ -1322,33 +1211,21 @@ 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', [ [ - 'an unresolved goto is the linker error alone', - `process p { user Foo goto Missing }`, - [unresolvedStatement('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 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 from outside into a parallel branch is one error', @@ -1356,26 +1233,19 @@ checks('Validation - goto', [ [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 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 }`, [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', [ @@ -1389,8 +1259,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 }`, []], - ['a start and an end are flow steps', `process p { start S end E }`, []], + [ + '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 { } }`, @@ -1401,81 +1277,76 @@ 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')], - ], - [ - 'a subprocess with one step is clean', - `process p { subprocess S { user 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 is one warning", - `process p { var flag: boolean while (flag == true) { } }`, - [warn(emptyBranch(`'while' body`))], - ], - [ - "an empty 'do ... while' body is one warning", - `process p { var flag: boolean do { } while (flag == true) }`, - [warn(emptyBranch(`'do' body`))], - ], - [ - 'an empty parallel branch is one warning naming its position', - `process p { parallel { { user A } { } } }`, - [warn(emptyNumberedBranch(2, 'parallel'))], + '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'), + ], ], [ - 'an empty race branch is one warning naming its position', - `process p { await { message("M") { } signal("S") { user B } } }`, - [warn(emptyNumberedBranch(1, 'await'))], + 'a listener written first in a subprocess body observes nothing', + `process p { subprocess S { on start(class: "x.L") user A } }`, + [BLOCK_LISTENER], ], [ 'an empty handler body is one warning', `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', [ [ - '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'), @@ -1483,6 +1354,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'), ], ], [ @@ -1491,52 +1375,27 @@ 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 StartEvent_p - user EndEvent_p user Boundary_X_error user Throw_p_1 user EventSubProcess_x user Catch_p_1 + subprocess StartEvent_p { user A } }`, [ 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'), + mintedTerminal('StartEvent_p', 'start', 'p'), ], ], [ - '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") -}`, - [ - reservedName('Gateway_x_split'), - reservedName('StartEvent_foo'), - reservedName('StartEvent_x'), - ], - ], - // 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 -}`, + "a modelling tool's default start id is an ordinary name", + `process p { start StartEvent_1 user EndEvent_1 }`, [], ], ]); @@ -1547,19 +1406,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', + '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_MESSAGE], ], [ 'starts written back to back open one chain', @@ -1582,39 +1435,22 @@ checks('Validation - where a start may sit', [ [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") } }`, - [], - ], - [ - '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')], - ], - [ - '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 inside an if nested in a subprocess is one error', - `process p { subprocess S { if (true) { 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')], ], ]); @@ -1622,22 +1458,20 @@ 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'))], - ], - [ - '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 }`, - [], + [warn(noDefaultStartMessage('p'))], ], [ - '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 +}`, [], ], [ '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', @@ -1647,18 +1481,76 @@ 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', `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_MESSAGE], + ]), + ...( + [ + ['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_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_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(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(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(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(noDefaultStartMessage('p'))], + ], ]); checks('Validation - duplicate declarations', [ @@ -1667,62 +1559,49 @@ 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 }`, - [duplicateStepName('Review', '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} }`, - [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 { @@ -1730,8 +1609,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", @@ -1743,7 +1623,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", @@ -1755,12 +1635,7 @@ checks('Validation - parallel branch heads', [ else { user B } } }`, - [PARALLEL_SECOND_ELSE], - ], - [ - "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_SECOND_ELSE_MESSAGE], ], ]); @@ -1780,13 +1655,8 @@ process p { expect(diagnostics[0]!.range.start.line).toBe(6); }); }); - -/** - * 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`, pinning that the chain's one head takes the settings. */ const GATEWAY_HOSTS: ReadonlyArray< [kind: string, description: string, head: (items: string) => string] > = [ @@ -1825,7 +1695,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( @@ -1845,7 +1714,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', @@ -1860,7 +1729,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')], + [settingsOnlyMessage('an if statement')], ], [ 'a repeated key is one duplicate', @@ -1870,42 +1739,42 @@ 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', - ifHead('joinExclusive: "false"'), + ifHead('joinAsyncBefore: true, joinExclusive: "false"'), [quotedBoolean('joinExclusive')], ], [ 'a decimal in joinJobPriority is refused as on an element', - ifHead('joinJobPriority: 1.5'), - [priorityShape('joinJobPriority')], + ifHead('joinAsyncBefore: true, joinJobPriority: 1.5'), + [priorityShapeMessage('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'))], ], [ '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', + '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', @@ -1914,14 +1783,220 @@ checks('Validation - gateway settings', [ ], ]); +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( + noJobMessage( + 'retryCycle', + ['asyncBefore', 'asyncAfter'], + 'a service task', + ), + ), + warn( + noJobMessage( + 'exclusive', + ['asyncBefore', 'asyncAfter'], + 'a service task', + ), + ), + warn( + noJobMessage( + 'jobPriority', + ['asyncBefore', 'asyncAfter'], + 'a service task', + ), + ), + warn( + noJobMessage( + 'retryCycle', + ['asyncBefore', '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( + noJobMessage( + 'retryCycle', + ['asyncBefore', 'asyncAfter'], + 'an if statement', + ), + ), + warn( + noJobMessage( + 'joinJobPriority', + ['joinAsyncBefore', 'joinAsyncAfter'], + 'an if statement', + ), + ), + warn( + noJobMessage( + 'exclusive', + ['asyncBefore', 'asyncAfter'], + 'a while loop', + ), + ), + warn( + noJobMessage( + 'jobPriority', + ['asyncBefore', 'asyncAfter'], + 'a do-while loop', + ), + ), + warn( + noJobMessage( + 'retryCycle', + ['asyncBefore', 'asyncAfter'], + 'a parallel statement', + ), + ), + warn( + noJobMessage( + 'joinExclusive', + ['joinAsyncBefore', 'joinAsyncAfter'], + 'a parallel statement', + ), + ), + warn( + noJobMessage( + 'jobPriority', + ['asyncBefore', 'asyncAfter'], + 'an await block', + ), + ), + warn( + noJobMessage( + 'joinRetryCycle', + ['joinAsyncBefore', '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(noJobMessage('retryCycle', ['asyncBefore', 'asyncAfter'], 'a step')), + warn(noJobMessage('exclusive', ['asyncBefore', 'asyncAfter'], 'a step')), + warn( + noPerRunJobMessage( + 'jobPriority', + ['runAsyncBefore', 'runAsyncAfter'], + 'a repeated step', + ), + ), + warn( + noPerRunJobMessage( + 'runRetryCycle', + ['runAsyncBefore', 'runAsyncAfter'], + 'a repeated step', + ), + ), + warn( + noPerRunJobMessage( + 'runExclusive', + ['runAsyncBefore', '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 } +}`, + [], + ], + [ + '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) + await { signal("S3", jobPriority: 5) { user A } message("M") { user B } } + on signal("S4", jobPriority: 5) { user C } +}`, + [ + warn( + noJobMessage( + 'exclusive', + ['asyncBefore', 'asyncAfter'], + 'a start event', + ), + ), + warn( + noJobMessage( + 'retryCycle', + ['asyncBefore', 'asyncAfter'], + 'a start event', + ), + ), + warn( + noJobMessage( + 'jobPriority', + ['asyncBefore', 'asyncAfter'], + 'an event handler', + ), + ), + ], + ], +]); + 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', @@ -1934,9 +2009,9 @@ checks('Validation - form fields', [ [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')], + '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 bare attribute on a start event is not valid there', @@ -1968,66 +2043,83 @@ 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_MESSAGE], + ], + [ + `an initiator on the start of ${kind} is never written`, + wrap('start In(initiator: "who") user A'), + [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')]], + 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', [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]], - ['min: -5', 'number', []], - ['min: "-5"', 'number', []], - ['maxlength: "80"', 'string', []], - ['validator: com.example.Check', 'string', []], + 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", `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', @@ -2042,7 +2134,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', @@ -2052,105 +2144,140 @@ 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" } } } }`, + [formFieldDirectionMessage('p', 'input', false)], ], ]); -checks('Validation - unreachable statements', [ +checks('Validation - form field defaults', [ [ - 'a step after an end in the same block can never run', - `process p { start S end Done user Dead }`, - [UNREACHABLE], + 'a decimal default on a number field is not an integer', + `process p { start S { form { n: number = 1.5 } } }`, + [numberDefaultMessage('n', '1.5')], ], [ - 'every dead step after an end is reported', - `process p { start S end Done user A user B }`, - [UNREACHABLE, UNREACHABLE], + 'a quoted non-digit default on a number field is not an integer', + `process p { start S { form { n: number = "high" } } }`, + [numberDefaultMessage('n', '"high"')], ], [ - 'a step after a goto can never run', - `process p { user A goto A user Dead }`, - [UNREACHABLE], + 'a quoted word default on a boolean field is neither true nor false', + `process p { start S { form { b: boolean = "yes" } } }`, + [booleanDefaultMessage('b', '"yes"')], ], [ - 'a goto target after an end stays reachable', - `process p { start S if (cond) { goto Retry } end Done user Retry }`, - [warn(undeclared('cond'))], + '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" } } }`, + [isoDateDefaultMessage('d', '2026-01-01')], ], [ - 'an unreachable compound is reported once, not once per nested step', - `process p { start S end Done if (cond) { user A user B } }`, - [warn(undeclared('cond')), UNREACHABLE], + '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" } } }`, + [], ], [ - 'ordinary sequential flow is clean', - `process p { start S user A end Done }`, + '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 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 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 step after an all-terminating parallel can never run', - `process p { var c: boolean parallel { { end A } { end B } } user Dead }`, - [UNREACHABLE], + '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], ], [ - '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 }`, - [], + 'an unreachable compound is reported once, not once per nested step', + `process p { start S end Done if (cond) { user A user B } }`, + [warn(undeclared('cond')), 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 }`, + '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 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 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], ], [ - 'a step after an if without else stays reachable', - `process p { if (cond) { end A } user Alive }`, - [warn(undeclared('cond'))], + '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 }`, + [UNREACHABLE], ], [ - '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 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 step after an end inside a subprocess can never run', - `process p { subprocess S { start In end Out user Dead } }`, - [UNREACHABLE], + '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], ], [ 'an unreachable subprocess is reported once, not once per nested step', `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 } }`, [], ], [ - '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', @@ -2162,32 +2289,15 @@ 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") }`, - [warn(undeclared('c'))], - ], ]); 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', + "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 @@ -2205,108 +2315,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: "") }`, - [CALL_PROCESS_EMPTY], - ], - [ - "'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 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 } }`, - [], - ], - [ - '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', @@ -2335,9 +2370,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', @@ -2355,54 +2400,39 @@ 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 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')], + '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', `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', `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', @@ -2412,7 +2442,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', @@ -2440,35 +2470,35 @@ 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") { } }`, - [unknownOnKind('erorr')], - ], - [ - "'on conditional' is a did-you-mean", - `process p { start S on conditional { user A } }`, - [CONDITIONAL_TYPO], - ], - [ - "'on compensate' is a did-you-mean", - `process p { start S on compensate { user A } }`, - [COMPENSATE_TYPO], - ], - [ - '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 } }`, - [], + "'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], ], [ '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')], + ], [ '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 } }`, @@ -2480,33 +2510,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 } }`, [], ], [ @@ -2522,7 +2527,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', @@ -2554,21 +2559,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,27 +2571,154 @@ checks('Validation - event handlers', [ ], ]); +// 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'; + 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 } } }`, + }, + { + 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") } }`, + }, +]; + +function timerClause(particle: string, value: string): string { + return particle === 'after' ? `"${value}"` : `${particle}: "${value}"`; +} + +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_MESSAGE, + }, + { + 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_MESSAGE, + }, + { + 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_MESSAGE, + }, +]; + +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], - ], - [ - '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 } }`, @@ -2645,8 +2767,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 { @@ -2664,7 +2784,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", @@ -2686,6 +2806,40 @@ ${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')], + ], + [ + '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')], + ], + [ + '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,17 +2848,25 @@ ${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')], + [missingHost('Missing')], ], [ - 'a hosted error handler still cannot run alongside', - `process p { error X user Pack on Pack: error(X, alongside) { user A } }`, - [ERROR_ALWAYS_INTERRUPTS], + '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', @@ -2723,93 +2885,70 @@ ${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], + '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', `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], - ], - [ - '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], + [blockNoFlowSteps('a subprocess', 'S'), COMPENSATION_ALONGSIDE_MESSAGE], ], [ '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], - ], - [ - 'an undo block in a branch is the generic placement error, once', - `process p { subprocess S { if (true) { on compensation { user A } } } }`, - [HANDLER_PLACEMENT], + [noFlowSteps('p'), COMPENSATION_PLACEMENT_MESSAGE], ], [ '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')], - ], - [ - '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 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], + [COMPENSATION_DUPLICATE_MESSAGE, blockNoFlowSteps('an attempt block', 'A')], ], + ...(['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', [ @@ -2842,27 +2981,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', @@ -2872,17 +3011,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', @@ -2900,20 +3039,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", @@ -2926,76 +3060,64 @@ 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], ], ]); checks('Validation - throw and emit', [ [ - "'emit error' points at 'throw error'", - `process p { error X emit error(X) }`, - [EMIT_ERROR], - ], - [ - 'an unknown throw kind names the kinds with a terminal form', - `process p { throw banana("X") }`, - [unknownThrowKind('banana')], - ], - [ - 'an unknown emit kind leaves error off the list', - `process p { emit banana("X") }`, - [unknownEmitKind('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', `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 }`, @@ -3006,54 +3128,33 @@ 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") }`, [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 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 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)], ], [ @@ -3064,78 +3165,68 @@ 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 error is raised outward, so it cannot be awaited', - `process p { error E await error(E) }`, - [unknownAwaitKind('error')], + "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'), + ], ], [ 'an escalation cannot be awaited either', `process p { escalation E await escalation(E) }`, - [unknownAwaitKind('escalation')], - ], - [ - 'compensation runs through an undo block, not an await', - `process p { await compensation }`, - [unknownAwaitKind('compensation')], - ], - [ - 'an unknown word names the kinds an await takes and where the rest are written', - `process p { start S await nonsense }`, - [unknownAwaitKind('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], - ], - [ - 'an awaited condition with no parens asks for the condition', - `process p { await condition }`, - [AWAIT_CONDITION_REQUIRED], - ], - [ - 'a timer key on an awaited message belongs to a timer', - `process p { await message(at: "PT1H") }`, - [awaitNameRequired('message'), AWAIT_PARTICLE_ONLY], - ], - [ - '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 code string on an awaited condition is not the condition', - `process p { await condition("X") }`, - [AWAIT_CONDITION_NO_CODE], - ], - [ - '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], + [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 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')], + '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`.", + "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')), + blockParameter('message'), + ], ], ]); @@ -3192,221 +3283,136 @@ 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], - ], - [ - 'a link cannot head a race branch', - `process p { await { link("L") { user A } message("M") { user B } } }`, - [LINK_IN_RACE], - ], - [ - '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')], - ], - [ - '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')], + '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], ], [ - '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") }`, + '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 +}`, [ - linkThrowNeverRuns("Setting 'asyncBefore'"), - linkThrowNeverRuns("Setting 'jobPriority'"), - linkThrowNeverRuns("The 'on end' listener"), + LINK_IN_RACE_MESSAGE, + awaitNameRequired('link'), + codeRequired('An emitted', 'link', 'emit'), ], ], [ - 'an emit link with no catch is refused', - `process p { step A emit link T("L") }`, - [linkNoCatch('L')], - ], - [ - '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')], - ], - [ - 'an emit link cannot enter a parallel branch from outside it', - `process p { parallel { { user P if (c) { emit link T("L") } } { end X await link C("L") user B } } }`, - [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 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") }`, - [THROW_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 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], - ], - [ - '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) }`, - [startRaisedKind('error')], - ], - [ - 'the engine ignores an escalation start', - `process p { escalation X start S escalation(X) }`, - [startRaisedKind('escalation')], - ], - [ - 'a compensation start points at the undo block', - `process p { start S compensation }`, - [START_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], - ], - [ - 'an unknown start kind names the legal ones', - `process p { start S nonsense("X") }`, - [unknownStartKind('nonsense')], + '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"), + ], ], [ - 'a message start name cannot hold a trailing expression', - `process p { start S message("Order\${x}") }`, - [startMessageExpression('Order${x}')], + 'an emit link with no catch is refused', + `process p { step A emit link T("L") }`, + [linkNoCatch('L')], ], [ - 'a message start name cannot hold a whole expression', - `process p { start S message("#{orderType}") }`, - [startMessageExpression('#{orderType}')], + "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')], ], [ - 'the other expression spelling is rejected too', - `process p { start S message("Order#{x}") }`, - [startMessageExpression('Order#{x}')], + 'an emit link cannot enter a parallel branch from outside it', + `process p { parallel { { user P if (c) { emit link T("L") } } { end X await link C("L") user B } } }`, + [warn(undeclared('c')), linkIntoBranch('L', 'parallel')], ], [ - 'a signal start name may hold an expression', - `process p { start S signal("Order\${x}") user A }`, - [], + "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 signal start name may hold the other spelling', - `process p { start S signal("#{orderType}") user A }`, - [], + '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'))], ], +]); + +checks('Validation - start triggers', [ [ - 'an awaited message name may hold an expression: the process is running', - `process p { start S await message("Order\${x}") user A }`, - [], + "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'), + ], ], [ - 'an awaited message name may hold the other spelling', - `process p { start S await message("#{orderType}") user A }`, - [], + "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}'), + ], ], [ - 'a handler message name may hold an expression', - `process p { start S user A on message("Order\${x}") { user B } }`, - [], + '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}')], ], [ 'only the process own start carries a trigger, not a subprocess start', @@ -3421,82 +3427,71 @@ 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', + `process p { start S if (true) { start In message("M") } end E }`, + [startNotFirst('In')], ], ]); 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 { @@ -3534,14 +3529,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', @@ -3554,14 +3547,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', @@ -3570,11 +3561,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([ [ @@ -3639,14 +3626,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 }`, - [unknownCodeDeclarationKind('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', @@ -3655,57 +3678,29 @@ checks('Validation - code declarations', [ "Expecting token of type '}' but found `{`.", 'Expecting end of file but found `}`.', noFlowSteps('p'), - unknownCodeDeclarationKind('usr'), + 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_HAS_NO_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 }`, @@ -3714,40 +3709,22 @@ 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], - ], - [ - 'a declaration may carry the name of a step', - `process p { error Pack user Pack }`, - [], + [DECLARATION_SETTINGS_ONLY_MESSAGE], ], - // 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") }`, - [quotedCode('error', 'error PAYMENT_DECLINED', '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('error', 'PAYMENT_DECLINED')], ], [ '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', @@ -3756,10 +3733,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] > = [ @@ -3771,54 +3744,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')], ]); }, ); @@ -3841,152 +3779,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) => { + 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(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([ - noPropertyHost(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([ - noMappingHost(description), + 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)] : []), ]); }, ); @@ -4009,281 +3852,154 @@ 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 } }`, - [unknownDirection('inp')], - ], - [ - '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} } }`, - [], - ], - [ - '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], - ], - [ - '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 }`, - [], - ], - [ - 'an output parameter name is in scope for a later step', - `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 } } }`, - [], - ], -]); - -// 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 } }`, - [], - ], - [ - '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 } }`, + '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 } } +}`, [ - fieldValue('greeting'), - unknownDirection('fld', `'input', 'output', 'field', or 'property'`), + 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 user task names the kinds that take a field', - `process p { user U { field greeting = "hello" } }`, - [noFields('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" } } }`, - [], - ], - [ - 'a delegate-bound task listener carries a field', - `process p { user U { on complete(delegate: "\${listenerBean}") { field greeting = "hello" } } }`, + '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 }`, [], ], [ - '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')], - ], - [ - '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`)], - ], - [ - '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`)], - ], - [ - '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 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 output parameter name is in scope for a later step', + `process p { start S user U { output total = 1 } step T { input seen = total } end E }`, [], ], +]); + +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'`)], + "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']), + 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 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'`)], + '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), + ], ], ]); -/** - * 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')], - ], - [ - 'a priority under a decision binding names the binding written', - `process p { decide D(decision: "k", taskPriority: 5) }`, - [topicBinding('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')], - ], - [ - 'a property value is text', - `process p { service V(topic: "t") { property k = ["a"] } }`, - [propertyValue('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'))], - ], - // 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')], - ], - [ - 'a mapping raises an error and nothing else', - `process p { escalation S service V(topic: "t") { escalation S when "\${x}" } }`, - [MAPPING_HEAD], - ], - [ - 'a mapping is written with when', - `process p { error E service V(topic: "t") { error E wenn "\${x}" } }`, - [MAPPING_WHEN], - ], - [ - '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, + ], ], ]); -/** - * 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"', []], @@ -4294,7 +4010,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, ]), @@ -4302,69 +4018,36 @@ 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 key beside a form reference is one error', - `process p { user T(formKey: "k", formRef: "review-form", binding: latest) }`, - [FORM_KEY_AND_REF], - ], - [ - 'a form reference with no binding is one error', - `process p { user T(formRef: "review-form") }`, - [FORM_REF_NEEDS_BINDING], - ], - [ - 'a binding with no form reference has nothing to pin', - `process p { user T(binding: latest) }`, - [FORM_REF_MISSING], - ], - [ - 'a version with no form reference has nothing to pin', - `process p { user T(version: 2) }`, - [FORM_REF_MISSING], - ], - [ - '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 id names a form, not a variable', - `process p { user T(formRef: reviewForm, binding: latest) }`, - [], + '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") - on assign(expression: "\${bean.assign(task)}") + on assignment(expression: "\${bean.assign(task)}") on complete(delegate: "\${listenerBean}") on update ${FENCE}groovy log(task) @@ -4372,99 +4055,61 @@ 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')], - ], - [ - '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)], - ], - [ - '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], - ], - [ - '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')], - ], - [ - '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')], - ], - [ - "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`)], + '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") } }`, + [], ], + ...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', () => { - // 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', @@ -4509,23 +4154,93 @@ 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") }`, [], ], ]); -/** The form carrying every optional part, the one a nested placement must still take. */ +const header = (setting: string) => `process p(${setting}) { start S }`; + +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_MESSAGE], + ], + ), + [ + 'a bareword historyTimeToLive is refused the same way and names no variable', + header('historyTimeToLive: P90D'), + [HISTORY_TIME_TO_LIVE_MESSAGE], + ], + [ + 'a versionTag template is stored as its own text, so it is refused', + header('versionTag: "${v}"'), + [VERSION_TAG_LITERAL_MESSAGE], + ], + [ + 'a number in versionTag asks for quotes', + header('versionTag: 3'), + [VERSION_TAG_LITERAL_MESSAGE], + ], + [ + 'a bareword in versionTag asks for quotes and names no variable', + header('versionTag: deadline'), + [VERSION_TAG_LITERAL_MESSAGE], + ], + [ + '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_MESSAGE], + ], + [ + 'a template anywhere in a starter list is stored as a literal id, so it is refused', + header( + 'candidateStarterUsers: "demo, ${starter}", candidateStarterGroups: "#{g}"', + ), + [ + candidateStarterMessage('candidateStarterUsers'), + 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}"'), + [headerLiteralMessage('label'), headerLiteralMessage('documentation')], + ], + [ + 'a bareword header label names no variable', + header('label: foo'), + [headerLiteralMessage('label')], + ], + [ + 'a label on a step still takes the expression a task name evaluates', + `process p { user U(label: "\${who}") }`, + [], + ], +]); + 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'. - */ const REPEAT_HOSTS: Array< [ kind: string, @@ -4552,7 +4267,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(', '); @@ -4564,108 +4278,76 @@ 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([ - ['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]); - }, - ); - - 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. */ 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`, @@ -4676,61 +4358,43 @@ 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), ], ]), [ - '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], + '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", runExclusive: "false") }`, - [quotedBoolean('runExclusive')], - ], - [ - 'a number in runRetryCycle asks for quotes', - `process p { service V for 3(topic: "t", 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) }`, - [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) }`, - [], - ], ]); /** - * 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 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'], @@ -4742,8 +4406,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 }`, @@ -4753,8 +4416,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 }`, @@ -4815,8 +4477,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 }`, ], @@ -4830,8 +4491,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 }`, @@ -4849,58 +4509,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 953a06a..527ef6a 100644 --- a/packages/language/test/variables.test.ts +++ b/packages/language/test/variables.test.ts @@ -1,44 +1,36 @@ -/** - * 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. - */ - 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,71 +39,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('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 repeat element enters the table as any beside the loop counters', + `process p { + var items: json + user U for each item in items +}`, + new Map([ + ['items', { name: 'items', type: 'json' }], + ['item', { name: 'item', type: 'any' }], + ...LOOP_COUNTERS, + ]), + ], + [ + '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 ffa1aba..5eb4f89 100644 --- a/packages/language/test/vocabulary-sync.test.ts +++ b/packages/language/test/vocabulary-sync.test.ts @@ -1,13 +1,5 @@ -/** - * 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. - */ +// 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'; @@ -18,7 +10,10 @@ import { EMIT_TRIGGERS, END_TRIGGERS, EXECUTION_LISTENER_EVENTS, + FLAG_WORD_RULE, ON_TRIGGERS, + SETTING_KEY_RULE, + splitFencedScript, START_TRIGGERS, TASK_LISTENER_EVENTS, THROW_TRIGGERS, @@ -30,8 +25,8 @@ 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$/; +/** The `keyword.control` alternation as `textmate-postprocess.mjs` leaves it, hyphen-aware so `invoice-start` colours no `start`. */ +const ALTERNATION = /^\(\? { ]; 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 }[] { + const raw = readFileSync(TEXTMATE_GRAMMAR, 'utf8'); + return (JSON.parse(raw) as { patterns: { name?: string; match?: string }[] }) + .patterns; +} + +/** 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) { + 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( + splitFencedScript('```groovy\r\ndef a = 1\r\ndef b = 2\r\n```'), + ).toEqual({ tag: 'groovy', code: 'def a = 1\ndef b = 2\n' }); + }); + + 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/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/README.md b/packages/transform/README.md index 1dd1a2d..6195bc1 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-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 @@ -40,8 +40,9 @@ 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 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 @@ -49,21 +50,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 { @@ -76,19 +77,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-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. @@ -102,8 +104,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. @@ -111,7 +113,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 @@ -125,54 +127,119 @@ 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 `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, 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 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. -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. -`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. +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 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 | +`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 `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 `` 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-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`. + +`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-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. + +`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 @@ -188,32 +255,48 @@ interface ImportWarning { | 'label' | 'unreferencedRoot' | 'documentation' - | 'unmappedConstruct'; + | 'unmappedConstruct' + | 'rewritten' + | 'behaviourChanged' + | 'carriedAsWritten'; message: string; // names the concrete construct } ``` -`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` or `taskPriority` 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. 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. 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. -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 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 `external` or no binding on a thrown message is dropped 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 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. +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, 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 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)). -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. +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. @@ -221,43 +304,121 @@ 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-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; 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 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)). +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 `rewritten` 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 `rewritten` 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, reported as `behaviourChanged`, 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. - -Two more members report neither a drop nor a change, but a construct that arrived half-written. + +`rewritten` covers content imported in a changed form that the engine reads as the source, where nothing is dropped. +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. +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 source reports `intermediateNoneThrowEvent`. +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: `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. +A script task with no `scriptFormat` is imported as `juel`, the `ScriptingEngines.DEFAULT_SCRIPTING_LANGUAGE` that `BpmnParse.parseScriptTaskElement` substitutes. +An injected field's `operaton:string` child is written back as a `stringValue` attribute, whose text the engine injects the same way. +A repeated enum value id on a form field is kept the way `FormTypes.parseFormPropertyType` keeps it, once at its first position with its last `name`, and a repeated `operaton:property` key once at its first position with its last value, as `DefaultFormHandler.parseProperties` and `BpmnParseUtil.parseOperatonExtensionProperties` keep it. + +`behaviourChanged` covers content imported in a changed form that the engine deploys or runs differently from the source, where the warning names the difference. +`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. +An absent `operaton:historyTimeToLive` is written back as `P30D`, so the rebuilt process deploys where the source, under the engine's default `enforceHistoryTimeToLive`, did not. +A `bpmn:transaction` with no start event imports with one added, so the block runs where `SubProcessActivityBehavior.execute` fails the source on entering it. +A `bpmn:conditionExpression` or `bpmn:completionCondition` body with no `${` or `#{` opener at all is written inside `${...}`, which evaluates it, where `UelExpressionCondition.evaluate` (or `MultiInstanceActivityBehavior.completionConditionSatisfied`) reads the bare text as a string and fails on every run. +An empty condition body, which fails the same way, is dropped instead: the flow imports with no condition and the rebuild routes along it. + +`carriedAsWritten` covers a construct that arrived half-written and imports unchanged, with a warning that the engine deploys it and then fails or misreads it, and that the printed script draws a diagnostic there. +On a form field 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"`, deploys, and `ShellActivityBehavior.readFields` compares it with `"true"` case-sensitively, so the engine reads it as false. +A `resultVariable` beside an `external` or a built-in `type` binding on a task deploys and is never written, since `parseServiceTaskLike` hands it to an expression binding alone. +A script whose `scriptFormat` lies outside the fence tags this surface knows imports under that tag, and a `bpmn:script` or `operaton:script` with an empty body imports empty, since `ScriptUtil.getScript` checks the source for null and not for emptiness. +An execution listener with `expression=""` and a task listener with `class=""`, `expression=""`, or `delegateExpression=""` carry the empty text: `ExpressionExecutionListener` and `ExpressionTaskListener` evaluate it, while `ClassDelegateTaskListener` and `DelegateExpressionTaskListener` fail when the listener's event fires. 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. A block holding a cancel end with no cancel boundary attached imports whole, and the warning names the error the engine stops with the first time that end is reached. 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. +Refusing either would reject a file the engine deploys although the rebuilt document fails the same way the source does; a boundary event without an id is refused even where it deploys, because the id minted for it would change what runs. + +### 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-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. ## Build and test @@ -273,39 +434,42 @@ 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 `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)). +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. +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 -- `@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 f440bab..f7058df 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'; @@ -52,8 +43,9 @@ import { integerLiteralText, renderExpression, formatPlainWordList, - SCRIPT_FORMAT_ALIASES, + scriptFormatOf, splitFencedScript, + CALL_BINDING_VALUES, CALL_MAPPER_KEY_BY_KIND, CATCH_TRIGGERS, DATE_PATTERN_KEY, @@ -64,10 +56,13 @@ import { EXECUTION_LISTENER_EVENTS, FIELD_DIRECTION, FORM_FIELD_TYPES, + INPUT_DIRECTION, isFormConstraintName, ON_TRIGGERS, + OUTPUT_DIRECTION, PROPERTY_DIRECTION, START_TRIGGERS, + STARTABLE_KEY, TASK_PRIORITY_KEY, TYPE_BINDING_KEY, TYPE_BINDING_VALUES, @@ -83,6 +78,7 @@ import { TASK_LISTENER_EVENTS, THROW_TRIGGERS, TIMER_PARTICLE_BY_KIND, + type TimerKind, isNamedStatement, } from '@bpmn-script/language'; import type { @@ -128,6 +124,7 @@ import type { CallVariableMapping, CatchEventDefinition, CodeBinding, + EmitEventDefinition, EndEventDefinition, EngineAttributes, ErrorMapping as IrErrorMapping, @@ -161,6 +158,7 @@ import { eventIdentities, ioMapped, jobSettings, + splitTimerJobSettings, } from './ir/types.js'; import { makeGatewaySplitId, @@ -181,42 +179,37 @@ 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; /** - * 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. + * 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 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). - */ +/** `taken` is shared document-wide and seeded with every authored name. */ interface Builder { readonly flowElements: FlowElement[]; readonly sequenceFlows: IrSequenceFlow[]; readonly taken: Set; } -/** Convert an AST `Model` into a {@link BpmnProcess}. Only the first `process` block is read. */ +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) { @@ -229,11 +222,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( @@ -244,6 +234,10 @@ export function astToIr(model: Model): BpmnProcess { process, 'candidateStarterGroups', ); + const isStartableInTasklist = boolAttrValue( + settingsOf(process.items), + STARTABLE_KEY, + ); const { errorCodes, escalationCodes } = eventIdentities({ id: process.name, flowElements: builder.flowElements, @@ -257,13 +251,13 @@ 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 } : {}), ...(candidateStarterUsers !== undefined ? { candidateStarterUsers } : {}), ...(candidateStarterGroups !== undefined ? { candidateStarterGroups } : {}), + ...(isStartableInTasklist !== undefined ? { isStartableInTasklist } : {}), flowElements: builder.flowElements, sequenceFlows: builder.sequenceFlows, ...(errorDecls.length > 0 ? { errorDecls } : {}), @@ -271,7 +265,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; @@ -279,17 +272,10 @@ 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. + * 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, @@ -326,8 +312,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. + // 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), @@ -343,11 +328,6 @@ function collectCodeDecls( return { errorDecls, escalationDecls }; } -/** - * 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`. - */ function lowerContainerBody( builder: Builder, statements: Statement[], @@ -367,20 +347,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. + // Empty, or only `on` handlers. const startId = makeStartEventId(containerId, builder.taken); const endId = makeEndEventId(containerId, builder.taken); builder.flowElements.unshift({ kind: 'startEvent', id: startId }); @@ -390,11 +365,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, @@ -407,12 +381,9 @@ 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. 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); @@ -421,8 +392,6 @@ 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. const stmtEntry = frontier.entry!; if (entry === null) { @@ -447,8 +416,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, @@ -458,15 +425,13 @@ 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 the branch segment (`_t`, `_e`, + * `_b`). */ 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 `_`. */ function lowerStatement( builder: Builder, stmt: Statement, @@ -533,7 +498,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}'.`, ); @@ -556,10 +520,8 @@ 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 literal return type forces every dispatch to carry an arm per admitted + * word. */ function admittedTrigger( vocabulary: readonly W[], @@ -570,11 +532,6 @@ 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. - */ function startEventDefinition( stmt: AstStartEvent, ): EventDefinition | undefined { @@ -584,11 +541,6 @@ 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. - */ function endEventDefinition( trigger: string | undefined, ): EndEventDefinition | undefined { @@ -608,21 +560,17 @@ 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 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', @@ -646,8 +594,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'); @@ -701,8 +649,8 @@ 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. + * Source order, which the engine validates in. Other keys (`pattern` included) + * are left to the validator. */ function readFormFieldConstraints( settings: KeyValueAttr[], @@ -714,11 +662,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 {@link 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, @@ -737,7 +681,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; @@ -751,7 +694,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, @@ -786,37 +728,29 @@ 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. - */ 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 }; } 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: '' }; -/** The three code forms first, then `type`, then `topic`, which emits `operaton:type="external"`. */ 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) { @@ -831,9 +765,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(); @@ -862,7 +795,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({ @@ -889,11 +821,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. - */ +/** Without `decision` the block reads as a service task's. */ function businessRuleBinding(attrs: KeyValueAttr[]): ServiceTaskBinding { const decisionRef = attrValue(attrs, 'decision'); if (decisionRef === undefined) { @@ -921,7 +849,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'); @@ -929,7 +856,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), @@ -940,23 +867,14 @@ 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. + * 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); 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({ @@ -984,7 +902,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 }] : []), @@ -997,25 +914,17 @@ 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; - /** 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 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. + * An unclaimed `defaultFlowId` runs fork -> join, so an all-conditioned fork + * has somewhere to go when none holds. */ function lowerForkBranches( builder: Builder, @@ -1044,11 +953,6 @@ 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`. - */ function lowerWhile( builder: Builder, stmt: WhileStatement, @@ -1071,19 +975,12 @@ 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 - // 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. - */ function lowerDoWhile( builder: Builder, stmt: DoWhileStatement, @@ -1109,27 +1006,20 @@ 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. - * - * 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 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. + * 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, @@ -1143,15 +1033,21 @@ 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)); + + // An idle claim would rename an authored collider 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({ @@ -1164,8 +1060,7 @@ 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`, so an invalid second one lowers deterministically. const defaultIndex = inclusive ? stmt.branches.findIndex((b) => b.otherwise) : -1; @@ -1185,15 +1080,10 @@ 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. + * 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); @@ -1230,25 +1120,12 @@ function lowerRace(builder: Builder, stmt: RaceStatement, x: string): Frontier { return { entry: raceId, exit: pruneUnreachableJoin(builder, joinId) }; } -/** - * 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. - */ 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({ @@ -1266,15 +1143,8 @@ 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, never on the - * trigger start it wraps: that start is elided on print, so anything stored - * there would be unrecoverable on the way back. + * 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, @@ -1285,11 +1155,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); @@ -1298,32 +1164,29 @@ 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, }); } /** - * 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. - * - * 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`. + * 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, @@ -1342,8 +1205,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, @@ -1353,20 +1214,18 @@ 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. + * 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( @@ -1384,11 +1243,8 @@ 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. + * 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); @@ -1432,16 +1288,10 @@ 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. - */ -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'; @@ -1451,7 +1301,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, @@ -1470,11 +1319,6 @@ function lowerThrow( return { entry: id, exit: null }; } -/** - * 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. - */ function thrownMessageBinding( def: EventDefinition, attrs: KeyValueAttr[], @@ -1487,8 +1331,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, @@ -1511,11 +1355,10 @@ 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): EventDefinition { +function emitEventDefinition(stmt: EmitStatement): EmitEventDefinition { const trigger = admittedTrigger(EMIT_TRIGGERS, stmt.trigger); const code = raisedCodeOf(stmt.items); switch (trigger) { @@ -1526,7 +1369,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 }; @@ -1555,7 +1398,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); @@ -1580,26 +1422,20 @@ function throwEventDefinition(stmt: ThrowStatement): EndEventDefinition { } } -/** - * 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. - */ +const ALWAYS_TRUE = '${true}'; + 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. + * A bare word naming a declaration resolves to that declaration's `code` + * setting. */ function raisedCodeOf(items: ParenItem[]): string | undefined { const payload = payloadItemOf(items)?.value; @@ -1610,13 +1446,8 @@ function raisedCodeOf(items: ParenItem[]): string | undefined { : payloadTextOf(items); } -/** 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[] }, @@ -1638,7 +1469,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': @@ -1652,15 +1483,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 = rawExpressionAttrValue( - 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({ @@ -1680,13 +1508,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 keeps it, that text being what Operaton - * evaluates as EL. - */ +/** Class first, the order Operaton resolves them in. */ function callVariableMapper( attrs: KeyValueAttr[], ): CallVariableMapper | undefined { @@ -1694,7 +1516,7 @@ function callVariableMapper( if (className !== undefined) { return { kind: 'class', className }; } - const expression = rawExpressionAttrValue( + const expression = elAttrValue( attrs, CALL_MAPPER_KEY_BY_KIND.delegateExpression, ); @@ -1704,13 +1526,7 @@ function callVariableMapper( return undefined; } -/** - * 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`. - */ +/** `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) { @@ -1722,22 +1538,13 @@ 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. - */ function numericOrElValue(expr: Expr): string { const integer = integerLiteralText(expr); if (integer !== undefined) { @@ -1746,13 +1553,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, @@ -1761,7 +1564,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[]; @@ -1775,12 +1577,6 @@ function lowerCallMappings(mappings: VariableMapping[]): { return { inMappings, outMappings }; } -/** - * 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`. - */ function lowerCallMapping(mapping: VariableMapping): CallVariableMapping { const local = mapping.local ? ({ local: true } as const) : {}; if (mapping.all) { @@ -1800,24 +1596,17 @@ function lowerCallMapping(mapping: VariableMapping): CallVariableMapping { } return { kind: 'expression', - sourceExpression: renderExpression(mapping.source), + sourceExpression: elText(mapping.source), target, ...local, }; } -/** - * 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. - */ +/** No node: `entry` is the target. `$refText` survives an unresolved link. */ 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. */ interface RepeatOwner { cardinality?: Expr; collection?: Expr; @@ -1828,13 +1617,9 @@ 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: - * `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. + * 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) { @@ -1856,7 +1641,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, }, @@ -1864,9 +1648,8 @@ 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. + * The engine `Integer.parseInt`s a body without `${`, so only a plain integer + * goes in bare. */ function loopCardinality(expr: Expr): string { if (isLiteralInt(expr)) { @@ -1876,11 +1659,8 @@ 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 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) { @@ -1892,7 +1672,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[]; @@ -1907,8 +1686,8 @@ 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 (join, loop + * run). */ function readJobSettings( attrs: Setting[], @@ -1924,9 +1703,8 @@ 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. + * A task event on an element without a task lifecycle is dropped; the validator + * reports it. */ function readExecutionListeners( listeners: AstListener[], @@ -1940,10 +1718,6 @@ 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. - */ function readTaskListeners( listeners: AstListener[], ): TaskListener[] | undefined { @@ -1965,7 +1739,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[], @@ -1975,11 +1748,10 @@ 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); - 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, @@ -1987,25 +1759,17 @@ 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. - */ 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, map, or script has no `operaton:field` form; the validator reports + * it. */ function readFieldInjections(params: AstIoParameter[]): FieldInjection[] { return params @@ -2032,7 +1796,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, @@ -2051,11 +1814,6 @@ 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. - */ function lowerErrorMappings( mappings: AstErrorMapping[], ): IrErrorMapping[] | undefined { @@ -2075,18 +1833,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, @@ -2096,7 +1849,6 @@ function lowerIoParameters( .map((param) => ({ name: param.name, value: lowerIoValue(param.value) })); } -/** Lists and maps recurse; anything else becomes the plain text {@link attrValue} resolves. */ function lowerIoValue(value: AstIoValue): IoValue { if (isListLiteral(value)) { return { kind: 'list', items: value.items.map(lowerIoValue) }; @@ -2112,9 +1864,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( @@ -2129,12 +1881,8 @@ 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. + * 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); @@ -2142,12 +1890,6 @@ function reserveDefaultFlowId(builder: Builder, gatewayId: string): string { return id; } -/** - * 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. - */ function addFlow( builder: Builder, sourceRef: string, @@ -2166,7 +1908,6 @@ function addFlow( }); } -/** Honors a reserved `exitFlowId`; a branch that terminated gets no continuation. */ function joinContinuation( builder: Builder, branch: Frontier, @@ -2177,28 +1918,18 @@ 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. - */ +/** 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; } - 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; } -/** - * 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 - * {@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) @@ -2207,14 +1938,8 @@ 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. - */ function namedAttrs(stmt: { items: ParenItem[] }): Named { const attrs = settingsOf(stmt.items); const name = attrValue(attrs, 'label'); @@ -2225,31 +1950,24 @@ 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. - */ 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 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. + * 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)) { - // 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); @@ -2257,21 +1975,18 @@ function exprText(value: Expr): string { return rendered; } -/** - * 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. - */ -function rawExpressionAttrValue( - attrs: KeyValueAttr[], - key: string, -): string | undefined { +function elText(value: Expr): string { + if (isLiteralString(value)) { + return value.value; + } + return renderExpression(value); +} + +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. */ function stripExpressionWrapper(rendered: string): string { if (rendered.startsWith('${') && rendered.endsWith('}')) { return rendered.slice(2, -1); @@ -2279,7 +1994,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/bpmn-moddle.d.ts b/packages/transform/src/bpmn-moddle.d.ts index b62d90e..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,9 +56,23 @@ declare module 'saxen' { getContext: () => ParseContext, ) => void; + /** 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..2ac7691 100644 --- a/packages/transform/src/cfg-analysis.ts +++ b/packages/transform/src/cfg-analysis.ts @@ -1,40 +1,56 @@ /** - * 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 0014). */ - +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 that cannot reach it. */ + /** + * `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; dominates(a: string, b: string): boolean; postDominates(a: string, b: string): boolean; - /** Original {@link SequenceFlow} objects, in input order. */ + /** + * 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; + + loopImmediateDominator(node: string): string | undefined; + backEdges(): SequenceFlow[]; + /** + * 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 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 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; + outgoing(node: string): string[]; incoming(node: string): string[]; @@ -45,8 +61,23 @@ 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); + // 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); + const { latches, entered, jumpCycleHeads, testGateways } = loopEntries( + forward, + container, + ); + const backEdgeList = container.sequenceFlows.filter((f) => + latches.get(f.targetRef)?.has(f.sourceRef), + ); + + // 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); const dominates = makeDominanceQuery(idom, VIRTUAL_ENTRY); const postDominates = makeDominanceQuery(ipdom, VIRTUAL_EXIT); @@ -60,12 +91,23 @@ export function analyzeCfg(container: FlowContainer): CfgAnalysis { }, dominates, postDominates, + loopDominates, + loopImmediateDominator(node) { + return loopIdom.get(node); + }, 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; + }, + 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) ?? [])]; @@ -77,9 +119,9 @@ export function analyzeCfg(container: FlowContainer): CfgAnalysis { } interface Graph { - /** Insertion-ordered and de-duplicated; dominance is set-based. */ succ: Map; pred: Map; + reentries: Set; } function buildGraph(container: FlowContainer): Graph { @@ -111,21 +153,20 @@ 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. + // 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', ); + 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' || @@ -137,21 +178,88 @@ 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); } - return { succ, pred }; + return { succ, pred, reentries }; +} + +/** + * `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 = 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) => + 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, + }; +} + +/** + * 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]])); + 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 }; +} + +function reachesExit(pred: Map): Set { + 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, @@ -162,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); @@ -217,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(); @@ -245,23 +351,365 @@ function reversePostorder(root: string, succ: Map): string[] { return postorder.reverse(); } -/** `a` dominates `b` when `a` sits on `b`'s idom chain. Reflexive and total. */ +interface EnteredLoop { + head: string; + cycle: Set; + /** Cycle nodes other than the head and the gateways it is entered at. */ + past: Set; +} + +/** + * 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, + 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(); + 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: 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( + (f) => + f.sourceRef === p && + f.targetRef === n && + f.conditionExpression === undefined, + ), + ); + // 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)) + .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 { + // 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, + ); + // 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 && + [...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; + // 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); + 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 (`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, + conditioned: SequenceFlow[], + succ: Map, + hasNaturalEntry: (n: string) => boolean, + entered: (n: string) => boolean, +): (n: string) => number { + 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), + ); + 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]; + }), + ); + // 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))]), + ); + 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; +} + +/** Strongly connected components holding a cycle (iterative Tarjan). */ +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; +} + +/** + * Dominance as interval containment of pre-order entry/exit numbers on the + * idom tree. Nodes absent from `idom` answer false. + */ 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)!; }; } + +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..6f73ca5 100644 --- a/packages/transform/src/errors.ts +++ b/packages/transform/src/errors.ts @@ -1,27 +1,22 @@ /** - * Refusals raised by `xmlToIr`. - * - * 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. + * Refusals raised by `xmlToIr` (content the IR cannot express; lossless drops + * warn instead), plus {@link LayoutError} from `irToXml`. */ import { - CATCH_TRIGGERS, + 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 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()`. + * 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) { @@ -31,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; @@ -48,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; @@ -76,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; @@ -106,14 +108,15 @@ export class UnsupportedFormFieldConstraintError extends UnsupportedConstructErr } } -/** - * 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 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.'; + export class UnsupportedElementError extends UnsupportedConstructError { - /** Fully-qualified BPMN type name, e.g. `bpmn:ParallelGateway`. */ declare readonly qname: string; declare readonly elementId?: string; @@ -121,19 +124,12 @@ 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 }, ); } } -/** 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; @@ -151,26 +147,16 @@ export class UnsupportedCallActivityError extends UnsupportedConstructError { } } -/** - * 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. - */ +type EventPosition = 'start' | 'end' | 'intermediate throw' | 'boundary'; + export class UnsupportedEventDefinitionError extends UnsupportedConstructError { declare readonly elementId: string; - declare readonly eventKind: - 'start' | 'end' | 'intermediate throw' | 'intermediate catch' | 'boundary'; - /** Moddle `$type`, e.g. `bpmn:TerminateEventDefinition`. */ + declare readonly eventKind: EventPosition; declare readonly definitionType: string; constructor( elementId: string, - eventKind: - | 'start' - | 'end' - | 'intermediate throw' - | 'intermediate catch' - | 'boundary', + eventKind: EventPosition, definitionType: string, ) { super( @@ -182,34 +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; 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. - */ export class UnsupportedEventFeatureError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly detail: string; @@ -220,19 +196,14 @@ 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 }, ); } } -/** - * 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; @@ -245,23 +216,30 @@ export class UnsupportedLoopCharacteristicsError extends UnsupportedConstructErr } } -/** Pools or message flows: the IR models a single standalone process. */ +/** + * 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; 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 }, ); } } -/** - * Operaton extension content the IR's discriminated unions cannot represent. - * `detail` names the shape; the message states the rule it broke. - */ +export class UnsupportedDocumentError extends UnsupportedConstructError { + declare readonly detail: string; + + constructor(detail: string) { + super(`The document cannot be imported: ${detail}.`, { detail }); + } +} + export class UnsupportedExtensionFormError extends UnsupportedConstructError { declare readonly elementId: string; declare readonly detail: string; @@ -282,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; @@ -303,12 +276,8 @@ export class UnsupportedFormReferenceError extends UnsupportedConstructError { } /** - * A condition Operaton evaluates outside UEL: 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. + * `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; @@ -317,15 +286,31 @@ 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 }, + ); + } +} + +/** `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; + + 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 }, ); } } -/** 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; @@ -341,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; @@ -358,16 +342,12 @@ export class UnsupportedAssignmentError extends UnsupportedConstructError { } } -/** `bpmn:TimerEventDefinition` -> `timer`. */ function friendlyEventDefinition(definitionType: string): string { const local = definitionType.replace(/^.*:/, ''); 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 ( @@ -377,18 +357,30 @@ function supportedKindsMessage( ); 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 'intermediate catch': - return `An await supports ${formatPlainWordList(CATCH_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` 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; + + 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/host-messages.ts b/packages/transform/src/host-messages.ts new file mode 100644 index 0000000..6cd09c1 --- /dev/null +++ b/packages/transform/src/host-messages.ts @@ -0,0 +1,46 @@ +/** + * User-facing messages for unconvertible files, shared by CLI and VS Code, so + * they carry no host wording. + */ + +// `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'; + +export const EMPTY_INPUT_MESSAGE = 'the file is empty'; + +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 ''; + // 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]; + 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` 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 = ( + firstLine.length > 200 ? firstLine.slice(0, 200) + '...' : firstLine + ).replace(/\p{C}/gu, ''); + return capped === WRONG_ROOT_MESSAGE ? capped + describeRoot(xml) : capped; +} + +export function xmlInputProblem(xml: string): string | undefined { + // `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())}")`; + } + return undefined; +} 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 980220e..69729c6 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, @@ -47,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, @@ -69,10 +65,18 @@ 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, 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, @@ -83,15 +87,25 @@ export { UnsupportedEventFeatureError, UnsupportedLoopCharacteristicsError, UnsupportedCollaborationError, + UnsupportedDocumentError, UnsupportedCallActivityError, UnsupportedExtensionFormError, UnsupportedFormReferenceError, UnsupportedConditionExpressionError, + UnsupportedGatewayShapeError, UnsupportedFormFieldConstraintError, UnsupportedErrorMappingError, UnsupportedAssignmentError, + LayoutError, + SERVICE_TASK_FORM_ATTRIBUTES, } from './errors.js'; 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 ecd8b67..8f1352c 100644 --- a/packages/transform/src/ir-to-dsl.ts +++ b/packages/transform/src/ir-to-dsl.ts @@ -1,29 +1,36 @@ /** - * 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. - * - * 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. + * 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. */ import { CALL_MAPPER_KEY_BY_KIND, DATE_PATTERN_KEY, END_TRIGGERS, + ENGINE_KEYS, + type EngineKey, + ERROR_MAPPING_HEAD, + ERROR_MAPPING_WHEN, EXPRESSION_OPEN, + EXTERNAL_BINDING_KEY, + EXTERNAL_TASK_EL_NAME, + FIELD_DIRECTION, + ID_TEXT, + INPUT_DIRECTION, isReservedName, joinSettingKey, + LOOP_VARIABLES, + OUTPUT_DIRECTION, + PROCESS_ENGINE_HEADER_KEYS, + STARTABLE_KEY, + PROPERTY_DIRECTION, runSettingKey, + TASK_PRIORITY_KEY, TIMER_PARTICLE_BY_KIND, TYPE_BINDING_KEY, + USER_TASK_VERBATIM_KEYS, } from '@bpmn-script/language'; import type { BpmnProcess, @@ -54,39 +61,51 @@ import type { } from './ir/types.js'; import { carriesFields, - eachElement, eventIdentities, gatewayDefaultFlowId, isGateway, repeats, + splitTimerJobSettings, } from './ir/types.js'; import { + boundaryEventIdBase, CATCH_EVENT_PREFIX, claimDeclarationName, - END_EVENT_PREFIX, - START_EVENT_PREFIX, + 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 { analyzeCfg, type CfgAnalysis, closure } from './cfg-analysis.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 = ' '; /** - * 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: indenting inside the fence + * would rewrite the code. */ type Lines = string[]; -/** Built by {@link codeDeclarations}: the name a raised code is written under, per kind. */ -interface CodeNames { +interface PrintNames { error: Map; escalation: Map; + printed: ReadonlyMap; } +function nameOf(names: PrintNames, id: string): string { + return names.printed.get(id) ?? id; +} + +export type PrintContainer = Pick; + export type PrintWarningCategory = | 'label' | 'documentation' @@ -95,14 +114,12 @@ export type PrintWarningCategory = | 'degradedSplit' | 'droppedCondition' | 'refusedStatement' + | 'renamedId' | '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; @@ -115,38 +132,36 @@ 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 }; } /** - * 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 - * 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. + * Gateways only: `xmlToIr` reports every other elided label, so a wider rule + * would report it twice. */ 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; @@ -178,30 +193,24 @@ 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 synthesized end printed 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.flowElements, suppressed) && - isReservedName(el.id) + !isElidedOnPrint(el, container, startTriggerSuppressed) && + 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); } } } @@ -218,43 +227,109 @@ function reservedNameWarning(elementId: string): PrintWarning { } /** - * 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. + * 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, + 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; +} + +interface Branch { + route: SequenceFlow; + join: string | undefined; + ownEntry?: string; + outer?: Branch; +} + +/** + * 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(); - /** The gateways whose job settings a statement head has printed. */ private readonly settingsTaken = new Set(); + /** + * 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 = ''; + /** + * 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 }[] = []; private readonly deferredEnds: { lines: string[]; index: number; stmt: Lines; id: string; }[] = []; + /** + * The minted plain end the print leaves out, unless a chain hoisted behind it + * forces it to print. + */ + private readonly elidedTail: string | undefined; + private readonly linkedCatch: ReadonlyMap; + private readonly ownMerges: ReadonlyMap; constructor( 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, - /** - * An event sub-process's start prints its trigger in the `on` header, so - * the start statement inside the body prints without one. - */ + private readonly names: PrintNames, + /** An event sub-process prints its start trigger in the `on` header. */ 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. + // 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}'.`, @@ -263,55 +338,256 @@ 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); } + 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(); + } + + /** + * 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( + 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 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;) { + 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 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 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, + 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 it suffices; the rest print inside their branches. + !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; + } + + /** + * 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. 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. + // 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' && - 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); } } - for (const el of this.container.flowElements) { - if (el.kind === 'startEvent' && !this.emittedNodes.has(el.id)) { - this.emitFrom(el.id, undefined, lines, 0); + // A chain printed after the one reaching the elided end pushes that end + // 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, + ): 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 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( + (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); + // 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.`, + ); } } - // 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) { + // 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; + base: string; + rank: number; + block: string[]; + }[] = []; + 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); + 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 }); } + }); + // 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); + 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)); } - // 3. Orphaned fragments. + // 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)) { @@ -319,8 +595,8 @@ class Emitter { } } - // 4. 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 becomes a jump; `consume` reports a dropped + // condition. for (const f of this.container.sequenceFlows) { if (!this.consumedFlows.has(f.id)) { this.consume(f); @@ -328,12 +604,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 - // 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. + // 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; @@ -342,18 +615,19 @@ 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) { + // 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); 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 - // 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) && @@ -364,12 +638,130 @@ class Emitter { } } + // 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 ( + inside !== undefined && + inside !== '' && + !`${branchPath}/`.startsWith(`${inside}/`) + ) { + this.warnings.push(crossBranchJumpWarning(target)); + } + } + return lines; } + private emitStartGroup( + start: Extract, + lines: string[], + ): void { + const route = this.soleRoute(start.id); + if (route === undefined) { + this.emitFrom(start.id, undefined, lines, 0); + return; + } + this.emitStartsInto(route.targetRef, lines); + this.emitFrom(route.targetRef, undefined, lines, 0); + } + /** - * 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. + * 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)) { + this.emittedNodes.add(el.id); + this.branchOf.set(el.id, this.branchPath); + lines.push( + ...renderStartEvent(el, this.startTriggerSuppressed, this.names), + ); + this.consume(this.soleRoute(el.id)!); + } + } + + 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, + ); + } + + /** + * 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 { + return closure( + [from], + (n) => (hops.has(n) ? this.successors(n) : this.modelSuccessors(n)), + to, + ).has(to); + } + + /** + * 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 entries = this.container.flowElements + .filter( + (el) => + !isBoundary(el) && + !isHandler(el) && + (el.kind === 'startEvent' || !entered.has(el.id)), + ) + .map((el) => el.id); + return closure(entries, (n) => this.successors(n)); + } + + /** Includes the hop from a link throw into the catch printed behind it. */ + private successors(id: string): string[] { + 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); + } + + /** 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; + } + + private soleRoute(id: string): SequenceFlow | undefined { + const outs = this.routesOut(id); + return outs.length === 1 ? outs[0] : undefined; + } + + /** + * 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); @@ -381,14 +773,9 @@ 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 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 []; @@ -398,7 +785,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 []; } @@ -406,26 +793,24 @@ 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)); } 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[], ): 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); @@ -433,11 +818,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, @@ -445,7 +827,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); @@ -453,10 +835,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, @@ -464,25 +842,210 @@ 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; } let current = node; - // Bounded by the node count, against a malformed IR cycle. + const hops = new Set(); let guard = this.byId.size + 1; while (current !== undefined && current !== stop && guard-- > 0) { - if (this.emittedNodes.has(current)) { + // 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 && + 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; } } - /** Returns the next id in the fall-through chain, or {@link STOP}. */ + /** + * 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!; + 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` 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, + 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 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); + 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 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) { + 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; + } + + /** Whether `n` lies on a cycle through `entry` inside `split`'s region. */ + 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); + } + + /** + * 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; + 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)); + } + + 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.routesOut(split).find((f) => f.targetRef === target); + } + private emitNode( id: string, stop: string | undefined, @@ -491,14 +1054,22 @@ 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. + // 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; - // 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. + // 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 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); @@ -506,43 +1077,36 @@ 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); } - // 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)); - 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. + // The import refuses a flow into an event sub-process: malformed IR. 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); } - // 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 malformed boundary with inbound flow prints here rather than being + // lost. if (isBoundary(el)) { this.emitBoundaryHandler(el, lines, depth); return STOP; @@ -554,11 +1118,10 @@ 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 - // 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)); @@ -568,18 +1131,17 @@ 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); } /** - * 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. - * - * 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. + * 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, @@ -587,18 +1149,23 @@ class Emitter { lines: string[], 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); + 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( + (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); } - /** - * 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: a step whose own routes split has no gateway node. */ private emitChoice( splitId: string, stop: string | undefined, @@ -616,10 +1183,8 @@ 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. + * Routes are passed in so loop emitters can hand over the ones they did not + * spend. */ private emitRoutes( splitId: string, @@ -628,43 +1193,52 @@ 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. - // 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)); - } + 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); + 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; } - // 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); + 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, @@ -674,23 +1248,35 @@ 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. + * 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, + route: SequenceFlow, + stop: string | undefined, + ): boolean { + return ( + route.conditionExpression !== undefined && + this.routesOut(splitId).some( + (o) => + o !== route && + o.targetRef === stop && + o.conditionExpression === undefined && + o.id === this.splitFallbackFlowId(splitId), + ) + ); + } + + /** + * 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[], @@ -702,80 +1288,290 @@ 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. + // 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, + ]); - // 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 ? `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` as none, so it is never printed. + const elseBody = + elseFlow === undefined + ? [] + : this.emitIfBranch(elseFlow, join, splitId, depth); + if (elseBody.length > 0) lines.push('} else {', ...elseBody); + lines.push('}'); + } + + /** + * The fallback counts as unconditioned even with a condition, or nothing + * would catch the no-match case. + */ + 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 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. + * Each route becomes a jump in its own branch; the marker records the lost + * split kind. */ 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 loop prints ahead of its body, so entering the body before its head + * prints needs a `goto`. */ - private cleanJoin(splitId: string, outs: SequenceFlow[]): string | undefined { + private entersUnprintedLoop(from: string, to: string): boolean { + return this.cfg + .headsEnteredPast(from, to) + .some((head) => !this.emittedNodes.has(head)); + } + + /** + * 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, + outs: SequenceFlow[], + stop: string | undefined, + ): string | undefined { const join = this.cfg.immediatePostDominator(splitId); if (join === undefined || !this.byId.has(join)) return undefined; - if (!this.cfg.postDominates(join, splitId)) return undefined; + if (stop !== undefined && !this.cfg.postDominates(stop, join)) { + 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; } /** - * 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. + * 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, + outs: readonly SequenceFlow[], + stop: string | undefined, + ): string | undefined { + const exit = stop ?? this.elidedTail; + const routes = outs + .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) => + routes.some((r) => r.reach !== reach && r.reach.has(n)), + ); + 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; + // 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), + ); + // Unlike ownJoin, every live route must pass the link-hop test. + const merge = shared.find( + (n) => + this.isMerge(n) && + live.every((r) => this.crossesHopInto(r.f.targetRef, splitId, n)), + ); + if (merge !== undefined) return merge; + if (exit !== undefined && shared.includes(exit)) { + // 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: take the farthest step. + const [only] = live; + const weighed = + live.length === 1 && + only!.f.conditionExpression !== undefined && + only!.f.id !== this.splitFallbackFlowId(splitId); + // 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), + ); + const holds = (n: string): boolean => + live.every((r) => !leavesBefore(r.f.targetRef, n)); + return weighed ? shared.findLast(holds) : shared.find(holds); + } + + /** + * Breadth order. `stop` is included but not crossed; the split is excluded. + */ + private reachable( + from: string, + splitId: string | undefined, + stop: string | undefined, + inRegion: (n: string) => boolean = () => true, + ): Set { + return closure(from === splitId ? [] : [from], (n) => + n === stop || !inRegion(n) + ? [] + : this.successors(n).filter((m) => m !== splitId), + ); + } + + /** + * 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 { + return closure([from], (n) => { + if (n === splitId) return []; + const nested = this.ownMerges.get(n); + return nested !== undefined ? [nested] : this.modelSuccessors(n); + }); + } + + private hasPrintableContinuation(n: string): boolean { + return [...this.reachable(n, undefined, undefined)].some( + (m) => !this.isGatewayId(m) && m !== this.elidedTail, + ); + } + + private crossesHopInto( + from: string, + splitId: string | undefined, + m: string, + ): boolean { + return ( + !this.hasPrintableContinuation(m) || + this.reachableOverFlow(from, splitId).has(m) + ); + } + + /** + * Over unconsumed routes only: a consumed edge already has its place in the + * print. + */ + private reachesUnconsumed( + from: string, + wall: string, + target: string, + ): boolean { + return closure( + from === wall ? [] : [from], + (n) => + this.unconsumedOut(n) + .map((f) => f.targetRef) + .filter((m) => m !== wall), + target, + ).has(target); + } + + /** + * 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, + 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: one branch terminates and the sole unconditioned route + * continues after the `if`. */ private guardClauseContinuation( unconditioned: SequenceFlow[], @@ -784,95 +1580,313 @@ class Emitter { } /** - * Walk or `goto` is decided per branch, since branches mix: one can flow back - * to the join while another jumps away. + * 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, + 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; + } + + /** + * 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 + * {@link leavesInnermostBranch} stops. */ 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); } 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) ) { + const outerBranch = this.branch; + this.branch = { route, join, ownEntry: entry, outer: outerBranch }; this.emitFrom(entry, join, body, depth + 1); - } else { - // The target escapes the [split, join) region: preserve the edge as a goto. + 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); + } + + /** + * 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); + 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]!)) + ); + } + + 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.routesOut(n); + if ( + el === undefined || + !isGateway(el) || + outs.length !== 1 || + jobSettingItems(el).length > 0 + ) { + break; + } + n = outs[0]!.targetRef; + } + return chain; } /** - * Whether the entry sits inside `[split, join)` and can be walked inline. - * Three 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. + * 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, 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); + // 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); + } + 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 ( - el === undefined || - !this.cfg.dominates(splitId, entry) || - this.cfg.dominates(join, entry) + joinEl !== undefined && + isGateway(joinEl) && + this.reachable(entry, splitId, join).has(join) ) { - return false; + return true; } - if (isSynthesizedTerminalId(entry, el.kind)) return true; - return el.kind === 'endEvent' && this.cfg.incoming(entry).length === 1; + if (isSynthesizedTerminalId(entry, el.kind, this.container)) return true; + if (el.kind === 'endEvent' && this.cfg.incoming(entry).length === 1) { + return true; + } + const after = this.reachable(join, splitId, undefined); + if (this.elidedTail === undefined) { + if (this.doWhileBackEdges(entry).length > 0) return true; + 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` 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 + .backEdges() + .some( + (f) => + this.isDoWhileTest(f) && this.cfg.loopDominates(f.targetRef, splitId), + ); } - /** With no `join` the body runs to its own end, which is how a race with no merge prints. */ + /** + * A node in a handler body is dominated by its boundary event as others are + * by the start. + */ + 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; + } + + /** + * 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, + splitId: string, + join: string, + ): boolean { + const backEdgeIds = new Set(this.cfg.backEdges().map((f) => f.id)); + 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) => + n === join || + !isSynthesizedTerminalId(n, this.byId.get(n)!.kind, this.container), + ); + } + + /** + * `branch` is the fork/race route; a loop body passes none and stays in its + * enclosing branch. + */ private emitBranch( entry: string, join: string | undefined, lines: string[], depth: number, + branch?: SequenceFlow, ): void { + const outer = this.branchPath; + 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. */ 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. + // 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 && f.conditionExpression === undefined, + (f) => + f.targetRef === loop.id && + f.sourceRef !== loop.id && + 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); @@ -881,7 +1895,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('}'); @@ -889,12 +1902,22 @@ 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. + * 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 + * unconsumed flows; a `continue` runs on to the real test. */ private tryDoWhileEntry( node: string, @@ -902,40 +1925,100 @@ class Emitter { lines: string[], depth: number, ): string | typeof STOP | undefined { - const backEdge = this.unconsumed(this.cfg.backEdges()).find((f) => { - if (f.targetRef !== 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.routesOut(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); - // 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); } + private doWhileBackEdges(node: string): SequenceFlow[] { + const candidates = this.unconsumed(this.cfg.backEdges()).filter( + (f) => f.targetRef === node && this.isDoWhileTest(f), + ); + // 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); + } + /** - * 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 condition tells a do-while from a `while`'s join-to-head edge. Operaton + * 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; + if (f.conditionExpression === undefined) return false; + // 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.id !== fallbackId, + ); + return ( + head?.kind === 'exclusiveGateway' && + firstTest === f && + this.enclosesBody(f.targetRef, head.id) && + // pairMerges calls this before ownMerges exists, so no reachableOverFlow. + this.flowsOnTo(f.targetRef, head.id, new Set()) + ); + } + + /** + * 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); + 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); + }); + } + + /** + * 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); @@ -944,22 +2027,24 @@ 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. + * 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 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,152 +2052,142 @@ class Emitter { } const join = - this.cleanForkJoin(fork.id, outs, fork.kind) ?? - this.recoveredForkJoin(fork.id, outs, fork.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) { - this.warnings.push(degradedSplitWarning(fork.id)); - this.emitJumps(fork.id, outs, lines, depth); + if (join === undefined && !this.routesEndApart(splitId, outs, stop)) { + 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 - // 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(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); 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 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, + 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), + ), + ); } /** - * `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 fork weighs no condition on its fallback, so it heads as `else` + * regardless. */ - 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)}) `; } - /** - * 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. - */ private isImplicitFallback( - fork: ForkGateway, + fork: Fork, flow: SequenceFlow, - join: string, + join: string | undefined, ): boolean { return ( fork.kind === 'inclusiveGateway' && - flow.id === fork.defaultFlowId && + flow.id === fork.fallbackId && flow.targetRef === join ); } - /** - * 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. - * - * 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. - */ + /** 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. - 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) ?? []; + const outs = this.routesOut(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. + * A step's `default` is honoured too, by Operaton's `handleNoTransitions`. */ 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; } - /** - * 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. - */ 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 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. + * A fallback beside an unconditioned branch is unreachable, and the validator + * refuses its `else`. */ - 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,57 +2197,33 @@ 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. + * The engine refuses to deploy a choice whose fallback has a condition; a + * fork or step ignores it. */ - 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), + ); } - /** - * 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. - */ - private warnUnweighedBranchCondition( - fork: ForkGateway, - outs: SequenceFlow[], - ): void { + /** 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; 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); - if (join === undefined) return undefined; - return this.byId.get(join)?.kind === joinKind ? join : undefined; - } - /** - * 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 - * `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. + * 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, @@ -1182,17 +2233,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.routesOut(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); @@ -1206,12 +2262,14 @@ class Emitter { return undefined; } + private mergesForward(id: string): boolean { + const ins = this.cfg.incoming(id); + return ins.length > 1 && !ins.some((p) => this.cfg.dominates(id, p)); + } + /** - * `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. + * Keyed on the waits alone, so it holds without a merge; any non-wait branch + * degrades. */ private emitRaceGateway( race: Extract, @@ -1232,19 +2290,17 @@ 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. if (outs.some((f) => f.conditionExpression !== undefined)) { this.warnings.push(raceConditionWarning(race.id)); } // An XOR merge: exactly one branch of a race ever runs. const join = - this.cleanForkJoin(race.id, outs, 'exclusiveGateway') ?? + this.ownJoin(race.id, outs, stop, 'exclusiveGateway') ?? this.recoveredForkJoin(race.id, outs, 'exclusiveGateway'); const settings = [ @@ -1252,17 +2308,15 @@ 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[] = []; 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. this.consume(body); - this.emitBranch(body.targetRef, join, branchLines, depth); + this.emitBranch(body.targetRef, join, branchLines, depth, outs[i]); } - const trigger = renderTrigger(el.eventDefinition, this.codeNames); + const trigger = renderTrigger(el.eventDefinition, this.names); const head = bodyHeader( trigger.head, [...trigger.items, ...jobSettingItems(el)], @@ -1271,18 +2325,12 @@ 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); } - /** - * 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. - */ private raceWait(target: string): RaceWait | undefined { const el = this.byId.get(target); if (el?.kind !== 'intermediateCatchEvent') return undefined; @@ -1293,11 +2341,8 @@ 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. + * 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); @@ -1306,7 +2351,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, @@ -1317,37 +2361,21 @@ 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: 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. - * - * 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. + * Beside an unconditioned fallback a dropped condition diverts the run rather + * than failing it. */ private droppedConditionWarning( flow: SequenceFlow, ): 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); 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 @@ -1358,11 +2386,8 @@ 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. + * A printed pass-through running into `stop` is fallen through, not jumped: + * it is still in its block. */ private continueAt( target: string, @@ -1371,6 +2396,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; } @@ -1378,14 +2404,14 @@ 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()); 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)); @@ -1393,16 +2419,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, 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. + * 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, @@ -1411,13 +2430,11 @@ 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); - 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]; @@ -1426,103 +2443,90 @@ class Emitter { return this.forwardToRealTarget(forward.targetRef, seen); } - /** `undefined` when the element has no statement form. */ + /** + * A synthesized id is omitted: the compiler re-derives it from the + * statement's position. + */ + private terminalNameSuffix( + el: Extract< + FlowElement, + { kind: 'endEvent' | 'intermediateThrowEvent' | 'intermediateCatchEvent' } + >, + ): string { + return isSynthesizedTerminalId(el.id, el.kind, this.container) + ? '' + : ` ${nameOf(this.names, el.id)}`; + } + 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': { - // 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.codeNames); - return bracketed( - `emit ${trigger.head}${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.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); - // These print as a line group in `emitNode`/`emitBoundaryHandler`, and are - // listed so the type checker still catches a new kind. + return renderCallActivity(el, this.names); case 'scriptTask': - return undefined; + return renderScriptTask(el, this.names); + // Printed by `emitNode`; listed for exhaustiveness. 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; @@ -1536,22 +2540,29 @@ 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'; + +interface Fork { + id: string; + kind: ForkKind; + fallbackId: string | undefined; +} + +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 { el: Extract; body?: SequenceFlow; } /** - * 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. + * 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'; @@ -1560,6 +2571,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, @@ -1571,13 +2586,6 @@ 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. - */ function degradedSplitWarning(splitId: string): PrintWarning { return { elementId: splitId, @@ -1585,44 +2593,68 @@ 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 { +function emptySplitWarning(kind: Gateway['kind'], id: string): PrintWarning { + const model = { + // 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.', + // 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.', + // 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.', + // 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.', + }[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. - */ -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,30 +2671,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 - * 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, @@ -1678,13 +2686,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, @@ -1716,11 +2717,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, @@ -1733,13 +2729,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, @@ -1779,39 +2768,219 @@ 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 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. - */ -export const PROCESS_HEADER_SETTINGS = [ - ['versionTag', quoteLiteral], - ['historyTimeToLive', quote], - ['candidateStarterUsers', quote], - ['candidateStarterGroups', quote], -] as const; - -function buildProcessHeader(process: BpmnProcess): string { +/** 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))); + } + } + if (process.isStartableInTasklist !== undefined) { + settings.push( + setting(STARTABLE_KEY, String(process.isStartableInTasklist)), + ); } - 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. */ function isHandler( el: FlowElement, ): el is Extract { return el.kind === 'subProcess' && el.triggeredByEvent === true; } -/** A boundary event has outgoing but no incoming flow, so a dedicated pass prints 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. + * + * 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, + 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()); + 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, 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 first body step, so count forward edges + // only. + 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 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(); + for ( + let d = cfg.immediateDominator(throwId); + d !== undefined && byId.has(d); + d = cfg.immediateDominator(d) + ) { + dominators.add(d); + } + 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(); + // 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, + 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); + // 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 continues through that throw's catch. + 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), + ); +} + +/** + * Each throw -> catch hop drawn as a flow, which also stops the analysis + * entering at the catch. + */ +function withLinkHops( + container: FlowContainer, + linked: ReadonlyMap, +): FlowContainer { + return { + ...container, + sequenceFlows: [ + ...container.sequenceFlows, + ...[...linked].map(([sourceRef, targetRef]) => ({ + id: `${sourceRef}->${targetRef}`, + sourceRef, + targetRef, + })), + ], + }; +} + function isBoundary( el: FlowElement, ): el is Extract { @@ -1819,13 +2988,17 @@ function isBoundary( } /** - * 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. + * 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; + const suffix = id.startsWith(`${base}_`) ? id.slice(base.length + 1) : ''; + return /^[2-9]\d*$/.test(suffix) ? Number(suffix) : Infinity; +} + 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 { @@ -1833,55 +3006,38 @@ 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` 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. - * - * `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. + * 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, - 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; - // 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); - // Every other definition prints as a `throw`, which drops a synthesized name. + if (!isSynthesizedTerminalId(el.id, el.kind, container)) return false; + // 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); return !isEndCarried(el.eventDefinition); case 'intermediateThrowEvent': - // Spells the id through `terminalNameSuffix`, which drops a synthesized one. - return isSynthesizedTerminalId(el.id, el.kind); 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; case 'subProcess': - // An event sub-process prints as an `on` header, an ordinary one its id. return el.triggeredByEvent === true; case 'userTask': case 'serviceTask': @@ -1894,7 +3050,6 @@ export function isElidedOnPrint( case 'parallelGateway': case 'inclusiveGateway': case 'eventBasedGateway': - // Callers forward past a gateway rather than ask, so this arm is unreachable. return true; default: { const exhaustive: never = el; @@ -1905,29 +3060,30 @@ 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 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' + ? splitTimerJobSettings(el).continuation + : el; return ( - isSynthesizedTerminalId(el.id, el.kind) && !carriesPrintableContent(el) + isSynthesizedTerminalId(el.id, el.kind, container) && + !carriesPrintableContent(own) ); } /** - * 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. + * Read only through {@link isElidedOnPrint}, or a jump could name a statement + * never emitted. Labels are handled by the callers. */ function carriesPrintableContent( el: Extract, @@ -1935,7 +3091,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[] { @@ -1946,42 +3101,31 @@ 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)]; } -/** Fixed 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, ): 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])], + ); } -/** - * 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), @@ -1989,32 +3133,33 @@ 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) => [`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. */ +/** IR order is the engine's evaluation order. */ 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; } /** - * 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. + * 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) { @@ -2024,7 +3169,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(', ')} }`; } @@ -2040,8 +3185,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) => @@ -2055,7 +3200,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, @@ -2063,7 +3207,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' @@ -2074,10 +3218,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': @@ -2096,32 +3236,32 @@ 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. + * 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}\`\`\``; } /** - * 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. + * 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, 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 +3272,12 @@ 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. */ function renderThrow( - el: Extract, def: Exclude, + nameSuffix: string, settings: string[], members: Lines[], - names: CodeNames, + names: PrintNames, ): Lines { switch (def.kind) { case 'error': @@ -2162,7 +3287,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 +3301,13 @@ function renderThrow( } } -/** Engine attributes come off the sub-process: the start is elided on print. */ +/** + * An elided start's timer-job settings lift into the header, where the compiler + * puts them back. + */ function buildOnHeader( handler: Extract, - names: CodeNames, + names: PrintNames, ): Lines { const start = handler.flowElements.find( (e): e is Extract => @@ -2191,25 +3319,28 @@ function buildOnHeader( ); } const trigger = renderTrigger(start.eventDefinition, names); + const lifted = isElidedOnPrint(start, handler, true) + ? jobSettingItems(splitTimerJobSettings(start).timer) + : []; 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. */ 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), @@ -2219,22 +3350,9 @@ 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. - */ function renderTrigger( def: EventDefinition, - names: CodeNames, + names: PrintNames, ): { head: string; items: string[]; @@ -2286,16 +3404,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. Absent - * for a catch-all, as {@link payloadItem} is. - */ function codeItem( names: ReadonlyMap, code: string | undefined, @@ -2303,15 +3415,11 @@ 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[]; } { - const particle = TIMER_PARTICLE[def.timerKind]; + const particle = TIMER_PARTICLE_BY_KIND[def.timerKind]; const time = quote(def.expression); return { head: 'timer', @@ -2319,7 +3427,6 @@ function renderTimerTrigger(def: Extract): { }; } -/** `code: x, message: y`. Only an error carries a message binding. */ function eventBindingSettings( def: Extract, ): string[] { @@ -2333,10 +3440,6 @@ 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. - */ function alongsideFlag(nonInterrupting: boolean): string[] { return nonInterrupting ? ['alongside'] : []; } @@ -2344,7 +3447,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 +3455,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), @@ -2365,12 +3468,20 @@ 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) { + 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( @@ -2381,28 +3492,14 @@ 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. */ -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. - */ +/** Braces even with no fields: `form` alone does not parse. */ function renderFormBlock(formFields: FormField[]): Lines { return [ 'form {', @@ -2414,7 +3511,6 @@ function renderFormBlock(formFields: FormField[]): Lines { ]; } -/** `: "`; - /** 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 +169,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', @@ -206,7 +179,7 @@ describe('every new type parses and re-serializes with nested values intact', () review: { assignee: 'alice', candidateUsers: 'bob,carol', - candidateGroups: 'reviewers', + candidateGroups: 'approvers', dueDate: 'P1D', followUpDate: 'P2D', priority: '50', @@ -242,27 +215,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 +227,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 +240,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,55 +252,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:potentialStarter'); - }); -}); - -describe('the form field extras and the external task extras parse and re-serialize', () => { const FORM_FIELD_FIXTURE = `${XML_HEADER} @@ -422,10 +275,8 @@ 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[]; @@ -462,9 +313,8 @@ 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; @@ -482,7 +332,79 @@ 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, + ], + [ + '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'], + ], + [ + // 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) => { + 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, @@ -513,31 +435,131 @@ 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); - }, - ); + [ + '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('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); + + expect(extensionValues(elementsOf(process)[0]).map((v) => v.$type)).toEqual( + ['operaton:FailedJobRetryTimeCycle'], + ); + 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); + + 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)', + ]); + + // 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( + '' + + 'user(a), group(g)' + + '', + ); + }); }); describe('AsyncCapable defaults are omitted on write', () => { @@ -565,21 +587,23 @@ describe('AsyncCapable defaults are omitted on write', () => { }); }); -describe('AsyncCapable extends the multi-instance element, so a repetition can carry per-run job settings', () => { - const fixture = `${XML_HEADER} +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} - - - - R3/PT10M - - 3 - - + + + R/PT1H + + + + + PT1H + + `; - 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, @@ -587,58 +611,18 @@ 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 [start, wait] = elementsOf(process); + const definitionOf = (el: ModdleElement): ModdleElement => + (el.get('eventDefinitions') as ModdleElement[])[0]; + expect([ + definitionOf(start).get('exclusive'), + definitionOf(wait).get('exclusive'), + ]).toEqual([false, true]); const { xml } = await moddle.toXML(definitions, { format: false }); expect(xml).toContain( - '', + '', ); - }); -}); - -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); - - 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'); + expect(xml).toContain('PT1H'); }); }); diff --git a/packages/transform/test/synthesize-ids.test.ts b/packages/transform/test/synthesize-ids.test.ts index 60663c8..ca821ab 100644 --- a/packages/transform/test/synthesize-ids.test.ts +++ b/packages/transform/test/synthesize-ids.test.ts @@ -1,25 +1,11 @@ -/** - * 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 - * EndEvent_ Implicit end event; duplicates get _2, _3, ... - * 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 (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'; import { + isMintedEndId, + isMintedStartId, + isWritableName, makeGatewaySplitId, makeGatewayJoinId, makeGatewayForkId, @@ -33,90 +19,43 @@ import { makeEventSubProcessId, makeBoundaryEventId, makeIntermediateCatchEventId, + mintPrintableName, 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 -// --------------------------------------------------------------------------- - -describe('structural stability', () => { - it.each([ +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())], [ 'EndEvent_invoice-approval', () => makeEndEventId('invoice-approval', new Set()), + 'invoice-approval', ], - [ - 'EndEvent_invoice-approval_2', - () => - makeEndEventId( - 'invoice-approval', - new Set(['EndEvent_invoice-approval']), - ), - ], - [ - 'EndEvent_invoice-approval_3', - () => - makeEndEventId( - 'invoice-approval', - new Set(['EndEvent_invoice-approval', 'EndEvent_invoice-approval_2']), - ), - ], - ['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'), @@ -125,10 +64,6 @@ describe('structural stability', () => { 'Boundary_Pack_error', () => makeBoundaryEventId('Pack', 'error', new Set()), ], - [ - 'Boundary_Review_timer', - () => makeBoundaryEventId('Review', 'timer', new Set()), - ], [ 'Boundary_Pack_timer_2', () => @@ -143,99 +78,100 @@ describe('structural stability', () => { new Set(['Boundary_Pack_timer', 'Boundary_Pack_timer_2']), ), ], - ] as const)('%s', (expected, make) => { + // Minted off a coordinate no container name reaches, so reserved by prefix. + [ + '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); }); - 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('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('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); }); }); -// --------------------------------------------------------------------------- -// Collision resolver -// --------------------------------------------------------------------------- +describe('mintPrintableName', () => { + 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, + ]); + }); +}); 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 2a02928..c50e85d 100644 --- a/packages/transform/test/xml-to-ir.test.ts +++ b/packages/transform/test/xml-to-ir.test.ts @@ -1,31 +1,25 @@ -/** - * 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. - */ - -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 { 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 { 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,45 +28,35 @@ import { UnsupportedExtensionFormError, UnsupportedFormFieldConstraintError, UnsupportedFormReferenceError, + UnsupportedGatewayShapeError, UnsupportedLoopCharacteristicsError, + SUPPORTED_KINDS_MESSAGE, UnsupportedServiceTaskFormError, } 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, @@ -82,8 +66,8 @@ import { import type { XmlTag } from './helpers/bpmn-doc.js'; import { bpmnDefs, + TIME_TO_LIVE, bpmnDoc, - camundaDefs, camundaDoc, dualDefs, dualDoc, @@ -94,7 +78,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, validate } from './helpers/parse.js'; const here = dirname(fileURLToPath(import.meta.url)); const HANDWRITTEN_XML = readFileSync( @@ -102,24 +87,27 @@ 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. -let parse: ReturnType>; +const expectParses = async (source: string): Promise => { + const doc = await parse(source, { validation: true }); + expect(doc.parseResult.parserErrors).toEqual([]); + expect(doc.diagnostics ?? []).toEqual([]); +}; -beforeAll(() => { - const services = createBpmnScriptServices(EmptyFileSystem); - parse = parseHelper(services.BpmnScript); -}); +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'); -/** 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, @@ -130,25 +118,23 @@ const warning = ( message: expect.stringMatching(message), }); -/** 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. - */ +const unread = ( + subject: string, + id: string, + tag: string, + engine?: string, +): ImportWarning => ({ + elementId: id, + category: 'extensionAttribute', + message: unimportedSettingMessage(subject, id, tag, engine), +}); + +const CAMUNDA_ALIAS_WARNING = { + elementId: 'p', + category: 'rewritten', + message: CAMUNDA_ALIAS_MESSAGE, +}; + const expectOneWarning = ( warnings: ImportWarning[], expected: { @@ -171,431 +157,741 @@ 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. - */ const rootedDoc = (roots: string, body: string, defs = bpmnDefs): string => - defs`${roots} + defs`${roots} ${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([]); + 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); + }, + ); + + 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]); }); }); -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); +describe('xmlToIr: service task binding forms', () => { + it('an external type without a topic refuses', async () => { + await expectRefusal( + xmlToIr(oneNodeDoc('serviceTask', { attrs: 'operaton:type="external"' })), + UnsupportedServiceTaskFormError, + ); }); - 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', + it('a connector refuses as a connector', async () => { + const e = await expectRefusal( + xmlToIr( + oneNodeDoc('serviceTask', { + children: extensionElements( + 'http-connector', + ), + }), + ), + UnsupportedServiceTaskFormError, + ); + expect(e.construct).toBe( + '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(node.assignee).toBe('alice'); + expect(e.subject).toBe('Service task'); }); }); -describe('xmlToIr: service task binding forms', () => { - const importServiceTask = (attrs: string, doc = operatonDoc) => - importOnly(oneNodeDoc('serviceTask', { attrs, doc }), 'serviceTask'); +describe('xmlToIr: script task support', () => { + const scriptTask = (attrs: string, children = '') => + oneNodeDoc('scriptTask', { id: 'ST', attrs, children, doc: bpmnDoc }); 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'), + 'a missing scriptFormat imports as juel, naming the engine default', + '', + 'x = 1;', + { format: 'juel' }, + 'rewritten', + "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' }, + '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.', + ], + [ + 'a self-closing bpmn:script imports as an empty body, since ScriptUtil.getScript deploys it', + '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.', + ], + [ + '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: '' }, + }, + ], + }, + '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.', ], ] 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([]); + '%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('the camunda: prefix is accepted for the expression form', async () => { - const { node } = await importServiceTask( - 'camunda:expression="${x}"', - camundaDoc, + 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.binding.kind).toBe('expression'); + 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'.", + ], + ]); }); - it('operaton:type="external" WITHOUT a topic stays refused', async () => { - await expect( - xmlToIr(oneNodeDoc('serviceTask', { attrs: 'operaton:type="external"' })), - ).rejects.toBeInstanceOf(UnsupportedServiceTaskFormError); + 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, + ); + }); +}); + +describe('xmlToIr: unsupported element', () => { + it('bpmn:AdHocSubProcess raises UnsupportedElementError', async () => { + const e = await expectRefusal( + xmlToIr( + oneNodeDoc('adHocSubProcess', { + children: '', + doc: bpmnDoc, + }), + ), + UnsupportedElementError, + ); + expect(e.qname).toBe('bpmn:AdHocSubProcess'); + expect(e.elementId).toBe('T'); + expect(e.message).toContain(SUPPORTED_KINDS_MESSAGE); }); +}); + +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'); + 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`/; 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.", + '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\.$/, + ), + ], + }, ], [ - '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.", + '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 }, ], [ - '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.", + 'two executable processes refuse, naming both', + bpmnDefs`${process(' isExecutable="true"', 'p1')}\n${process(' isExecutable="true"', 'p2', ' ')}`, + { + refusal: UnsupportedCollaborationError, + detail: /^two executable processes \('p1', 'p2'\)$/, + }, ], [ - '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.", + '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/, + }, ], - ] 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]); - }, - ); + [ + 'one process without isExecutable imports as executable and says so', + bpmnDefs`${process('')}`, + { + imports: 'p', + warnings: [ + 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', + ), + ], + }, + ], + [ + '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}`, + ), + }, + ], + [ + '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/, + }, + ], + [ + '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', '')}`, + { + 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); + }); - 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', + 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}`)}`, ); - 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.", + // 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:/), + ]), ]); }, ); - it('the refusal names every code attribute the type shadows', async () => { - const e = await expectRefusal( - xmlToIr( - oneNodeDoc('serviceTask', { - attrs: - 'operaton:class="com.example.Svc" operaton:delegateExpression="${d}" operaton:type="External"', - }), - ), - 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.', - ); - }); - - /** 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 TIMER = + 'PT1H'; + const LINEAR = + ' \n \n \n'; - const connectorChild = (prefix: 'operaton' | 'camunda'): string => - ` <${prefix}:connector><${prefix}:connectorId>http-connector` + - ``; + 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 connector alone refuses, no longer as "no execution discriminator"', - 'serviceTask', - '', - 'operaton', - 'Service task', + 'a lone start event', + ' ', + 'startEvent', + 'p', + DEPLOY('BpmnParse.parseStartFormHandlers'), ], [ - 'a connector beside operaton:class refuses the connector, not the class', - 'serviceTask', - 'operaton:class="com.example.Svc"', - 'operaton', - 'Service task', + 'a message start where the process has no plain start', + ` + `, + 'startEvent', + 'p', + DEPLOY('BpmnParse.parseStartFormHandlers'), ], [ - 'a connector beside operaton:type="external" and a topic refuses the connector', - 'serviceTask', - 'operaton:type="external" operaton:topic="shipping"', - 'operaton', - 'Service task', + 'a timer start', + ` ${TIMER}`, + 'startEvent', + 'p', + DEPLOY('BpmnParse.parseTimer', ` ('Attribute "id" is required!')`), ], [ - 'the deprecated camunda:connector prefix beside a class refuses alike', - 'serviceTask', - 'camunda:class="com.example.Svc"', - 'camunda', - 'Service task', + 'the start event of a subprocess', + `${LINEAR} `, + 'startEvent', + 'Sub', + DEPLOY('HistoryParseListener.parseStartEvent'), ], [ - 'a bpmn:sendTask carrying a connector refuses, named as a send task', - 'sendTask', - '', - 'operaton', - 'Send task', + 'the start event of an event sub-process', + `${LINEAR} `, + 'startEvent', + 'ES', + DEPLOY('HistoryParseListener.parseStartEvent'), ], [ - 'a bpmn:businessRuleTask naming a decision and a connector refuses on the connector', - 'businessRuleTask', - 'operaton:decisionRef="dec1"', - 'operaton', - 'Business rule task', + 'a timer boundary event', + boundary(TIMER), + 'boundaryEvent', + 'p', + DEPLOY('BpmnParse.parseTimer', ` ('Attribute "id" is required!')`), ], - ] 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, - }), + [ + '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', ), - UnsupportedServiceTaskFormError, - ); - expect(e.construct).toBe(CONNECTOR_CONSTRUCT); - expect(e.subject).toBe(subject); - }); -}); - -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` - - - - - - - - - - - - - `, - ); - - 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' }, - ], + ], + [ + 'an escalation boundary event, which deploys but fails the run the escalation reaches', + `${LINEAR} + `, + 'boundaryEvent', + 'p', + RUN( + 'ExecutionEntity.generateActivityInstanceId', + 'when an escalation reaches it', ), - ); - 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 body', async () => { - const { node } = await importOnly( - scriptTaskDoc('total = price * quantity;', 'name="Compute total"'), - 'scriptTask', - ); - expect(node.format).toBe('javascript'); - expect(node.code).toBe('total = price * quantity;'); - 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( - xmlToIr( - oneNodeDoc('scriptTask', { - id: 'ST', - attrs: `scriptFormat="javascript" operaton:resource="${resource}"`, - children: '1 + 1', - }), + ], + [ + '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', ), - UnsupportedExtensionFormError, - `the script on 'ST' names an external resource ("${resource}"); ` + - 'only an inline script 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( - ` - - `, - ), - }), + ], + [ + 'a conditional boundary event, which deploys but fails the run of its step', + boundary( + 'go', ), - UnsupportedExtensionFormError, - 'the operaton:script in an operaton:executionListener names an ' + - `external resource ("${resource}"); only an inline script body ` + - 'can be written here', - ); - expect(listenerErr.elementId).toBe('T'); - }); -}); + '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`, + ); + }, + ); -describe('xmlToIr: unsupported element (still refused kinds)', () => { + const HERE = "without an id in 'p')"; 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( - '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.', - ); - }); -}); - -describe('xmlToIr: multi-process error', () => { - it('XML with two bpmn:process elements raises a clear multi-process error', async () => { - const xml = bpmnDefs` - - - - - - - - - `; - - await expect(xmlToIr(xml)).rejects.toThrow(/multi.process|not supported/i); - }); + [ + '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', () => { const MESSAGE_ROOT = ' \n'; - const SIGNAL_ROOT = ' \n'; const MESSAGE_DEF = ''; 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; } - /** `TStart -> E`, where the start carries the given trigger definition. */ const startTriggerXml = ( definition: string, { attrs = '', roots = '', defs = bpmnDefs }: EventXmlOptions = {}, ): string => - defs`${roots} + defs`${roots} ${definition} @@ -603,12 +899,15 @@ 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} + defs`${roots} ${definition} @@ -616,14 +915,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} @@ -631,2487 +926,1466 @@ describe('xmlToIr: start, end, and emit triggers', () => { `; - describe('a start carries message, signal, timer, or condition', () => { - it.each([ - ['timeDuration', 'PT1H', 'duration'], - ['timeDate', '2026-08-01T09:00:00Z', 'date'], - ['timeCycle', 'R/PT10M', 'cycle'], - ] as const)( - 'a %s timer start imports as that timer kind, verbatim', - async (child, expression, timerKind) => { - const { node, warnings } = await importById( - startTriggerXml( - ` - ${expression} - `, - ), - 'TStart', - 'startEvent', - ); - expect(node.eventDefinition).toEqual(timerDef(timerKind, expression)); - 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([]); - }); + const sendDef = (attrs: string): string => + ``; - 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' }]); - }); - }); + const messageDefWith = (attrs: string, children: string): string => + ` + +${children} + + `; - 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'); - }, - ); + const subWith = (tag: string, content: string): string => + bpmnDoc` + <${tag} id="Sub"> +${content} + + + + `; - it('a link start refuses, naming every trigger a process start does take', async () => { - const e = await expectRefusal( - xmlToIr( - startTriggerXml(''), + 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', + ` + + + `, + ), + '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 + `, ), - 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 }, - ), + ), + '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"', ), - 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', - }), + ), + '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"', ), - 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', + ), + '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 element '${elementId}' cannot be imported: ${detail}. ${remedy}`, ); - 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', () => { - it('a process with two start events imports both, each keeping its outgoing flow', async () => { - const { ir, warnings } = - await xmlToIr(bpmnDoc` - - - - `); - 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'], - ]); - }); - - 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('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', + ` - - - - - `), - 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'); - }, + `, + ), ); + expect(subProcess(ir, 'Sub').flowElements.map((fe) => fe.id)).toEqual([ + 'In', + 'SubEnd', + ]); + expect(warnings).toEqual([ + { + elementId: 'Sub', + category: 'behaviourChanged', + 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.', + }, + ]); }); - 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([]); - }); + const EVENT_PROPERTIES = ` + + + + + `; + + 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('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('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([]); - }); - - 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', - message: - "The 'topic' setting on 'Typed' only takes effect alongside " + + 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.', - }); - }); - - 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('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 }, - ), - ); - expectOneWarning(extensionWarnings(warnings), { - elementId: 'Typed', - message: 'operaton:Connector', - }); - }); - }); - - describe('a name that starts with an expression refuses everywhere', () => { - const EXPR_MESSAGE_ROOT = - ' \n'; - const EXPR_SIGNAL_ROOT = - ' \n'; - const SIGNAL_DEF = - ''; - - /** 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 ' + - '"#{...}"'; - - it.each([ - [ - 'a message end', - endTriggerXml(MESSAGE_DEF, { roots: EXPR_MESSAGE_ROOT }), - 'Typed', - MESSAGE_EXPR_DETAIL, ], - [ - 'a message emit', - emitXml(MESSAGE_DEF, { roots: EXPR_MESSAGE_ROOT }), - 'Emit1', - MESSAGE_EXPR_DETAIL, - ], - [ - '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, - ], - [ - 'a message handler start', - handlerDoc(MESSAGE_DEF, { roots: EXPR_MESSAGE_ROOT, body: '' }), - 'HStart', - MESSAGE_EXPR_DETAIL, - ], - // 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, - ], - ])( - '%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, { - roots: ' \n', - }), - ); - expect(warnings).toEqual([]); - const start = byId(subProcess(ir, 'Handler'), 'HStart'); - expect(start.kind === 'startEvent' && start.eventDefinition).toEqual( - messageDef('#{orderType}'), - ); - }); - - it('a signal start named with the "#{...}" spelling imports', async () => { - const { node } = await importById( - startTriggerXml(SIGNAL_DEF, { - roots: ' \n', - }), - 'TStart', - 'startEvent', - ); - expect(node.eventDefinition).toEqual({ - kind: 'signal', - signalName: '#{topic}', - }); - }); - - it.each(['Order-${orderType}', '#{orderType}'])( - 'a message end named %s imports: it reads back as the name it is', - async (name) => { - const { node, warnings } = await importById( - endTriggerXml(MESSAGE_DEF, { - roots: ` \n`, - }), - 'Typed', - 'endEvent', - ); - expect(node.eventDefinition).toEqual(messageDef(name)); - expect(warnings).toEqual([]); - }, - ); - }); - - 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([]); - }); - - 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', () => { - /** `` 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, - children: repeat( - 'operaton:collection="lines" operaton:elementVariable="line"', - ), - }), - 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 }); + { + 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'), + ], }, - ); - - 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}`, + { + 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', ), - '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}`, + { + case: 'a field beside a class is dropped, naming the definition the engine reads it off', + xml: endTriggerXml( + messageDefWith( + 'operaton:class="com.example.Send"', + ' ', + ), ), - '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 - `, + 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()}"', + ' ', + ), ), - 'userTask', - ); - expect(node.loop).toEqual({ - collection: 'assigneeList', - elementVariable: 'assignee', - }); - 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 () => { - const { node } = await importById( - dataDoc('', 'lines'), - 'T', - 'userTask', - ); - 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', + 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"', + ), ), - '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.", + 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"', + ' ', + ), + { roots: ERROR_ROOTS }, ), - ); - }); - - // 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}"`, - ), - 'userTask', - ); - expect(node.loop).toEqual({ - collection: 'lines', - elementVariable: name, - }); - expect(warnings).toEqual([]); - }, - ); - - 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( - 'operaton:collection="lines" operaton:elementVariable="item"', - '', - ), - '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'; " + - "Operaton reads bpmn:inputDataItem second, so 'line' was imported and 'item' was dropped.", - }); - }); - - 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', - repeatedTaskDoc(), - 'it sets neither a number of runs nor a collection to run over, and Operaton refuses to deploy that', - ], - [ - 'an empty bpmn:loopCardinality', - 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'), - ], - [ - 'an element variable with no collection', - repeatedTaskDoc( - 'operaton:elementVariable="line"', - '3', - ), - "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'), - ], - [ - 'an operaton:outputParameter on a repeated step', - oneNodeDoc('userTask', { - children: `${IO_BLOCK}${repeat('operaton:collection="lines"')}`, - }), - "it maps an 'operaton:outputParameter', which Operaton refuses to deploy on a repeated step", - ], - ])('%s is refused', async (_title, xml, detail) => { - const e = await expectRefusal( - xmlToIr(xml), - UnsupportedLoopCharacteristicsError, - detail, - ); - expect(e.elementId).toBe('T'); - expect(e.loopType).toBe('bpmn:MultiInstanceLoopCharacteristics'); - expect(e.message).toBe( - `The repetition on 'T' cannot be imported: ${detail}.`, - ); - }); - - 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', - }; - - // 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: - "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: - '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); - }, - ); - - 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', - ); - 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( - xmlToIr( - handlerDoc('', { - startAttrs: 'name="Escalate"', - }).replace( - '', - '\n ' + - repeat('', '2'), - ), - ), - UnsupportedLoopCharacteristicsError, - 'an event handler is entered by its trigger, so it cannot be repeated', - ); - }); - - 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 () => { - const { ir, warnings } = await xmlToIr( - handlerDoc( - '', - { roots: ' \n' }, - ).replace( - '', - '\n ' + - '', - ), - ); - const handler = ir.flowElements.find((el) => el.id === 'Handler'); - expect(handler?.kind === 'subProcess' && handler.loop).toBeUndefined(); - expect(warnings).toEqual([ - { - elementId: 'Handler', - category: 'unmappedConstruct', - message: - "The bpmn:standardLoopCharacteristics on 'Handler' 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.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 #{...} expression body is rewrapped, 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.'; - - 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'), - ], - [ - 'a job priority', - oneNodeDoc('userTask', { attrs: 'operaton:jobPriority="#{high}"' }), - rewrapped("'jobPriority' setting", 'T'), - ], - [ - 'a job priority on a gateway', - oneNodeDoc('exclusiveGateway', { - attrs: 'operaton:jobPriority="#{high}"', - }), - rewrapped("'jobPriority' setting", 'T'), - ], - [ - 'a user task priority', - oneNodeDoc('userTask', { attrs: 'operaton:priority="#{high}"' }), - rewrapped("'priority' setting", 'T'), - ], - [ - 'a call activity version binding', - oneNodeDoc('callActivity', { - attrs: - 'calledElement="sub" operaton:calledElementBinding="version" ' + - 'operaton:calledElementVersion="#{v}"', - }), - rewrapped('calledElementVersion', 'T'), - ], - ] 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 }); - expect(warnings).toEqual([]); - }, - ); -}); - -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, - body = 'amount > 1000', - ): string => - doc` - - - - - - ${body} - - - `; - - 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', - ], - ] as const)('%s', async (_title, attrs, doc, detail) => { - await expectRefusal( - xmlToIr(flowDoc(attrs, doc)), - UnsupportedConditionExpressionError, - detail, - ); - }); - - 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)); - const flow = ir.sequenceFlows.find((f) => f.id === 'F2'); - expect(flow?.conditionExpression).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.', - }, - ]); - }, - ); - - // 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. - const conditionalDoc = ( - conditionAttrs: string, - position: 'boundary' | 'event-subprocess start' | 'intermediate catch', - body = 'amount > 1000', - ): string => { - const definition = ` - ${body} - `; - if (position === 'boundary') { - return operatonDoc` - - - ${definition} - - - - `; - } - if (position === 'event-subprocess start') { - return handlerDoc(definition, { defs: operatonDefs }); - } - return oneNodeDoc('intermediateCatchEvent', { - id: 'Owner', - children: definition, - doc: operatonDoc, - }); - }; - - 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)( - '%s refuses a language and warns on a lone operaton:resource on its bpmn:condition', - async (_title, position, ownerId) => { - await expectRefusal( - xmlToIr(conditionalDoc('language="groovy"', position)), - UnsupportedConditionExpressionError, - 'it declares language="groovy", which Operaton runs in a script ' + - 'engine rather than evaluating as UEL', - ); - - const { warnings } = await xmlToIr( - conditionalDoc( - 'operaton:resource="deployment://check.groovy"', - position, - ), - ); - expect(warnings).toEqual([ - { - elementId: ownerId, - category: 'extensionAttribute', - message: - `The 'operaton:resource' setting on '${ownerId}' 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.', - }, - ]); + id: 'Typed', + binding: classBinding('com.example.Send'), + warnings: [ + neverReadOnThrow("'taskPriority' setting"), + neverReadOnThrow("operaton:errorEventDefinition 'Map_1'"), + neverReadOnThrow('operaton:properties block'), + ], }, - ); -}); - -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); + { + case: 'the extras on a caught message are throw-only settings, one warning each', + xml: startTriggerXml( + messageDefWith( + 'operaton:taskPriority="7" operaton:resultVariable="r"', + ' \n' + + ' ', + ), + { 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('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(warnings, { - elementId: 'FormHandlerTask', - category: 'extensionAttribute', - message: 'formHandlerClass', - }); - }, - ); +describe('xmlToIr: imports a repetition', () => { + const repeat = (attrs = '', children = ''): string => + `${children}`; - it('does NOT warn for the supported assignee/formKey/class attributes', async () => { - const xml = operatonDoc` - - - - - - `; - const { warnings } = await xmlToIr(xml); - expect(warnings).toEqual([]); - }); + const repeatedTaskDoc = (attrs = '', children = ''): string => + oneNodeDoc('userTask', { children: repeat(attrs, children) }); - /** `start -> E`, with the given attributes on the process and on its start. */ - const headerDoc = ( - processAttrs: string, - startAttrs = '', - startId = 'S', - ): string => - operatonDefs` - - - - `; + const on = ( + category: ImportWarning['category'], + message: string | ReturnType, + ) => ({ elementId: 'T', category, message }); - // 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. - it.each([ + 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[]]>([ [ - 'a process keeps the history time to live it authored', - 'operaton:historyTimeToLive="P90D"', + 'isSequential="true" imports the runs as sequential', + 'operaton:collection="lines" isSequential="true"', '', - (ir: BpmnProcess) => ir.historyTimeToLive, - 'P90D', + { collection: 'lines', sequential: true }, + [], ], [ - '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, + 'operaton:collection and bpmn:loopDataInputRef spelling two variable names import the second', + 'operaton:collection="items"', + 'lines', + { collection: 'lines' }, + [ + both( + 'operaton:collection and bpmn:loopDataInputRef name the collection', + 'parseMultiInstanceLoopCharacteristics writes both into the same field and bpmn:loopDataInputRef second', + 'lines', + 'items', + ), + ], ], [ - 'a process keeps the users allowed to start it', - 'operaton:candidateStarterUsers="demo,manager"', - '', - (ir: BpmnProcess) => ir.candidateStarterUsers, - 'demo,manager', + 'an expression collection outranks a bpmn:loopDataInputRef variable name', + 'operaton:collection="${items}"', + 'lines', + { collection: '${items}' }, + [ + 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 process keeps the groups allowed to start it', - 'operaton:candidateStarterGroups="adjusters"', - '', - (ir: BpmnProcess) => ir.candidateStarterGroups, - 'adjusters', + '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', + ), + ], + ], + [ + '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', + }, + [ + on( + 'behaviourChanged', + "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.', + ), + ], ], [ - 'a start keeps the variable the engine writes the starting user into', + 'the older async spelling on the loop element carries asyncBefore and names the respelling', + 'operaton:collection="lines" operaton:async="true"', '', - 'operaton:initiator="claimant"', - (ir: BpmnProcess) => only(ir, 'startEvent').initiator, - 'claimant', + { collection: 'lines', asyncBefore: true }, + [on('rewritten', asyncRespellingMessage("the repetition of 'T'"))], ], - ])('%s', async (_title, processAttrs, startAttrs, read, expected) => { - const { ir, warnings } = await xmlToIr(headerDoc(processAttrs, startAttrs)); - 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, - }), + ...( + [ + [ + '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(attrs, children), + 'userTask', ); - const handlerStart = only(subProcess(ir, 'Handler'), 'startEvent'); - expect([handlerStart.initiator, warnings]).toEqual(['claimant', []]); + expect(node.loop).toEqual(loop); + expect(warnings).toEqual(expected); }); - // 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'), + 'neither a count nor a collection', + repeatedTaskDoc(), + 'it sets neither a number of runs nor a collection to run over, and Operaton refuses to deploy that', ], [ - 'an event handler start, whose trigger prints in the header instead', - operatonDefs` - - - - - - - - - - `, + 'an empty bpmn:loopCardinality', + repeatedTaskDoc('', ''), + 'its bpmn:loopCardinality is empty, so Operaton has no number of runs to read', ], - ])( - '%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.', - }, - ]); - }, - ); -}); - -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', + [ + 'a fractional bpmn:loopCardinality', + repeatedTaskDoc('', '3.5'), + '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', + repeatedTaskDoc( + 'operaton:elementVariable="line"', + '3', + ), + "it names 'line' for each run to see but no collection to take it from, and Operaton refuses to deploy that", + ], + [ + 'a bpmn:inputDataItem name outside the identifier the clause writes', + repeatedTaskDoc( + 'operaton:collection="lines"', + '', + ), + '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: `${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")', + ], + ])('%s is refused', async (_title, xml, detail) => { + const e = await expectRefusal( + xmlToIr(xml), + UnsupportedLoopCharacteristicsError, + detail, + ); + expect(e.elementId).toBe('T'); + expect(e.loopType).toBe('bpmn:MultiInstanceLoopCharacteristics'); + expect(e.message).toBe( + `The repetition on 'T' cannot be imported: ${detail}.`, ); - 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 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. - */ - 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 loopedHandler = (loop: string, roots = ''): string => + handlerDoc( + '', + { roots }, + ).replace( + '', + `\n ${loop}`, ); - const clean = byId(ir, 'CleanTask'); - expect(clean.kind === 'userTask' && clean.assignee).toBe('alice'); - expectOneWarning(warnings, { - elementId: 'ConfiguredSvc', - category: 'extensionAttribute', - message: "'greeting'", - }); - }); -}); - -describe('xmlToIr: foreign-namespace extension elements are per-element', () => { - it('names a camunda: extension element against its owning task', 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( - ` `, + it('an event handler carrying a multi-instance repetition is refused', async () => { + await expectRefusal( + xmlToIr( + loopedHandler( + repeat('', '2'), + ), ), - doc: camundaDoc, - }); - const { warnings } = await xmlToIr(xml); - expectOneWarning(warnings, { - elementId: 'CamSvc', - category: 'extensionAttribute', - message: 'Extra configuration (camunda:field)', - }); - }); -}); - -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: /potentialStarter/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 - - - - - - - - - - `, + UnsupportedLoopCharacteristicsError, + 'an event handler is entered by its trigger, so it cannot be repeated', ); - expectOneWarning(extensionWarnings(warnings), { - elementId: 'Message_1', - message: 'operaton:InputOutput', - }); + }); + + 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( + loopedHandler( + '', + ' \n', + ), + ); + expect(subProcess(ir, 'Handler').loop).toBeUndefined(); + expect(warnings).toEqual([ + { + elementId: 'Handler', + category: 'unmappedConstruct', + message: + "The bpmn:standardLoopCharacteristics on 'Handler' 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.', + }, + ]); }); }); -describe('xmlToIr: bpmn:documentation', () => { - const doc = (text: string): string => - `${text}`; +describe('xmlToIr: a #{...} body the printer spells bare is rebuilt inside ${...}, and says so', () => { + const loopDoc = (children: string): string => + oneNodeDoc('userTask', { + children: `${children}`, + }); - /** 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 rewrapped = (slot: string, id: string): string => + `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 reported = (warnings: ImportWarning[]): unknown[] => - warnings.map((w) => [w.category, w.elementId, w.message]); + it.each([ + [ + 'a loop cardinality', + loopDoc('#{lineCount}'), + rewrapped('bpmn:loopCardinality', 'T'), + ], + ] 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(['rewritten']); + }); +}); - const expected = ( - rows: readonly (readonly [ImportWarning['category'], string, string])[], - ): unknown[] => - rows.map(([category, id, detail]) => [ - category, - id, - expect.stringContaining(detail), - ]); +describe('xmlToIr: a scripted condition on a sequence flow or a conditional event definition', () => { + const flowDoc = ( + conditionAttrs: string, + doc: XmlTag = operatonDoc, + body = '${amount > 1000}', + flowAttrs = '', + plainFlowAttrs = '', + ): string => + doc` + + + + + + ${body} + + + `; + const conditionOf = (ir: BpmnProcess): string | undefined => + ir.sequenceFlows.find((f) => f.id === 'F2')?.conditionExpression; - const TIMER = ` - P1D - `; + it.each([ + [ + '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)), + UnsupportedConditionExpressionError, + detail, + ); + }); - /** - * 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. - */ - const POSITIONS: [ - title: string, - xml: string, - carried: readonly (readonly [string, string])[], - warned: readonly (readonly [ImportWarning['category'], string, string])[], - ][] = [ + it.each([ + ['xsi:type="bpmn:tExpression"', 'bpmn:tExpression'], [ - '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.'], - ], - [], + 'an unprefixed xsi:type="tExpression" in a bpmn:-prefixed document, which moddle drops before any reader sees it', + 'tExpression', ], - ...( - [ - ['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}.`]], - [], - ]), + ])( + '%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([ [ - "the text is carried verbatim, the body's own whitespace included", - oneNodeDoc('userTask', { - children: - '\n Two lines.\n ', - }), - [['T', '\n Two lines.\n ']], - [], + '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 empty documentation body carries as an empty string', - oneNodeDoc('userTask', { - children: '', - }), - [['T', '']], - [], + '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([]); + }, + ); + + 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([ [ - '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 body with no "${" or "#{" opener', + 'bpmn:tFormalExpression', + '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', 'bpmn:tFormalExpression', '', undefined, EMPTY_BODY], + [ + 'a whitespace-only body recovered past moddle, as moddle imports one', + 'tFormalExpression', + ' ', + undefined, + EMPTY_BODY, ], + ])( + 'a condition with %s warns that the engine fails the flow on every run', + async (_title, xsiType, body, imported, message) => { + const { ir, warnings } = await xmlToIr( + flowDoc( + `xsi:type="${xsiType}" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`, + operatonDoc, + body, + ), + ); + expect(conditionOf(ir)).toBe(imported); + expect(warnings).toEqual([ + { elementId: 'F2', category: 'behaviourChanged', message }, + ]); + }, + ); + + it.each([ [ - '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_1', 'Where it begins.'], - ['EndEvent_1', 'Where it stops.'], - ], + 'a conditioned flow named other than its condition text', + 'name="yes"', + '', [ - [ - 'documentation', - 'StartEvent_1', - 'this start is left out entirely and its documentation with it', - ], - [ - 'documentation', - 'EndEvent_1', - 'Where the script can do without this end, it is left out and its documentation with it', - ], + { + 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").', + }, ], ], [ - '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.')} - - - - - `, - [], + 'an unconditioned flow named at all', + '', + 'name="no"', [ - ['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'], + { + 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 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} + ])( + '%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); + }, + ); + + const conditionalDoc = ( + conditionAttrs: string, + position: 'boundary' | 'event-subprocess start' | 'intermediate catch', + body = 'amount > 1000', + ): string => { + const definition = ` + ${body} + `; + if (position === 'boundary') { + return operatonDoc` + + + ${definition} + + + `; + } + if (position === 'event-subprocess start') { + return handlerDoc(definition, { defs: operatonDefs }); + } + return oneNodeDoc('intermediateCatchEvent', { + id: 'Owner', + children: definition, + doc: operatonDoc, + }); + }; + + 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( + xmlToIr(conditionalDoc('language="groovy"', position)), + UnsupportedConditionExpressionError, + 'it declares language="groovy", which Operaton runs in a script ' + + 'engine rather than evaluating as UEL', + ); + + const { warnings } = await xmlToIr( + conditionalDoc( + 'operaton:resource="deployment://check.groovy"', + position, + ), + ); + expect(warnings).toEqual([ + { + elementId: ownerId, + category: 'extensionAttribute', + message: + `The 'operaton:resource' setting on '${ownerId}' 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.', + }, + ]); + }, + ); +}); + +describe('xmlToIr: warns for dropped extension attributes', () => { + it('does NOT warn for the supported assignee/formKey/class attributes', async () => { + const xml = operatonDoc` + + - - - `, - [], - [['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 - - + + `; + const { warnings } = await xmlToIr(xml); + expect(warnings).toEqual([]); + }); + + const headerDoc = ( + processAttrs: string, + startAttrs = '', + startId = 'S', + ): string => + operatonDefs` + - - `, - [], - [ - ['documentation', 'Await', 'an event definition has no documentation'], - ['documentation', 'Await', 'an await has no documentation'], - ], - ], + + `; + + it.each([ + ['an authored history time to live is kept', 'P90D', 'P90D', []], + ['the exported default reads back as absent', 'P30D', undefined, []], [ - 'an emit reports it, and so does the event definition inside it', - bpmnDefs` - - - ${doc('Tell the warehouse.')} - ${doc('The broadcast itself.')} - - - - - `, - [], + 'an absent history time to live warns that the rebuilt process deploys', + undefined, + undefined, [ - ['documentation', 'Emit', 'an event definition has no documentation'], - ['documentation', 'Emit', 'an emit has no documentation'], + { + 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.', + }, ], ], - [ - 'a throw reports it, and so does the event definition inside it', - bpmnDefs` - + ])('%s', async (_title, authored, expected, expectedWarnings) => { + const { ir, warnings } = await xmlToIr( + operatonDefs` - ${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]]), ); + expect([ir.historyTimeToLive, warnings]).toEqual([ + expected, + expectedWarnings, + ]); }); -}); -describe('xmlToIr: warns for unmapped BPMN content', () => { - const reviewTaskDoc = (children: string) => - oneNodeDoc('userTask', { id: 'Review', children, doc: bpmnDoc }); + 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]); + }); - it.each([ - [ - 'an artifact against the container holding it', - bpmnDoc` + const starterDoc = (processAttrs: string, children: string): string => + operatonDefs` + +${children} + + - - 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'"], + `; + const potentialStarter = (expression?: string): string => + ` ${ + expression === undefined + ? '' + : ` + + ${expression} + ` + } + `; + + 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: [ + [ + 'rewritten', + '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.', + ], ], - ], - [ - 'an attribute BPMN does not declare, against each element carrying it', - bpmnDoc` - - - - `, - [ - ['S', "The 'wobble' attribute on 'S'"], - ['Review', "The 'wobble' attribute on 'Review'"], + }, + { + 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).', + ], ], - ], - ] 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)]), + }, + ])( + '$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, ); }, ); - it('leaves a foreign-namespace attribute on a mapped element unreported', async () => { - const xml = ` - - - - + const signalDoc = (tag: string, defAttrs = ''): string => + rootedDoc( + ' \n', + ` + + + + + + - - - -`; + + `, + 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", + 'Ev', + 'a ', + 'reads it on a thrown signal as its async delivery ' + + '(BpmnParse.parseSignalEventDefinition)', + ), + unread( + "operaton:in 'a'", + 'Ev', + 'a ', + 'reads it on a thrown signal as its payload ' + + '(BpmnParse.parseSignalEventDefinition through parseInputParameter)', + ), + ], + }, + { + title: + "an operaton:in on a caught signal's definition reads nowhere, since parseSignalEventDefinition only reads it on a throw", + xml: signalDoc('intermediateCatchEvent'), + warnings: [ + unread("operaton:in 'a'", 'Ev', 'a '), + ], + }, + { + title: + 'the job priority and task priority on the process name parseProcess', + xml: headerDoc('operaton:jobPriority="3" operaton:taskPriority="4"'), + 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)', + ), + ], + }, + ])('$title', async ({ xml, warnings: expected }) => { const { warnings } = await xmlToIr(xml); - expect(warnings).toEqual([]); + expect(warnings).toEqual(expected); }); - 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:import' }, - ], - ])('%s', async (_title, root, expected) => { - const xml = bpmnDefs`${root} - + 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', () => { + it('names a lane nested in a childLaneSet as well as the lane holding it', async () => { + const xml = bpmnDoc` + + + + + + - - `; + `; - expectOneWarning(unmappedWarnings((await xmlToIr(xml)).warnings), expected); + 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'); }); +}); - it('reports isExecutable="false", which imports as executable regardless', async () => { - const xml = bpmnDefs` - +describe('xmlToIr: undeclared operaton extension element residual', () => { + const residualXml = operatonDoc` + + + + + + + + - - `; - - const { ir, warnings } = await xmlToIr(xml); - expect(ir.isExecutable).toBe(true); + + + `; - const reported = unmappedWarnings(warnings); - expectOneWarning(reported, { + 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: 'isExecutable="false"', + message: /formProperty/i, }); - expect(reported[0].message).toMatch(/deploy/i); + expect(warnings.some((w) => w.elementId === 'CleanTask')).toBe(false); }); +}); - it('says nothing when the process omits isExecutable, which an engine reads as executable', async () => { - const xml = bpmnDefs` - - - - `; +describe('xmlToIr: bpmn:documentation', () => { + const doc = (text: string): string => + `${text}`; - expect((await xmlToIr(xml)).warnings).toEqual([]); - }); + 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('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([]); + 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.`, }); -}); -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 = ''; + const TIMER = + 'P1D'; - /** No `bpmn:sequenceFlow` names the data element: nothing can point at one. */ - const atProcessLevel = (el: string) => - operatonDoc` - ${el} - - `; + 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; - const insideSubProcess = (el: string) => - operatonDoc` - - - ${el} - - + const CONTENT: Partial> = { + scriptTask: 'total = 1;', + subProcess: + '', + }; + + 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: 'Msg', + category: 'unreferencedRoot', + message: + 'The message "Paid" declared by root \'Msg\' is never used by an on/throw/emit; it was not imported.', + }, + 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'), + ]); + }); - 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('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(documented(ir)).toEqual([ + ['StartEvent_p', 'Where it begins.'], + ['EndEvent_p', 'Where it stops.'], + ]); + expect(warnings).toEqual([ + 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([ [ - '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 second documentation child leaves the element with none', + `${doc('The first.')}${doc('The second.')}`, + /this surface holds one and 'T' carries 2/, ], [ - 'a dataStoreReference inside a sub-process is reported once, whole', - insideSubProcess(DATA_STORE_REF), - dropped('dataStoreReference'), + '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, xml, message) => { - const { warnings } = await xmlToIr(xml); - expect(warnings).toEqual([ - { elementId: 'Data1', category: 'unmappedConstruct', message }, - ]); + ])('%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: 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).'; - - 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', - bpmnDefs`${GLOBAL_SCRIPT_TASK} - +describe('xmlToIr: warns for unmapped BPMN content', () => { + const withRoots = ( + roots: string, + content = '', + attrs = 'isExecutable="true"', + ) => + bpmnDefs`${roots} + + ${content} - `, - [ - { - elementId: 'G3', - category: 'unmappedConstruct', - message: `A bpmn:globalScriptTask 'G3' root element was not imported ${ROOT_DROP_NOTE}`, - }, - ], + `; + + it.each([ + [ + 'content on bpmn:definitions itself is reported against the process', + withRoots(' '), + [warning('p', /bpmn:extension/, 'unmappedConstruct')], ], [ - '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}`, + 'a dataObject with a dataState child at process level is reported once, whole', + withRoots( + '', + '', + ), [ { - elementId: 'G3', + elementId: 'Data1', category: 'unmappedConstruct', - message: `A bpmn:globalScriptTask 'G3' root element was not imported ${ROOT_DROP_NOTE}`, + message: + "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.', }, ], ], [ - '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} - - - - - `, + 'a globalScriptTask root is reported once, and its own script child is not blamed on the process', + withRoots( + ' \n x\n ', + ), [ - { - 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 ${KEPT_SETTINGS_NOTE}`, + 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).', }, ], ], [ - '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 - - - - `, + 'an eventDefinitionRef is reported on the event that names it, beside the root it names', + withRoots(' ').replace( + '', + '\n TED\n ', + ), [ { - elementId: 'G3', + elementId: 'S', category: 'unmappedConstruct', - message: `A bpmn:globalScriptTask 'G3' root element was not imported ${ROOT_DROP_NOTE}`, + 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: 'G1', + elementId: 'TED', 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 ${KEPT_SETTINGS_NOTE}`, + 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).', }, ], ], - ] as const)('%s', async (_title, xml, expected) => { - const { warnings } = await xmlToIr(xml); + [ + 'isExecutable="false" is reported, and the process imports as executable regardless', + withRoots('', '', 'isExecutable="false"'), + [warning('p', /isExecutable="false".*deploy/i, 'behaviourChanged')], + ], + ])('%s', async (_title, xml, expected) => { + const { ir, warnings } = await xmlToIr(xml); + expect(ir.isExecutable).toBe(true); 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); - }); - - 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( - ` + 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: ` `, - ); - - 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 ' + @@ -3121,352 +2395,258 @@ 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', () => { - const callDoc = (attrs = '', children = '', doc = operatonDoc) => + const callDoc = (attrs = '', extension = '', doc = operatonDoc) => oneNodeDoc('callActivity', { id: 'CallSub', attrs: `calledElement="sub-process" ${attrs}`, - children, + children: extension && extensionElements(extension), doc, }); - const richCallXml = callDoc( - 'name="Call sub" operaton:calledElementBinding="version" operaton:calledElementVersion="3"', - extensionElements(` + 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"', + ` - - `), - ); - - 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' }, - ], - }; - - 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 callXmlWithBindingAttrs = (attrs: string): string => - callDoc(attrs, '', dualDoc); - - 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" }', - '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); - }); - - it.each([ - [ - 'calledElementBinding="version" WITHOUT a version is refused', - 'operaton:calledElementBinding="version"', - ], - [ - 'an unrecognized calledElementBinding value (e.g. versionTag) is refused', - 'operaton:calledElementBinding="versionTag"', - ], - ])('%s', async (_title, attrs) => { - await expect( - xmlToIr(callXmlWithBindingAttrs(attrs)), - ).rejects.toBeInstanceOf(UnsupportedCallActivityError); + + `, + ), + '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([]); }); - it('a dangling calledElementVersion (binding absent) imports with NO binding and exactly one warning', async () => { - const { node, warnings } = await importCall( - 'operaton:calledElementVersion="#{v}"', - ); - expect('binding' in node).toBe(false); - expect(warnings).toEqual([ - { - elementId: 'CallSub', - category: 'extensionAttribute', - message: - "The 'calledElementVersion' setting on 'CallSub' has no effect " + - 'without calledElementBinding="version" and was not imported.', - }, - ]); + const onCall = (message: string, category = 'extensionAttribute') => ({ + elementId: 'CallSub', + category, + message, }); - it.each([ - [ - 'operaton:calledElementTenantId is refused, naming the tenant attribute', - 'operaton:calledElementTenantId="tenant-a"', - /calledElementTenantId/, - ], - [ - 'camunda:calledElementTenantId is refused too, matching the dual-namespace contract', - 'camunda:calledElementTenantId="tenant-a"', - /calledElementTenantId/, - ], - ] as const)('%s', async (_title, attributes, detail) => { - await expectRefusal( - xmlToIr(callXmlWithBindingAttrs(attributes)), - UnsupportedCallActivityError, - detail, + 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([ - [ - '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' }, - [], - ], - [ - 'both attributes import the class and report the delegate as shadowed', - 'operaton:variableMappingClass="com.acme.Mapper" ' + + { + 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.', + 'rewritten', + ), + CAMUNDA_ALIAS_WARNING, + ], + }, + { + title: + 'operaton:variableMappingDelegateExpression imports as a delegate mapper', + xml: callDoc( 'operaton:variableMappingDelegateExpression="${mapperBean}"', - { kind: 'class', className: 'com.acme.Mapper' }, - [ - { - elementId: 'CallSub', - category: 'extensionAttribute', - message: - "The 'variableMappingDelegateExpression' setting on 'CallSub' " + + ), + 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.', - }, + ), ], - ], - ] as const)( - '%s', - async (_title, attributes, expectedMapper, expectedWarnings) => { - const { node, warnings } = await importCall(attributes); - expect(node.mapper).toEqual(expectedMapper); - expect(warnings).toEqual(expectedWarnings); }, - ); - - const callXmlWithExtension = (extension: string): string => - callDoc('', extensionElements(extension)); + { + title: 'source beside sourceExpression keeps source', + xml: callDoc( + '', + '', + ), + 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', + xml: callDoc('', ''), + 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', + xml: callDoc( + '', + '', + ), + 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', + xml: callDoc( + '', + '', + ), + node: { businessKey: '${b}' }, + warnings: [ + 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.', + ), + ], + }, + ])('$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([ [ - 'an operaton:in with both source and sourceExpression is refused, naming the shape', - '', - 'an operaton:in carries both source and sourceExpression', + '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', ], [ - 'an operaton:in with source but no target is refused, naming the shape', - '', - 'an operaton:in carries source without a target', + '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', ], [ - '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)', + 'operaton:calledElementTenantId', + callDoc('operaton:calledElementTenantId="tenant-a"'), + /calledElementTenantId/, ], [ - 'a businessKey In combined with a target is refused, naming the shape', - '', - 'an operaton:in businessKey is combined with source/sourceExpression/target/variables/local', + 'a caseRef and no calledElement', + oneNodeDoc('callActivity', { + id: 'CallSub', + attrs: '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', ], [ - 'two businessKey Ins are refused, naming the shape', - '', - 'more than one operaton:in businessKey is set', + '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'\")", ], [ - '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)', + '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 sourceExpression but no target is refused, naming the shape', - '', - 'an operaton:in carries sourceExpression without a target', + 'an operaton:in with source but no target', + callDoc('', ''), + 'an operaton:in carries source 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', + 'an operaton:in with variables="foo"', + callDoc('', ''), + 'an operaton:in carries variables="foo", which this tool cannot import (only variables="all" is supported)', ], [ - 'a businessKey In combined with variables is refused, naming the shape', - '', - 'an operaton:in businessKey is combined with source/sourceExpression/target/variables/local', + 'an empty operaton:in', + callDoc('', ''), + 'an operaton:in carries none of the recognized shapes (source+target, sourceExpression+target, variables="all", or businessKey)', ], [ - 'an operaton:out with source but no target is refused, naming the out tag', - '', - 'an operaton:out carries source without a target', + '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")', ], - ] as const)('%s', async (_title, extension, detail) => { - await expectRefusal( - xmlToIr(callXmlWithExtension(extension)), - UnsupportedCallActivityError, - detail, - ); - }); - - it('a camunda:in on a call activity produces a drop warning (foreign-namespace element)', async () => { - const xml = callDoc( - '', - extensionElements(' '), - camundaDoc, - ); - - const { warnings } = await xmlToIr(xml); - const w = extensionWarnings(warnings).find( - (w) => w.elementId === 'CallSub', - ); - expect(w).toBeDefined(); - }); - - 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([]); - }); - - 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', + 'an operaton:in with sourceExpression but no target', + callDoc('', ''), + 'an operaton:in carries sourceExpression without a target', ], - ])('a call-activity label that %s', async (_title, written, kept) => { - const { node } = await importOnly( - oneNodeDoc('callActivity', { - id: 'Fulfil_Order', - attrs: `name="${written}" calledElement="sub-process"`, - doc: bpmnDoc, - }), - 'callActivity', - ); - expect('name' in node).toBe(kept !== undefined); - expect(node.name).toBe(kept); + ])('a call activity with %s is refused', async (_title, xml, detail) => { + await expectRefusal(xmlToIr(xml), UnsupportedCallActivityError, detail); }); }); describe('xmlToIr: event layer import', () => { const fullEventXml = dualDefs` - + @@ -3531,60 +2711,10 @@ 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([]); - }); - - /** 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, - ], - ])('%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', - ); - expect(w).toBeDefined(); - expect(w?.message).toContain('has no code'); - expect(w?.message).not.toContain('never caught'); + expect(warnings).toEqual([CAMUNDA_ALIAS_WARNING]); }); 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', @@ -3593,16 +2723,6 @@ describe('xmlToIr: event layer import', () => { - `, - ], - [ - 'an event handler with two start events', - bpmnDoc` - - - - - `, ], [ @@ -3613,20 +2733,6 @@ describe('xmlToIr: event layer import', () => { { body: '' }, ), ], - [ - 'an event handler with an incoming flow', - bpmnDoc` - - F1 - - - - - - - - `, - ], [ 'isInterrupting="false" on an error handler', handlerDoc('', { @@ -3634,16 +2740,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( @@ -3656,16 +2752,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,62 +2776,185 @@ describe('xmlToIr: event layer import', () => { UnsupportedEventFeatureError, ); }); - }); - describe('warn-drops', () => { - const ERROR_X_ROOT = ' \n'; + 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"); + }, + ); + + const workBoundaryDoc = (boundary: string, roots = ''): string => + rootedDoc( + roots, + ` + + + + + + ${boundary} + + + + + + `, + ); + + 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})`; + + it.each([ + [ + 'a boundary catch whose escalationRef names no root', + workBoundaryDoc( + '', + ), + 'B', + 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); + }, + ); - 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([]); + const danglingErrorRef = (ownerId: string): ImportWarning => ({ + elementId: ownerId, + category: 'rewritten', + 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.', }); - it('operaton:errorCodeVariable on an error end event (throw side) warns: it has no effect there', async () => { + 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( - rootedDoc( - ERROR_X_ROOT, - ` - - - `, - operatonDefs, - ), + throwDoc(''), ); - const end = byId(ir, 'ThrowX'); - expect(end.kind === 'endEvent' && end.eventDefinition).toEqual( - errorDef('X'), + const owner = byId(ir, 'Throw'); + expect('eventDefinition' in owner && owner.eventDefinition).toEqual( + errorDef('Error_Missing'), ); - expectOneWarning(warnings, { - elementId: 'ThrowX', - message: 'errorCodeVariable', - }); - }); - - it('an unrelated operaton: attribute on a mapped event definition warns', async () => { - const { warnings } = await xmlToIr( - handlerDoc( - '', - { roots: ERROR_X_ROOT, defs: operatonDefs }, - ), + 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`, @@ -3754,237 +2963,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('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` - - - - - - - `; - - 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([ [ @@ -3994,13 +2988,18 @@ describe('xmlToIr: message/signal/timer/conditional import', () => { 'non-empty name', ], [ - 'a signal ref to a nameless root', + 'a prefixed messageRef, which the engine resolves and this tool does not', handlerDoc( - '', - { roots: ' \n', body: '' }, + '', + { + roots: ' \n', + body: '', + }, ), - 'a signal definition must reference a bpmn:Signal root with a ' + - 'non-empty name', + "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', @@ -4008,18 +3007,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( @@ -4047,28 +3034,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 camunda: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', - handlerDoc( - ` - \${amount > 100} - `, - { body: '', defs: camundaDefs }, - ), - "a conditional definition's camunda: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) => { @@ -4120,53 +3085,6 @@ 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` - - - - - - - - - - - - - - - - - `; - - const { ir, warnings } = await xmlToIr(xml); - expect(warnings).toEqual([]); - const start = byId(subProcess(ir, 'Handler'), 'HStart'); - expect(start.kind === 'startEvent' && start.eventDefinition).toEqual( - PING, - ); - const emit = byId(ir, 'Emit'); - expect( - emit.kind === 'intermediateThrowEvent' && emit.eventDefinition, - ).toEqual(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( '', @@ -4186,133 +3104,14 @@ 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', () => { - 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 = '') => ` @@ -4322,7 +3121,6 @@ describe('xmlToIr: compensation import', () => { `; - /** `S -> Booking -> E`, where the plain sub-process hosts `body`. */ const bookingDoc = (body: string) => oneNodeDoc('subProcess', { id: 'Booking', @@ -4344,21 +3142,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', { @@ -4371,19 +3154,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"')), @@ -4411,59 +3181,26 @@ describe('xmlToIr: compensation import', () => { 'a compensation event sub-process hosted by another event sub-process', bpmnDoc` - - - - ${undoHandler()} - - - - - `, - 'another event subprocess', - ], - ] as const)( - '%s refuses with UnsupportedEventFeatureError naming the host', - async (_title, xml, host) => { - const { detail } = await expectRefusal( - xmlToIr(xml), - UnsupportedEventFeatureError, - ); - expect(detail).toContain(host); - expect(detail).toContain('compensat'); - }, - ); - - 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', + + + + ${undoHandler()} + + + + + `, + 'another event subprocess', ], ] as const)( - 'isForCompensation="true" on %s refuses with UnsupportedEventFeatureError', - async (_title, xml, elementId) => { - const e = await expectRefusal( + '%s refuses with UnsupportedEventFeatureError naming the host', + async (_title, xml, host) => { + const { detail } = await expectRefusal( xmlToIr(xml), UnsupportedEventFeatureError, - IS_FOR_COMPENSATION_DETAIL, ); - expect(e.elementId).toBe(elementId); + expect(detail).toContain(host); + expect(detail).toContain('compensat'); }, ); @@ -4504,25 +3241,21 @@ 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 = + ''; + + const pairedDoc = (...nodes: readonly string[]): string => + operatonDoc` + ${nodes.join('\n ')} `; - }; const REWRITE_PREVIEW = [ 'subprocess Compensated_ReserveRoom {', @@ -4531,22 +3264,18 @@ describe('xmlToIr: compensation import', () => { ' user CancelReservation', ' }', '}', - ].join('\n'); + ]; it.each([ - [ - 'the boundary event comes first in document order', - pairedDoc(['boundary', 'handler'] as const), - 'CompensationBoundary', - ], [ 'the handler comes first in document order', - pairedDoc(['handler', 'boundary'] as const), + pairedDoc(RESERVE, HANDLER, BOUNDARY), 'CancelReservation', + REWRITE_PREVIEW, ], ] 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, @@ -4555,65 +3284,12 @@ 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. + // Boundary first: only the rewrite preview's mapper meets the host before the container walk. const xml = operatonDoc` @@ -4633,71 +3309,13 @@ 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).', - }, - ]); - }); - - 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', () => { const boundaryXml = bpmnDefs` - + @@ -4787,41 +3405,10 @@ 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 `; - /** `PStart -> Review -> PEnd`, carrying the given boundary block. */ const reviewBoundaryDoc = ( boundary: string, extraFlows = '', @@ -4835,10 +3422,6 @@ ${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. - */ 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 ' + @@ -4869,25 +3452,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(` @@ -4898,48 +3462,6 @@ ${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` @@ -4954,28 +3476,7 @@ ${extraFlows} - - - - - - - - - - ${TIMER_1H} - `), - unattachableDetail('Handler'), - 'Boundary_Handler_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( ` @@ -5015,145 +3516,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([]); - - // 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. */ const unsupportedTriggerXml = (definitionXml: string, doc = bpmnDoc) => oneNodeDoc('intermediateCatchEvent', { id: 'Wait', @@ -5162,10 +3528,6 @@ 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. - */ 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 ' + @@ -5179,21 +3541,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) => { @@ -5248,27 +3595,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) => { @@ -5284,17 +3610,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 = '', @@ -5304,7 +3627,7 @@ describe('xmlToIr: link events', () => { extraFlows = '', defs = bpmnDefs, }: PairOptions = {}): string => - defs` + defs` ${throwChildren} @@ -5319,40 +3642,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({ @@ -5360,7 +3649,13 @@ ${extraFlows} `; 'name="Retry" operaton:asyncBefore="true" operaton:jobPriority="5"', throwChildren: extensionElements( ` R3/PT5M - `, + + + + + + 1 + `, ), catchAttrs: 'name="Retry" operaton:asyncBefore="true"', defs: operatonDefs, @@ -5387,17 +3682,13 @@ ${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'), ]); }); 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) => { @@ -5445,38 +3736,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', @@ -5488,43 +3751,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) => { @@ -5556,8 +3782,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(` @@ -5580,20 +3805,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([]); - }); }); describe('xmlToIr: BPMN-native assignment and the quantity attributes', () => { const role = ( @@ -5619,45 +3834,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.each([ [ 'a humanPerformer beside operaton:assignee', @@ -5706,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)], ]); }, ); @@ -5738,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":/, @@ -5816,37 +3992,9 @@ ${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([]); - }); - - 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([]); - }); -}); - describe('xmlToIr: flat engine settings on every carrying node kind', () => { const everyKindXml = operatonDefs` - + { expect(ir.versionTag).toBe('1.4'); }); - it('an event handler and its trigger start each carry their own settings', async () => { - const xml = operatonDefs` - + const RESPELLED_RESULT_VARIABLE = { + elementId: 'T', + category: 'rewritten', + 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.', + }; + + it.each([ + { + 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: + '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); + }, + ); +}); + +describe("xmlToIr: a timer job's lock is read where BpmnParse.parseTimer reads it", () => { + const boundaryTimerDoc = (tagAttrs: string, defAttrs: string): string => + operatonDefs` + + + + + + PT1H + + + + + + + `; + + const handlerTimerDoc = ( + tagAttrs: string, + defAttrs: string, + subAttrs = '', + ): string => + operatonDefs` - - - + + + R/PT1H @@ -5931,220 +4152,102 @@ describe('xmlToIr: flat engine settings on every carrying node kind', () => { `; + const CONTINUATION_COPY_WARNING = warning( + 'Handler', + /'jobPriority' setting on 'Handler' was not imported: the 'on timer' head.*timer job.*continuation job/, + ); + + 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 definition alone on a boundary', + 'B', + { def: '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 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([]); + 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); }); }); -describe('xmlToIr: the job settings on a gateway', () => { - const TIMER_CATCH = ` +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 = ` +// 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, + { + 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} `; - }; - - 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', - }; - 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([ - { - 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: [], - }, - { - 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: 'an input/output block on a fork is still reported', - tag: 'parallelGateway', - attrs: '', - children: extensionElements(` - bar - `), - placement: 'first' as const, - settings: {}, - warnings: stillReported('operaton:InputOutput'), - }, - { - title: 'an execution listener on a split is still reported', - tag: 'exclusiveGateway', - attrs: '', - children: extensionElements( - ' ', - ), - placement: 'last' as const, - settings: {}, - warnings: stillReported('operaton:ExecutionListener'), - }, - ])('$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, +describe('xmlToIr: the job settings on a gateway', () => { + 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', + }), ); - }); -}); - -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', 'rewritten', asyncRespellingMessage("'G'")], ]); }); }); -/** - * 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. - */ const serviceTaskWith = (children: string): string => oneNodeDoc('serviceTask', { id: 'Svc', @@ -6155,7 +4258,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', @@ -6165,222 +4267,258 @@ 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}`), - ); + 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()} + PT1H + + ${IO()} + + + + + + + + `; - 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}')), + const { ir, warnings } = await xmlToIr(xml); + const carried = ir.flowElements + .filter( + (fe) => 'inputParameters' in fe && fe.inputParameters !== undefined, + ) + .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)', + ), ]); - expect(warnings).toEqual([]); }); - it('a list of maps and a map of lists both import, nested either way', async () => { + it('a parameter with an empty body imports as empty text', async () => { const { node: task, warnings } = await importServiceTask( - ioBlock(` - - - v - - - - - - - - z - - - - `), + ioBlock(' '), ); - - expect(task.inputParameters).toEqual([ - ioParam( - 'listOfMaps', - listValue([mapValue([mapEntry('k', textValue('v'))])]), - ), - ioParam( - 'mapOfLists', - mapValue([mapEntry('k', listValue([textValue('z')]))]), - ), - ]); + expect(task.inputParameters).toEqual([ioParam('nothing', textValue(''))]); expect(warnings).toEqual([]); }); - it('a parameter with an empty body imports as empty text', async () => { + 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 ` + + `mapping unsupported for element type '${tag}'${suffix}")`; + + const TRIGGERED = " with attribute 'triggeredByEvent = true'"; + + 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'), + ], + [ + 'a parallelGateway', + gatewayDoc('parallelGateway', '', { children: IO() }), + 'G', + ioRefusal('parallelGateway', '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); + }, + ); + + 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(' '), - ); - expect(task.inputParameters).toEqual([ioParam('nothing', textValue(''))]); - expect(warnings).toEqual([]); - }); - - it('parameter order is preserved across both directions', async () => { - const { node: task } = await importServiceTask( - ioBlock(` 1 - 2 - 3 - a - b`), + ioBlock( + ` texta`, + ), ); - expect(task.inputParameters?.map((p) => p.name)).toEqual([ - 'one', - 'two', - 'three', + expect(task.inputParameters).toEqual([ + ioParam('x', listValue([textValue('a')])), ]); - expect(task.outputParameters?.map((p) => p.name)).toEqual([ - 'first', - 'second', + 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.', + }, ]); }); - - it('every activity kind reads its own io block, and an event does not', async () => { - const io = ` - - 1 - - `; - const xml = operatonDoc` -${io} - - -${io} - - -${io} - 1 - - -${io} - - - - - -${io} - - - - - - - `; - - const { ir, warnings } = await xmlToIr(xml); - const carried = ir.flowElements - .filter( - (fe) => 'inputParameters' in fe && fe.inputParameters !== undefined, - ) - .map((fe) => fe.id); - expect(carried).toEqual(['Review', 'Calc', 'Booking', 'Sub']); - - // 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, - }); - }); }); 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' - `, + 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(scripted.node.executionListeners).toEqual([ - { event: 'end', binding: scriptValue('groovy', "println 'done'") }, + 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)', + ), ]); - 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.each([ + { + 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: 'carriedAsWritten', + message: + '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 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.", + }, + ], + }, + ] as const)('$case', async ({ children, listeners, warnings: expected }) => { + const { node: task, warnings } = await importServiceTask(children); + expect(task.executionListeners).toEqual(listeners); + expect(warnings).toEqual(expected); }); }); @@ -6388,14 +4526,14 @@ 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', @@ -6424,55 +4562,108 @@ 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 }); - }); - - it('a listener id is reported, not swallowed with the listener that runs', 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( - ` + it.each([ + { + case: 'an empty class carries as written, since parseTaskListener checks no attribute for emptiness', + children: ` `, + listeners: [{ event: 'create', binding: classBinding('') }], + warnings: [ + { + elementId: 'Review', + category: 'carriedAsWritten', + 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: 'carriedAsWritten', + 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: 'carriedAsWritten', + 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: ` - PT8H + PT1H `, - ); - expect(task.taskListeners?.[0].timer).toEqual(timerDef('duration', 'PT8H')); - expect(warnings).toHaveLength(1); - expect(warnings[0].message).toMatch( - /'id' on an operaton:taskListener 'timeout'/, - ); - }); - - 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/), - ]); + 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); }); }); @@ -6495,80 +4686,29 @@ 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('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: ` - - - - - - - `, - }); - 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:inputOutput/], + ], + ] 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)), + ); }); }); -/** 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}`; @@ -6591,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}.`); @@ -6612,41 +4757,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', @@ -6654,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.', @@ -6694,7 +4804,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,26 +4816,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', - ), - ], - ], - [ - '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.', + 'quoted literal the compiler refuses, since a raw template opens ' + + 'directly after its quote', ), ], ], @@ -6760,22 +4852,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', @@ -6808,20 +4884,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', () => { @@ -6853,41 +4915,25 @@ 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; - - // Revert: drop the builtin arm from readCodeOrExternalBinding, and every row - // refuses as an unsupported form. 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', + '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: 'carriedAsWritten', + 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: @@ -6904,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: ' + @@ -6947,36 +4993,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)`; - - // 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', - 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', [ @@ -7016,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` - + @@ -7027,29 +5044,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), @@ -7060,144 +5054,18 @@ 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, doc = operatonDoc) => - importOnly(oneNodeDoc('userTask', { attrs, doc }), '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([]); - }, - ); + const importFormRef = (attrs: string) => + importOnly(oneNodeDoc('userTask', { attrs }), 'userTask'); - it('the camunda: prefix spells the same form reference', async () => { + 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( - 'camunda:formRef="review-form" camunda:formRefBinding="version" ' + - 'camunda:formRefVersion="3"', - camundaDoc, + 'operaton:formRef="review-form" operaton:formRefBinding="deployment"', ); expect(node).toEqual({ kind: 'userTask', id: 'T', - formRef: { - key: 'review-form', - binding: { kind: 'version', version: '3' }, - }, + formRef: { key: 'review-form', binding: { kind: 'deployment' } }, }); expect(warnings).toEqual([]); }); @@ -7243,12 +5111,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', - ], - [ - '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', + 'formRefBinding="versionTag" is outside the bindings ' + + 'BpmnParse.parseFormDefinition resolves (deployment, latest, ' + + 'version), so the engine refuses to deploy it', ], ])( '%s refuses, rather than importing a task Operaton would not deploy', @@ -7263,75 +5128,76 @@ 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, - }); - }); - - 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); - }); +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 warns and the first is kept', async () => { - const { node: task, warnings } = await importUserTaskWith( - ` - - - - - `, + 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(task.formFields).toEqual([{ id: 'kept', type: 'string' }]); - expect(warnings).toHaveLength(1); - expect(warnings[0].message).toMatch(/FormData/i); + expect(e.elementId).toBe('Review'); }); }); -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.', + }, + ]; + + 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, - }, + }), ); - - const { ir, warnings } = await xmlToIr(xml); const start = byId(subProcess(ir, 'Handler'), 'HStart'); - expect(start.kind === 'startEvent' && start.formFields).toEqual([ - { id: 'reason', type: 'string' }, - ]); - expect(warnings).toEqual([]); + 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 `; @@ -7435,7 +5301,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( @@ -7461,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([ { @@ -7511,7 +5382,7 @@ ${validation(' ')} constraints: [{ name: 'min', config: '0' }], }, warnings: [ - reviewWarning( + carried( /'min'.*imported as written.*number field.*MinValidator\.validate/, ), ], @@ -7527,72 +5398,59 @@ ${validation(' ')} constraints: [{ name: 'min', config: 'abc' }], }, warnings: [ - reviewWarning( - /'min'.*imported as written.*MinValidator\.validate.*'abc'/, - ), + carried(/'min'.*imported as written.*MinValidator\.validate.*'abc'/), ], }, { - case: "a decimal 'min' is carried and the script draws an error", - field: ` -${validation(' ')} + case: 'an enum default naming no value is carried and the script draws an error', + field: ` + + `, imported: { - id: 'amount', - type: 'number', - constraints: [{ name: 'min', config: '1.5' }], + id: 'plan', + type: 'enum', + defaultValue: 'zzz', + values: [{ id: 'a' }, { id: 'b' }], }, warnings: [ - reviewWarning( - /'min'.*imported as written.*MinValidator\.validate.*'1\.5'/, + carried(/'zzz'.*imported as written.*EnumFormType\.validateValue/), + ], + }, + { + case: 'a decimal default on a number field is carried and the script draws an error', + field: ` `, + imported: { id: 'amount', type: 'number', defaultValue: '1.5' }, + warnings: [ + carried( + /^The default '1\.5' on form field 'amount' of 'Review' was imported as written.*LongFormType\.convertValue.*Long\.valueOf/, ), ], }, { - 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' }], - }, + 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( - /'maxlength'.*imported as written.*MaxLengthValidator\.validate.*'2\.5'/, + carried( + /^The default 'maybe' on form field 'flag' of 'Review' was imported as written.*BooleanFormType\.convertValue.*Boolean\.valueOf/, ), ], }, { - case: 'an enum default naming no value is carried and the script draws an error', - field: ` - - - `, - imported: { - id: 'plan', - type: 'enum', - defaultValue: 'zzz', - values: [{ id: 'a' }, { id: 'b' }], - }, + 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( - /'zzz'.*imported as written.*EnumFormType\.validateValue/, + carried( + /^The default '2026-01-01' on form field 'due' of 'Review' was imported as written.*DateFormType.*dd\/MM\/yyyy/, ), ], }, { - 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' }], - }, + case: 'an empty label and an empty default are kept as written', + field: ` `, + imported: { id: 'note', type: 'string', label: '', defaultValue: '' }, warnings: [], }, { @@ -7607,7 +5465,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}`, ); @@ -7681,48 +5538,17 @@ ${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` - + ${body} `; -/** An external service task `Charge`, with the given attributes and extension children. */ const externalTask = (attrs: string, children: string): string => ` ${extensionElements(children)}`; @@ -7734,63 +5560,21 @@ 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'; + + const mappingDoc = (mapping: string, roots = CODED_ROOT): string => + operatonDefs`${roots} + +${externalTask('', mapping)} + + `; it.each([ [ - 'a mapping with no expression', - errorMapping('errorRef="Err_Declined"'), + 'a mapping whose errorRef names no root and carries no expression, which the engine checks before the root', + errorMapping('errorRef="Err_Missing"'), /no expression/, ], [ @@ -7798,31 +5582,9 @@ ${extensionElements(errorMapping('errorRef="Err_Timeout" expression="${false}"') errorMapping('errorRef="Err_Blank" expression="${true}"'), /'Err_Blank'.*no code/, ], - [ - 'a mapping with no errorRef', - errorMapping('expression="${true}"'), - /names no error root/, - ], - [ - 'a mapping with neither attribute', - errorMapping(''), - /names no error root/, - ], - [ - 'a mapping whose errorRef names no root in the document', - errorMapping('errorRef="Err_Missing" expression="${true}"'), - /'Err_Missing'.*names no error root/, - ], ])('%s is refused', async (_title, mapping, detail) => { - const xml = operatonDefs` - - - -${externalTask('', mapping)} - - `; const err = await expectRefusal( - xmlToIr(xml), + xmlToIr(mappingDoc(mapping, CODED_ROOT + BLANK_ROOT)), UnsupportedErrorMappingError, detail, ); @@ -7830,7 +5592,57 @@ ${externalTask('', mapping)} 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 is skipped with a warning, as the engine skips it', + errorMapping('expression="${true}"'), + undefined, + [SKIPPED_MAPPING_WARNING], + ], + ] as const)('%s', async (_title, mapping, errorMappings, expected) => { + const { node, warnings } = await importById( + mappingDoc(mapping), + 'Charge', + 'serviceTask', + ); + expect(node.binding).toEqual({ + ...externalBinding('charge-card'), + ...(errorMappings === undefined ? {} : { errorMappings }), + }); + expect(warnings).toEqual(expected); + }); + it.each([ + { + 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: 'w' }], + }, + 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', body: ` @@ -7862,140 +5674,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 decimal task priority is carried and the script draws an error', - body: externalTask('operaton:taskPriority="1.5"', ''), - binding: { ...externalBinding('charge-card'), taskPriority: '1.5' }, - warnings: [ - warning( - 'Charge', - /taskPriority '1\.5' on 'Charge' was imported as written.*parsePriority/, - ), - ], - }, - { - case: 'a task priority opening with a digit before its expression is carried and the script draws an error', - body: externalTask('operaton:taskPriority="1 ${x}"', ''), - binding: { ...externalBinding('charge-card'), taskPriority: '1 ${x}' }, - warnings: [ - warning( - 'Charge', - /taskPriority '1 \$\{x\}' on 'Charge' was imported as written.*parsePriority/, - ), - ], - }, - { - case: 'a task priority opening with #{ is carried and its rewrapping reported', - body: externalTask('operaton:taskPriority="#{x}"', ''), - binding: { ...externalBinding('charge-card'), taskPriority: '#{x}' }, - warnings: [ - warning( - 'Charge', - /'taskPriority' setting on 'Charge' is written with "#\{\.\.\.\}".*written back inside "\$\{\.\.\.\}"/, - 'unmappedConstruct', - ), - ], - }, - { - case: 'a task priority on an external message throw is an unimported setting', - roots: ' \n', - body: ` - - `, - binding: undefined, - warnings: [ - warning( - 'Ping', - /'operaton:taskPriority' setting on 'Ping' was not imported/, - ), - ], - }, - { - case: 'properties on a user task stay extra configuration', - body: ` -${extensionElements(propertiesOf(' '))}`, - binding: undefined, - warnings: [ - warning( - 'Review', - /Extra configuration \(operaton:Properties\) on 'Review'/, - ), - ], - }, - ])('$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(' ')} @@ -8012,21 +5694,19 @@ ${body} } expect(warnings).toEqual(expected); }); + + it('a decimal task priority refuses, quoting parsePriority', async () => { + const err = await expectRefusal( + xmlToIr(codedErrorsDoc(externalTask('operaton:taskPriority="1.5"', ''))), + UnsupportedExtensionFormError, + priorityRefusal('taskPriority', '1.5'), + ); + expect(err.elementId).toBe('Charge'); + }); }); -/** - * 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. - */ +// 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 = ( @@ -8045,35 +5725,13 @@ 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 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', - children: ioBlock( - ` `, - ), - detail: - "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', @@ -8092,16 +5750,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', @@ -8126,6 +5774,17 @@ ${body} "the operaton:script in operaton:inputParameter 'x' has no " + 'scriptFormat, so there is no language to evaluate its body in', }, + { + 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,7 +5812,17 @@ ${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\)/, + }, + // The engine sets this variable under a name the script cannot spell. + { + case: 'an input parameter whose name the script cannot spell', + on: 'Svc', + children: ioBlock( + ` 1`, + ), + detail: unspellable("operaton:inputParameter 'my.param'"), }, { case: 'an injected field naming no value slot', @@ -8179,22 +5848,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,20 +5879,6 @@ ${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/, - }, { case: 'a timeout task listener with no timer', on: 'Review', @@ -8232,23 +5887,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) => { @@ -8256,22 +5894,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'], [ @@ -8292,9 +5940,8 @@ 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} + operatonDefs`${roots} @@ -8302,7 +5949,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` @@ -8313,38 +5959,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 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')), @@ -8356,79 +5970,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 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', { - attrs: - 'operaton:class="com.example.Rate" ' + - 'operaton:decisionRefBinding="latest" ' + - 'operaton:decisionRefVersion="3" ' + - '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 'mapDecisionResult' setting on 'T' has no effect without an operaton:decisionRef and was not imported.", - ]); - }); - - 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', @@ -8437,15 +5978,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', - ], - [ - 'an unrecognized decisionRefBinding', - 'operaton:decisionRefBinding="versionTag"', - 'decisionRefBinding="versionTag" is not a binding this tool can represent', + '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', @@ -8458,7 +5996,7 @@ describe('xmlToIr: task kinds', () => { ), UnsupportedServiceTaskFormError, ); - expect(e.construct).toBe(construct); + expect([e.serviceTaskId, e.construct]).toEqual(['T', construct]); }, ); @@ -8467,13 +6005,7 @@ describe('xmlToIr: task kinds', () => { receiveDoc('messageRef="Msg_OrderPaid"', ORDER_PAID_ROOT), 'receiveTask', ); - expect(node.messageName).toBe('OrderPaid'); - 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(node.messageName).toBe('OrderPaid'); expect(warnings).toEqual([]); }); @@ -8491,17 +6023,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) => { @@ -8521,25 +6042,9 @@ 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', + category: 'rewritten', message: "The bpmn:manualTask 'M1' imports as a plain step: token flow, " + 'waiting, listeners, async and job configuration are all unchanged, ' + @@ -8556,71 +6061,49 @@ 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"', + 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( - ' \n' + - ' ok\n' + - ' ', + ioBlock( + ' reached', + ), ), }), - 'task', ); - expect(node.asyncBefore).toBe(true); - expect(node.inputParameters).toEqual([ioParam('note', textValue('ok'))]); - expect(warnings).toEqual([MANUAL_TASK_WARNING]); - }); - - 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')), + 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: 'rewritten', + 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 " + + "'intermediateNoneThrowEvent'.", + }, ]); + + const { source } = irToDsl(ir); + expect(source).toContain( + 'step M(label: "Milestone reached", asyncBefore: true)', + ); + await expectParses(source); }); }); 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 = ` @@ -8639,19 +6122,13 @@ 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. */ const blockDoc = ({ tag = 'transaction', attrs = '', @@ -8667,7 +6144,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, @@ -8675,330 +6151,165 @@ ${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 () => { + ], + ])('%s refuses', async (_title, xml, elementId, detail) => { const e = await expectRefusal( - xmlToIr( - pairedDoc({ - beside: cancelBoundary( - 'Boundary_Book_cancel', - 'cancelActivity="false"', - ), - }), - ), + xmlToIr(xml), UnsupportedEventFeatureError, ); + expect(e.elementId).toBe(elementId); 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}`, + `The element '${elementId}' cannot be imported: ${detail}`, ); }); - it('a cancel end with no cancel boundary on its block warns, naming the runtime failure', async () => { - const { warnings } = await xmlToIr( + it.each([ + [ + 'a cancel end with no cancel boundary on its block warns, naming the runtime failure', 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 " + + '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.", - }); - }); - - 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 " + + 'carriedAsWritten', + ], + [ + '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.', - }); - }); - - it.each([ - [ - 'method', - 'method="##Store"', - 'Operaton reads it on a not at all', + 'carriedAsWritten', ], [ - 'protocol', - 'protocol="two-phase"', - 'Operaton reads it on a not at all', + '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.', + 'unmappedConstruct', ], [ - 'triggeredByEvent', - 'triggeredByEvent="true"', - 'Operaton ignores it on a and runs the block as an ' + - 'ordinary step of the surrounding flow', + '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.', + 'unmappedConstruct', ], - ])( - '%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('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 = ` - - - - - - -`; - const { ir, warnings } = await xmlToIr( - blockDoc({ body: BLOCK_BODY + undoHandler }), - ); - expect( - subProcess(subProcess(ir, 'Book'), 'UndoCharge').triggeredByEvent, - ).toBe(true); - expect(warnings).toEqual([]); - - const e = await expectRefusal( - xmlToIr(blockDoc({ beside: undoHandler })), - 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}`, - }), - ); - 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([]); + ])('%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, message }]); }); }); describe('xmlToIr: an either-branch split', () => { - /** `S -> Fork -> {A, B} -> Join -> Sub -> E`, `Sub` holding a split of its own. */ - const splitXml = bpmnDefs` + const xorDoc = (gateway: string, routes: string): string => + bpmnDefs` - - - - - - - - - - - + ${gateway} + + + - - - \${amount > 10000} - - - - - - + +${routes} + + + `; + const route = (id: string, target: string, condition?: string): string => + condition === undefined + ? ` ` + : ` + ${condition} + `; - 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([ + [ + '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.', + ], + [ + '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'); + }, + ); }); 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` + bpmnDefs` ${element} @@ -9012,90 +6323,74 @@ ${element} `; - it('reports the fallback named on a step, which no reader carries, and names the route', async () => { + 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(' '), + 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', - ); + expect(warnings).toEqual([ + { + 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.', + }, + ]); }); +}); - it('reports it on a block and on a step nested in one, one warning each', async () => { - const { 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(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( - ' ', + 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', ); - - expectOneWarning(warnings, { - elementId: 'Triage', - category: 'unmappedConstruct', - message: /The 'default' attribute on 'Triage' was not imported/, - }); - }); - - 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(` `), - ); - - expect(byId(ir, 'Triage')).toMatchObject({ defaultFlowId: 'F2' }); - expect(warnings).toEqual([]); - } }); -}); -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; } @@ -9118,7 +6413,6 @@ describe('xmlToIr: a wait with several branches', () => { const MESSAGE_ROOT = ' \n'; - /** `S -> Wait`, every branch merging into `Merge -> E`. */ const waitDoc = ({ attrs = '', children = '', @@ -9128,7 +6422,7 @@ describe('xmlToIr: a wait with several branches', () => { extraFlows = '', defs = bpmnDefs, }: WaitOptions = {}): string => - defs`${roots} + defs`${roots} `; - 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"' }), @@ -9224,140 +6474,380 @@ ${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')); - }); - - // 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], - ])( - '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( + it('a wait marked operaton:asyncAfter is refused, naming the engine rule', async () => { + const err = await expectRefusal( xmlToIr( - waitDoc({ - branches: [ - messageBranch, - { id: 'Ship', element: '' }, - ], - }), + 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(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(err.elementId).toBe('Wait'); + expect(err.message).toBe( + "The element '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.", ); + }); +}); - 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', - ); +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: 'rewritten' 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.', }); - 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(e.elementId).toBe('OnLink'); + 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', + ); + }, + ], + ...( + [ + ['both messageless', '', '', { name: 'DUP', code: 'DUP' }], + [ + 'only the second carrying a message', + '', + 'operaton:errorMessage="Oops"', + { name: 'DUP', code: 'DUP', message: 'Oops' }, + ], + [ + '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' + + ' ', + operatonDefs, + ), + ); + expect(ir.errorDecls).toEqual([decl]); + 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: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 listener binding: the timer is read before one is resolved. + 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'); + await expectRewrite(e, [ + 'subprocess Compensated_ReserveRoom {', + ' subprocess ReserveRoom {', + ' start RStart', + ' user Hold', + ' end REnd', + ' }', + ' on compensation {', + ' user CancelReservation', + ' }', + '}', + ]); + }, + ], + [ + '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 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. ${SUPPORTED_KINDS_MESSAGE}`, + ); + }, + ], + [ + '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/boundary-events.round-trip.test.ts b/tests/boundary-events.round-trip.test.ts index b437ee5..04595a3 100644 --- a/tests/boundary-events.round-trip.test.ts +++ b/tests/boundary-events.round-trip.test.ts @@ -1,18 +1,17 @@ -import { describe, it, expect } from 'vitest'; - -import { Diagnostic } from 'vscode-languageserver-types'; +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'; -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 +19,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 +66,8 @@ 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)`. function assertAttachedToHost( bounds: Map, hostId: string, @@ -101,15 +93,12 @@ 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. +// Boundary ids the id template would never produce. const IMPORT_FIRST_BPMN = ` - + Flow_CrateArrived_InspectCrate @@ -171,191 +160,107 @@ 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'); - 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 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. - 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( - '', - ); - }); +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); }); -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); - }); - - it('the message a boundary correlates on gets its own bpmn:Message root', () => { - const roots = [ - ...rt.frozenXml.matchAll( - / 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.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', @@ -366,9 +271,6 @@ describeImportFirst( 'Boundary_InspectCrate_error_2', 'Boundary_StoreCrate_timer', ]); - }); - - it('keeps every host, trigger payload, and cancelActivity paired correctly', () => { 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 bda02cf..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,9 +29,7 @@ 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 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; @@ -45,7 +41,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; } +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', ]; -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 forks name a default; a join has one incoming path per branch - // and nothing to fall back to. - 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', - 'Gateway_order-handling_3_fork', - ]); - } - }); - - 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")'); - }); - - 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 names its - // default, 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 fallback with a branch of its own points at it, the one without at the join', () => { - 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])); +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( - byId.get('Flow_Gateway_order-handling_2_fork_default')?.targetRef, - ).toBe('TriageOrder'); + countOfKind(ir, 'inclusiveGateway'), + `inclusive pairs differ in ${label}`, + ).toBe(4); expect( - byId.get('Flow_Gateway_order-handling_3_fork_default')?.targetRef, - ).toBe('Gateway_order-handling_3_join'); - }); + countOfKind(ir, 'parallelGateway'), + `parallel pair differs in ${label}`, + ).toBe(2); + expect( + 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 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 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 { @@ -253,53 +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', () => { - // 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'); - }); - - 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 9a6e238..c765477 100644 --- a/tests/call-activity.round-trip.test.ts +++ b/tests/call-activity.round-trip.test.ts @@ -1,38 +1,22 @@ // 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 { xmlToIr, astToIr } from '@bpmn-script/transform'; import type { BpmnProcess, FlowContainer, CallActivity, - CallVariableMapper, - ImportWarning, } from '@bpmn-script/transform'; -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'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const PURCHASING_EXAMPLE_PATH = resolve( - __dirname, - '../examples/spring-boot/processes/purchasing.bpmnscript', -); + camundaAliasWarning, + describeImportFirst, +} from './helpers/import-first.js'; +import { normalizeIr } from './helpers/normalize-ir.js'; +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( @@ -46,337 +30,263 @@ 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', - '}', - '', - ].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', - }); - }); - - it('generates calledElementBinding="version" and calledElementVersion="3"', () => { - expect(run.xml).toContain('operaton:calledElementBinding="version"'); - expect(run.xml).toContain('operaton:calledElementVersion="3"'); - }); - - it('re-imports to the same binding and re-emits `version = 3`', async () => { - expect(findCallActivity(run.ir2, 'InvokeSub').binding).toEqual({ - kind: 'version', - version: '3', - }); - 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 - // 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', - '', +const inProcess = (name: string, body: string[], vars: 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 and re-parses cleanly', async () => { - 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); - }); -}); - -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, and re-parses with zero errors", async () => { - // 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 document = await parse(run.dsl); - expect(document.parseResult.parserErrors).toHaveLength(0); - }); + const { diagnostics: printedDiagnostics } = await validate(run.dsl); + expect(printedDiagnostics).toEqual([]); + }, + ); }); -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 one humanized from the id, so it survives as a label. +const HANDWRITTEN_BPMN = ` - + Flow_Start_ReviewApprovalCall @@ -420,80 +330,40 @@ describe('round-trip: import-first, with interleaved mappings and the camunda: b `; - 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 zero warnings', () => { - expect(importWarnings).toEqual([]); - 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)); - }); - - 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' + - ' }', - ); - }); -}); - -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([]); - - const document = await parse(run.dsl); - expect(document.parseResult.parserErrors).toHaveLength(0); - }); -}); +describeImportFirst( + 'interleaved mappings and the camunda: binding alias', + HANDWRITTEN_BPMN, + (first) => { + 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([ + { 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', + }, + ]); + 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')], +); diff --git a/tests/compensation.round-trip.test.ts b/tests/compensation.round-trip.test.ts index ba05b46..b12b447 100644 --- a/tests/compensation.round-trip.test.ts +++ b/tests/compensation.round-trip.test.ts @@ -1,18 +1,7 @@ -// 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. - -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, @@ -23,7 +12,6 @@ import { import { roundTripFixture } from './helpers/round-trip-fixture.js'; const rt = roundTripFixture('compensation', { - example: 'compensating-saga', importPath: true, recompile: 'clean', }); @@ -35,13 +23,10 @@ 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. +// `waitForCompletion="true"` is the moddle default, so import drops it. const IMPORT_FIRST_BPMN = ` - + Flow_Begin_Pick @@ -91,83 +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(/ { - const all = - rt.frozenXml.match(/]*>/g) ?? []; - expect(all).toHaveLength(4); - for (const tag of all) { - expect(tag).toBe(''); - } - }); - - 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( @@ -181,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 new file mode 100644 index 0000000..9cad617 --- /dev/null +++ b/tests/cyclic-loop-performance.test.ts @@ -0,0 +1,44 @@ +// `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'; + +const WHILE_COUNT = 150; +const BUDGET_MS = 60_000; + +// 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++) { + 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/documentation.round-trip.test.ts b/tests/documentation.round-trip.test.ts index f0aa540..6f3a511 100644 --- a/tests/documentation.round-trip.test.ts +++ b/tests/documentation.round-trip.test.ts @@ -1,14 +1,8 @@ -// 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. - -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', { @@ -20,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.', @@ -44,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.") {', @@ -74,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/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 f3e8fd2..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. @@ -53,7 +48,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/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 a6dc59f..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 { @@ -12,6 +11,7 @@ import { engineGet, eventSubscriptions, historicActivities, + jobsOfInstance, waitFor, waitForTaskId, waitForTaskKeys, @@ -28,19 +28,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', () => { @@ -48,14 +35,8 @@ 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 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, @@ -73,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 new file mode 100644 index 0000000..48f1b30 --- /dev/null +++ b/tests/e2e/deploy-sweep.test.ts @@ -0,0 +1,136 @@ +// 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. +// +// 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 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 } 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 { + DEPLOY_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 = startFixture(); + }, DEPLOY_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/e2e/engine-extensions.test.ts b/tests/e2e/engine-extensions.test.ts index f91aefb..9b5fdc7 100644 --- a/tests/e2e/engine-extensions.test.ts +++ b/tests/e2e/engine-extensions.test.ts @@ -1,19 +1,14 @@ // 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'; +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 { @@ -74,9 +69,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 +94,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; @@ -120,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(); @@ -150,12 +141,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..4f5a689 100644 --- a/tests/e2e/event-positions.test.ts +++ b/tests/e2e/event-positions.test.ts @@ -1,15 +1,13 @@ -// 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. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +// 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 } from 'vitest'; import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -20,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 { @@ -71,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 0f886aa..93fb746 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 { @@ -41,7 +40,7 @@ const ASSIGNMENT_BPMN = ` - + @@ -67,13 +66,8 @@ describe.skipIf(SKIP)('E2E: forms and external tasks on Operaton', () => { beforeAll(async () => { fixture = await deployExamples(CHARGE_KEY, PLAN_KEY); - }, ENGINE_BOOT_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); - afterAll(async () => { - 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( @@ -183,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/e2e/invoice-approval.test.ts b/tests/e2e/invoice-approval.test.ts index 49c6c19..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'; @@ -26,14 +25,9 @@ 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); - - afterAll(async () => { - await fixture?.stop(); - }, ENGINE_STOP_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); // ReviewStart -> ReviewInvoice -> AmountCheck -> SeniorApproval. it('happy path: senior approval branch', async () => { @@ -90,9 +84,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..239fa74 100644 --- a/tests/e2e/job-settings-and-mail.test.ts +++ b/tests/e2e/job-settings-and-mail.test.ts @@ -1,13 +1,8 @@ // 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. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +// engine does with them; this suite reads back what a real Operaton did. + +import { describe, it, expect, beforeAll } from 'vitest'; import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -18,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'; @@ -77,14 +71,8 @@ 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); - // The definition and suspension state of each job once the instance holds - // the expected number of them. async function parkedJobs( processInstanceId: string, count: number, @@ -224,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 56c38d4..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 @@ -69,7 +64,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..2bda690 100644 --- a/tests/e2e/loan-approval.test.ts +++ b/tests/e2e/loan-approval.test.ts @@ -2,18 +2,16 @@ // 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'; -// 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'); @@ -48,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 @@ -89,7 +83,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..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'; @@ -38,14 +37,9 @@ 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); - - 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 697c21b..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,14 +23,8 @@ describe.skipIf(SKIP)('E2E: repetition on Spring Boot Operaton', () => { beforeAll(async () => { fixture = await deployExamples(PROCESS_KEY, EMPTY_KEY); - }, ENGINE_BOOT_TIMEOUT_MS); + }, DEPLOY_TIMEOUT_MS); - afterAll(async () => { - 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 +39,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 +49,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 +67,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 +78,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..3dea324 100644 --- a/tests/e2e/service-boundary-and-compensation.test.ts +++ b/tests/e2e/service-boundary-and-compensation.test.ts @@ -1,20 +1,19 @@ // 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 // `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 { @@ -34,17 +33,12 @@ describe.skipIf(SKIP)( () => { let fixture: FixtureAdapter; - // Both examples are deployed into one container boot. beforeAll(async () => { fixture = await deployExamples( 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/engine-attributes.round-trip.test.ts b/tests/engine-attributes.round-trip.test.ts index f1364a5..bbf40ec 100644 --- a/tests/engine-attributes.round-trip.test.ts +++ b/tests/engine-attributes.round-trip.test.ts @@ -1,14 +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', { @@ -17,21 +13,15 @@ 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. +// Not the exporter's `P30D` default, which the importer reads as unwritten. const PROCESS_HEADER = { versionTag: '3.1.0', historyTimeToLive: 'P90D', candidateStarterUsers: 'demo,manager', candidateStarterGroups: 'adjusters', + isStartableInTasklist: false, }; -// 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, @@ -57,9 +47,6 @@ 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. [ 'ApprovePayout', 'formRef', @@ -68,128 +55,95 @@ 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); +// `satisfies` fails to compile when `EngineAttributes` gains a field not listed. +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. +// 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, + isStartableInTasklist, + } = 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( - '', + isStartableInTasklist, + }, + 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 what it pins is that no direction -// invents one, on either kind, at any depth. -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', () => { - it("a hosted handler's settings land on the boundary event", () => { - const boundary = theOnly(rt.ir2, 'boundaryEvent'); - expect(boundary.attachedToRef).toBe('ApprovePayout'); - expect(boundary.asyncAfter).toBe(true); - expect(boundary.exclusive).toBe(false); - }); - - 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 360c58d..113f8e8 100644 --- a/tests/event-handlers.round-trip.test.ts +++ b/tests/event-handlers.round-trip.test.ts @@ -1,39 +1,29 @@ -// 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 { describeImportFirst } from './helpers/import-first.js'; + camundaAliasWarning, + describeImportFirst, +} from './helpers/import-first.js'; 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', }); -// 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. const IMPORT_FIRST_BPMN = ` - + Flow_Begin_DoWork @@ -64,96 +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 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'); - } - } - }); - - 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( @@ -161,11 +119,11 @@ 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) {', ); }); }, + [camundaAliasWarning('import-first')], ); 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 978b797..e49fd10 100644 --- a/tests/event-triggers.round-trip.test.ts +++ b/tests/event-triggers.round-trip.test.ts @@ -1,16 +1,7 @@ -// 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. - -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, @@ -19,11 +10,10 @@ 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', { - example: 'order-reminder', importPath: true, recompile: 'clean', }); @@ -34,15 +24,11 @@ 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. +// One `bpmn:Signal` root referenced by the intermediate throw and the end event. const IMPORT_FIRST_BPMN = ` - - - + + Flow_Begin_Dispatch @@ -53,11 +39,11 @@ const IMPORT_FIRST_BPMN = ` Flow_Dispatch_Broadcast Flow_Broadcast_Done - + Flow_Broadcast_Done - + @@ -99,99 +85,58 @@ 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 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}'); - } - } - }); - - 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('there is exactly one root per distinct message and signal name', () => { - expect(rt.frozenXml.match(/ root.name)).toEqual([ + 'OrderCancelled', + ]); }); 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', () => { + 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 collapsed signal name', () => { - const collapsed = { kind: 'signal', signalName: 'ParcelDispatched' }; - expect(definitionOf(first.ir, 'Broadcast')).toEqual(collapsed); - expect(definitionOf(first.ir, 'Done')).toEqual(collapsed); + 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..9502964 100644 --- a/tests/examples.health.test.ts +++ b/tests/examples.health.test.ts @@ -1,16 +1,15 @@ -// 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 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'; 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 { parseToAst, validate } from './helpers/pipeline.js'; +import { normalizeIr } from './helpers/normalize-ir.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -28,82 +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 { - const document = await parse(sourceOf(file)); - expect(document.parseResult.parserErrors).toEqual([]); - return irToXml(astToIr(document.parseResult.value)); -} - 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([]); - }); + 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 () => { - // 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 catchNode = ir.flowElements.find( - (el) => el.kind === 'intermediateCatchEvent', - ); - - expect(catchNode).toBeDefined(); - expect(catchNode).toMatchObject({ + const ir = astToIr(await parseToAst(sourceOf('awaiting-confirmation'))); + 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 () => { - // Doubles as the boundary-event walkthrough; no golden fixture of its own. - 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/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/spring-boot.ts b/tests/fixtures/adapters/spring-boot.ts index f2930f9..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,46 +31,24 @@ 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'), + 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), @@ -169,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/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/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/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.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 7a2e5f3..82c121a 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 @@ -25,22 +23,11 @@ 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' }, ); } -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 function startFixture(): FixtureAdapter { + return springBootAdapter.start(); } 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/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..824e19b 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; @@ -8,12 +8,12 @@ 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( xmlPath: string, deploymentName?: string, + tenantId?: string, ): Promise<{ deploymentId: string }>; startProcess( @@ -29,8 +29,4 @@ export interface FixtureAdapter { ): Promise; restBaseUrl(): string; - - 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..9b7a1f8 100644 --- a/tests/forms.round-trip.test.ts +++ b/tests/forms.round-trip.test.ts @@ -1,16 +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; narrowing here keeps the -// contract test itself free of `kind` bookkeeping. function formFieldsOf( container: BpmnProcess, id: string, @@ -106,53 +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 (an `if`/`else` between two flat tasks), -// so there is nothing for describeDiContainment to walk, the same reason -// intermediate-catch leaves it out. diff --git a/tests/fuzz-regressions.test.ts b/tests/fuzz-regressions.test.ts new file mode 100644 index 0000000..ab13e75 --- /dev/null +++ b/tests/fuzz-regressions.test.ts @@ -0,0 +1,2994 @@ +// Minimized fuzz finds, pinned here since a fuzz run reaches a shape only +// while the generator still draws it. + +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, validationErrors } from './helpers/pipeline.js'; +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 reread against the reversed model +// ('same' if absent). +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 +}`, + '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', + `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 + } +}`, + ], + [ + '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) + } +}`, + ], + [ + '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 + } +}`, + ], + [ + '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 + } + } +}`, + ], + [ + '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 +}`, + ], +]; + +// 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; +} + +it.each(ROWS)('%s', async (_title, source) => { + await expectStableRoundTrip(source); +}); + +// 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, + flowElements: c.flowElements + .map((el) => (el.kind === 'subProcess' ? reversed(el) : el)) + .reverse(), + sequenceFlows: c.sequenceFlows.toReversed(), + }; +} + +it.each(ORDER_ROWS)( + '%s, in model order and reversed', + async (_title, source, comparison = 'same') => { + // 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(model, reread)).toBe(comparison); + expect(next).toBe(dsl); + }, +); + +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 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, so only listing order settles 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; +} + +// 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, +][] = [ + [ + '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 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 { + 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, + ], + // 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 { + error E + var c: any + if (c) { + step X + throw error Fail(E) + } else if (c) { + goto X + } + end Done +} +`, + plain, + ], + // 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 { + var c: any + var d: any + if (d) { + goto B + } + user A + if (c) { + user B + } + user C + goto A +} +`, + plain, + ], + // 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 { + if (true) { + call Call5(process: "invoice-approval") + } else { + service Release10(topic: "shipping") + end Done + } + on Call5: escalation { + step H + goto Release10 + } +} +`, + plain, + ], + [ + '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 + user B + } while (u) + user C + on A: signal("R") { + goto B + } +} +`, + 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) { + if (d) { + goto A + } + } + user K + user A + user N + user Z +} +`, + 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 + 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 + } +} +`, + plain, + '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', + ], + // 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); +}); + +// 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 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); +}); + +// 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( + [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ...[ + ['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 validationErrors(dsl1)).toEqual([]); + const second = await roundTrip(dsl1); + expect(second.dsl).toBe(dsl1); +}); + +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( + [ + '', + conditionalStart('B', '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 validationErrors(dsl)).toEqual([]); + const { ir1: reread, dsl: next } = await roundTrip(dsl); + expect(compareModels(ir, reread)).toBe('same'); + expect(next).toBe(dsl); +}); + +// 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( + [ + '', + conditionalStart('B', 'c'), + conditionalStart('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 validationErrors(dsl)).toEqual([]); + const { ir1: reread, dsl: dsl2 } = await roundTrip(dsl); + 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([]); + 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('same'); + expect(dsl3).toBe(dsl2); +}); + +// 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( + [ + '', + conditionalStart('B', '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.', + }, + ]); +}); diff --git a/tests/fuzz.test.ts b/tests/fuzz.test.ts new file mode 100644 index 0000000..fabc33c --- /dev/null +++ b/tests/fuzz.test.ts @@ -0,0 +1,903 @@ +// 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; 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 +// 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'; +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 { 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, + 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); + +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; +} + +// Shuffles per-source blocks only; a gateway's routes keep their order. +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)!); +} + +// One shared `rand`, so same-size nested containers 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; +} + +const CLASS_ORDER = [ + 'crash', + 'timeout', + 'import-refused', + 'dsl-invalid', + 'silent-change', + 'warned-restructuring', + 'clean', +] as const; +type Cls = (typeof CLASS_ORDER)[number]; + +const FAIL_CLASSES = new Set(CLASS_ORDER.filter((c) => c !== 'clean')); + +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'; + } +} + +// 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) => { + timer = setTimeout(() => reject(new TimeoutError(stage)), TIMEOUT_MS); + }); + try { + return await Promise.race([fn(), clock]); + } finally { + clearTimeout(timer); + } +} + +// 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; +} + +// 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, '') + .replace(/\d+/g, '#') + .replace(/\s+/g, ' ') + .slice(0, 200); +} + +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 errors = result.diagnostics + .filter((d) => d.severity === DiagnosticSeverity.Error) + .map((d) => Diagnostic.getMessageString(d)); + const warnings = result.diagnostics + .filter((d) => d.severity === DiagnosticSeverity.Warning) + .map((d) => Diagnostic.getMessageString(d)); + 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 + ); +} + +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'; + // A layout failure still carries the complete, diagram-less model. + const serialize = async (ir: BpmnProcess): Promise => { + try { + return await bounded(stage, () => irToXml(ir)); + } catch (e) { + if (!(e instanceof LayoutError)) throw e; + o.layoutFallback = true; + return e.xml; + } + }; + const importXml = async ( + xml: string, + ): Promise> | undefined> => { + try { + return await bounded(stage, () => xmlToIr(xml)); + } catch (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)), + ].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'; + const xml2 = await serialize(ir3); + + stage = 'import2'; + const imported2 = await importXml(xml2); + if (imported2 === undefined) return o; + + 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') { + // `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) => ({ + ...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': + if (o.printWarningCategories.length > 0) + return `clean-warned|${o.printWarningCategories.join(',')}`; + return o.secondPassStable === false ? 'clean-unstable' : 'clean'; + } +} + +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; +} + +type Tally = Record; + +function tally(t: Tally, key: string, seed: number): void { + t[key] ??= { count: 0, exampleSeed: seed }; + t[key].count++; +} + +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) { + 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`, + ); + } + } + + 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: Tally = {}; + const importWarningMessages: Tally = {}; + const unstable: { + seed: number; + cls: Cls; + kind: string; + before: string; + after: string; + }[] = []; + const printWarningCategories: Tally = {}; + const invalidMessages: Tally = {}; + const warningMessages: Tally = {}; + for (const r of rows) { + for (const w of r.warningMessages ?? []) + tally(warningMessages, normalizeMessage(w), r.seed); + if (!r.valid) { + tally(invalidMessages, normalizeMessage(r.errors![0]), r.seed); + 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) { + tally(importWarningCategories, c, r.seed); + } + for (const m of o.importWarningMessages) { + tally(importWarningMessages, m, r.seed); + } + if (o.unstableLines) + unstable.push({ seed: r.seed, cls: o.cls, ...o.unstableLines }); + for (const c of o.printWarningCategories) { + tally(printWarningCategories, c, r.seed); + } + 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); + } + + 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!; + 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: Tally = {}; + 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) { + tally(warnedCategoryCounts, c, r.seed); + } + } + + if (OUT !== undefined) { + const outDir = OUT; + let k = 0; + for (const s of symptoms.values()) { + k++; + 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([]); + expect( + Object.keys(importWarningMessages).filter( + (m) => !UNCAUGHT_CANCEL_WARNING.test(m), + ), + ).toEqual([]); + }, + ); +}); diff --git a/tests/gateway-settings.round-trip.test.ts b/tests/gateway-settings.round-trip.test.ts index d33efa9..75c61dd 100644 --- a/tests/gateway-settings.round-trip.test.ts +++ b/tests/gateway-settings.round-trip.test.ts @@ -1,18 +1,15 @@ -// 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'; import type { FlowContainer, JobSettings } from '@bpmn-script/transform'; import { roundTripFixture } from './helpers/round-trip-fixture.js'; -import { describeDiContainment } from './helpers/di-bounds.js'; +import { bpmnDoc } from './helpers/bpmn-doc.js'; import { allElements } from './helpers/ir-query.js'; import { describeImportFirst } from './helpers/import-first.js'; +import { roundTrip, validationErrors } from './helpers/pipeline.js'; +import { modelSignature } from './helpers/model-equivalence.js'; const rt = roundTripFixture('gateway-settings', { dslPrimeFrom: 'frozen', @@ -20,10 +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. Revert: any one direction dropping a `join*` key from the -// join it synthesizes, or reading a head key onto the join. +// Bare gateways included, so a setting nothing wrote fails too. const GATEWAY_SETTINGS: Record = { 'Gateway_loan-application_2_split': { asyncBefore: true, @@ -37,7 +31,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 +43,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 }, @@ -68,9 +67,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; @@ -96,111 +94,88 @@ 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); - } - }); - - // 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'], - ['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: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: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'], - ]); - }); - - // 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. - 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) (exclusive: false, jobPriority: 10)', - 'parallel (asyncBefore: true, joinAsyncAfter: true, joinExclusive: false, joinRetryCycle: "R2/PT1M") {', - 'parallel (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. -const AUTHORED_SPLIT_AND_JOIN = - '\n' + - '\n' + - ' \n' + +// Authored gateway ids, so the `join*` keys are recovered from the shape alone. +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( [ 'process p {', + ' var creditScore: any', ' start Applied', ' if (creditScore >= 700) (jobPriority: 7, joinAsyncBefore: true) {', ' user Approve(assignee: "demo")', @@ -220,4 +195,22 @@ describeImportFirst( }, ); -describeDiContainment(rt, ['CollectReferences']); +// 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); + expect(await validationErrors(first.dsl)).toEqual([]); + expect(modelSignature(first.ir3)).toEqual(modelSignature(first.ir1)); +}); diff --git a/tests/golden/README.md b/tests/golden/README.md index a618f57..da7db33 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. @@ -40,9 +41,9 @@ 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 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. @@ -52,10 +53,11 @@ 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. ## `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}`. @@ -145,37 +147,41 @@ 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. 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-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. -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}` -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-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. ## `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-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 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}` @@ -249,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. @@ -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..addaa51 100644 --- a/tests/golden/engine-attributes.bpmn +++ b/tests/golden/engine-attributes.bpmn @@ -1,6 +1,6 @@ - + @@ -102,7 +102,7 @@ execution.setVariable("mechanicsGrade", grade) Flow_Boundary_ApprovePayout_timer_NudgeApprover - + PT8H 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/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/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/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..baf3eb5 100644 --- a/tests/goto-fallback.round-trip.test.ts +++ b/tests/goto-fallback.round-trip.test.ts @@ -1,316 +1,322 @@ // 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. -import { describe, it, expect, beforeAll } from 'vitest'; - -import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver-types'; +import { describe, it, expect } from 'vitest'; import { xmlToIr, astToIr, + irToDsl, 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 { parse, printDsl } from './helpers/pipeline.js'; +import { + parseToAst as parseUnvalidated, + validationErrors, +} 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. +// 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( - (d) => d.severity === DiagnosticSeverity.Error, - ); + const problems = await validationErrors(source); if (problems.length > 0) { - throw new Error( - 'Validation errors in emitted DSL:\n' + - problems.map((d) => Diagnostic.getMessageString(d)).join('\n'), - ); + throw new Error('Errors in emitted DSL:\n' + problems.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) }; + 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 } : {}), -}); - -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' }, - { kind: 'exclusiveGateway', id: 'Gateway_X_split' }, - { kind: 'userTask', id: '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', () => { - 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. - const reDesugared = astToIr(await parseToAst(dsl)); - expect(realNodeReachability(reDesugared)).toEqual( - realNodeReachability(imported), - ); - expect(realNodeReachability(reDesugared)).toContain('T2->T1'); - }); -}); - -const BACK_EDGE_CONDITION_LOOP: BpmnProcess = { - id: 'BackEdgeConditionLoop', isExecutable: true, - flowElements: [ - { kind: 'startEvent', id: 'Start_1' }, - { kind: 'exclusiveGateway', id: 'Gateway_L_loop' }, - { kind: 'userTask', id: '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)', () => { - 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', () => { - // 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)`, - ); - // 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(); - }); + flowElements, + sequenceFlows: flows.map(([fid, sourceRef, targetRef, condition]) => ({ + id: fid, + sourceRef, + targetRef, + ...(condition !== undefined ? { conditionExpression: condition } : {}), + })), }); -const SURPLUS_EDGE_ON_TASK: BpmnProcess = { - id: 'SurplusEdgeOnTask', - isExecutable: true, - flowElements: [ - { kind: 'startEvent', id: 'Start_1' }, - { kind: 'exclusiveGateway', id: 'Gateway_H_join' }, - { kind: 'userTask', id: 'T1' }, - { kind: 'userTask', id: 'Cont' }, - { kind: 'userTask', id: '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', () => { - 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. - expect(dsl).toContain('if (true) {'); - expect(dsl).toContain('goto Cont'); - expect(dsl).toContain('goto T1'); - expect(dsl).not.toContain(UNSTRUCTURED_MARKER); - }); - - 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'); - }); -}); +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 }); -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' }, +// `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', + ' start Start_1', + ' user T1', + ' if (c) {', + ' user T2', + ' goto T1', + ' }', + ' end End_1', + '}', + '', ], - 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'), + [], + ], + [ + // 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', + ' var more: any', + ' start Start_1', + ' if (true) {', + ' user Body_1', + ` ${UNSTRUCTURED_MARKER} (dropped edge into Gateway_L_loop)`, + ' } else if (done) {', + ' }', + ' end End_1', + '}', + '', ], - }; - 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', - }, - }, - { kind: 'userTask', id: 'T1' }, + 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', + ' 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', + '}', + '', ], - sequenceFlows: [ - flow('f1', 'Start_1', 'Catch_1'), - flow('f2', 'Catch_1', 'T1'), - flow('f3', 'T1', 'Catch_1'), + ['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', + ' user T1', + ' parallel {', + ' {', + ' user Cont', + ' user Later', + ' end End_1', + ' }', + ' {', + ' goto T1', + ' }', + ' }', + '}', + '', ], - }; - 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)`); - 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' }, + [], + ], + [ + // 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'), + xor('Gateway_E_join'), + end('EndEvent_ElidedTerminal'), + ], + [ + ['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', + ' 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)`, + ' }', + '}', + '', ], - 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'), + null, + ], + [ + // `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', + eventDefinition: { + kind: 'timer', + timerKind: 'duration', + expression: 'PT5M', + }, + }, + task('T1'), + ], + [ + ['f1', 'Start_1', 'Catch_1'], + ['f2', 'Catch_1', 'T1'], + ['f3', 'T1', 'Catch_1'], + ], + ), + [ + 'process CatchLoop {', + ' start Start_1', + ' await timer("PT5M")', + ' user T1', + ` ${UNSTRUCTURED_MARKER} (dropped edge into Catch_1)`, + '}', + '', ], - }; - expect(printDsl(ir)).toBe( + null, + ], + [ + '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'), + xor('Gateway_I_join'), + task('Outer_1'), + xor('Gateway_O_join'), + end(), + ], + [ + ['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', + ' var b: any', ' start Start_1', ' if (a) {', ' if (b) {', @@ -324,39 +330,39 @@ 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( + [], + ], + [ + 'two independent splits in sequence print as two sibling `if`s', + proc( + 'SiblingIfs', + [ + start, + xor('Gateway_A_split'), + task('A_1'), + xor('Gateway_A_join'), + xor('Gateway_B_split'), + task('B_1'), + xor('Gateway_B_join'), + end(), + ], + [ + ['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', + ' var b: any', ' start Start_1', ' if (a) {', ' user A_1', @@ -367,7 +373,152 @@ describe('shapes that structure cleanly stay structured', () => { ' end End_1', '}', '', - ].join('\n'), + ], + [], + ], + ])('%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')); + 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, +// 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)); + +// 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< + [ + title: string, + shape: () => Promise, + categories: string[], + valid: boolean, + ] + >([ + [ + 'a loop and a throw inside a guarded branch walk inline', + () => + 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', + () => + 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', + () => + 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', + () => + 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, + ], + [ + 'a race branch holding a post-test loop whose body ends draws a jump into the branch', + () => + 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', + () => + 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, + ], + [ + 'a post-test loop whose body ends prints its unreachable head as a jump', + () => + 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, + ], + ])('%s', async (_title, shape, categories, valid) => { + const { ir: imported, warnings } = await xmlToIr( + await irToXml(await shape()), ); + 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); }); }); diff --git a/tests/helpers/bpmn-doc.ts b/tests/helpers/bpmn-doc.ts new file mode 100644 index 0000000..17137a5 --- /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..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; @@ -11,7 +9,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', () => { - 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); - } - } - }); - }); -} - -/** 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', () => { - expect(rt.generatedXml.match(/ parent.x && @@ -98,60 +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(); - } + 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( + 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. +function expectContained( + container: FlowContainer, + bounds: Map, + parent?: Bounds, +): void { 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 box = boundsOf(bounds, fe.id); + if (parent !== undefined) { expect( - strictlyInside(childBounds!, parentBounds), - `${fe.id} ${JSON.stringify(childBounds)} not inside ${container.id} ${JSON.stringify(parentBounds)}`, + strictlyInside(box, parent), + `${fe.id} ${JSON.stringify(box)} not inside ${container.id} ${JSON.stringify(parent)}`, ).toBe(true); } - if (fe.kind === 'subProcess') { - assertShapeContainment(fe, bounds, false); - } + if (fe.kind === 'subProcess') expectContained(fe, bounds, box); } } -// 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. -export function describeDiContainment( - rt: RoundTrip, - requiredIds: readonly string[] | (() => readonly string[]) = [], - source: 'generated' | 'frozen' = 'frozen', -): 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); - } - - 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(/( 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, withoutTenantId: true }, + `startByMessage(${messageName})`, + ); const results = (await response.json()) as Array<{ processInstance: { id: string }; }>; @@ -67,12 +88,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, withoutTenantId: true }, + `broadcastSignal(${name})`, + ); } export interface EngineJob { @@ -89,12 +110,11 @@ export async function jobsOf( ): Promise { return engineGet( fixture, - `/engine-rest/job?processDefinitionKey=${encodeURIComponent(processDefinitionKey)}`, + `/engine-rest/job?withoutTenantId=true&processDefinitionKey=${encodeURIComponent(processDefinitionKey)}`, `jobsOf(${processDefinitionKey})`, ); } -// The jobs an instance is parked on, async continuations included. export async function jobsOfInstance( fixture: FixtureAdapter, processInstanceId: string, @@ -112,10 +132,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, @@ -123,30 +143,25 @@ 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})`, ); } // 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', ); } @@ -176,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})`, ); } @@ -205,7 +220,6 @@ export interface HistoricActivityInstance { canceled: boolean; } -// Running and finished alike. export async function historicActivities( fixture: FixtureAdapter, processInstanceId: string, @@ -224,9 +238,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 +251,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 +270,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 +288,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 +322,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 +333,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 +352,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 +372,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 +392,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 +401,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 +434,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 +448,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 +471,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 +510,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/fuzz-generator.ts b/tests/helpers/fuzz-generator.ts new file mode 100644 index 0000000..a01a71b --- /dev/null +++ b/tests/helpers/fuzz-generator.ts @@ -0,0 +1,2354 @@ +// Fuzz program generator, printer and delta-debugging reducer. Programs are +// best-effort valid; the harness keeps only validator-clean ones. + +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; + +class Rng { + private state: number; + constructor(seed: number) { + // eslint-disable-next-line no-bitwise + this.state = seed >>> 0 || 1; + } + next(): number { + // mulberry32 + /* 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)]; + } +} + +type ExprType = 'boolean' | 'number' | 'string' | 'unknown'; + +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 }; + +interface Item { + key?: string; + value: string; + flag?: true; +} + +interface FormFieldSpec { + id: string; + type: string; + label?: string; + defaultValue?: string; + items: Item[]; + values: { id: string; label?: string }[]; + props: { name: string; value: string }[]; +} + +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 }; + +interface Repeat { + cardinality?: string; + element?: string; + collection?: string; + sequential?: true; + until?: Expr; +} + +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[]; + }; + +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[]; +} + +// 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) { + 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[] { + // The script already contains its tag line and closing fence. + 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[] { + return [`${head} ${lines[0]}`, ...lines.slice(1)]; +} + +function renderMemberBlock(members: Member[]): string[] | undefined { + if (members.length === 0) return undefined; + return ['{', ...indent(renderMembers(members), 1), '}']; +} + +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'); +} + +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 { + container: 'process' | 'subprocess' | 'attempt' | 'handler'; + 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 + depth: number; + 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 { + 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[] = []; + 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) { + 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', + }; + } + + 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 { + // Not dead: each `chance` draws independently. + 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)); + } + + 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++; + } + 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++; + } + } + // Reopen flow after a terminal: a `start` at process level or an `await link`. + 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); + } + } + + 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` 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])) { + 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; + } +} + +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(); +} + +export type Keep = (candidate: Program) => Promise; + +function clone(x: T): T { + return JSON.parse(JSON.stringify(x)) as T; +} + +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; +} + +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) { + 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; + } + } + // Unwrap compound statements into their bodies. + 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; +} + +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; + } + } + 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; +} + +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; +} + +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++) { + 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); + // 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 29b9a4e..8dce9c4 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,6 +10,16 @@ import type { BpmnProcess, ImportWarning } from '@bpmn-script/transform'; import { normalizeIr } from './normalize-ir.js'; import { parseToAst, printDsl } from './pipeline.js'; +export const camundaAliasWarning = (elementId: string): ImportWarning => ({ + elementId, + category: 'rewritten', + 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[]; @@ -18,12 +27,12 @@ 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, extra: (first: ImportFirst) => void = () => undefined, + expectedWarnings: ImportWarning[] = [], ): void { const first = {} as ImportFirst; @@ -36,14 +45,11 @@ 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 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 2a372e8..8b23783 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], @@ -80,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 -// event sub-process at any 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)[] { @@ -105,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, @@ -118,7 +114,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/model-equivalence.test.ts b/tests/helpers/model-equivalence.test.ts new file mode 100644 index 0000000..a8f5a43 --- /dev/null +++ b/tests/helpers/model-equivalence.test.ts @@ -0,0 +1,420 @@ +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'; + +const compile = async (source: string) => astToIr(await parseToAst(source)); + +// Each `changed` row swaps one part of this baseline. +const ifA = (then: string, otherwise: string): string => ` if (a) { + step ${then} + } else { + step ${otherwise} + }`; +const ifB = ` if (b) { + step W + }`; +const PARTS = [ + ifA('X', 'Y'), + ` parallel { + { + step P1 + } + { + step P2 + } + }`, + ' 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) { + } 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 the same model', + `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 + } +}`, + 'same', + ], + [ + 'swapped if and else bodies are changed', + base, + withPart(0, ifA('Y', 'X')), + 'changed', + ], + [ + 'a condition replaced by true is changed', + base, + withPart(3, ifB.replace('(b)', '(true)')), + 'changed', + ], + [ + 'asyncBefore dropped from a step is changed', + base, + withPart(2, ' step Z'), + 'changed', + ], + [ + 'a parallel printed as a sequence is changed', + base, + withPart(1, ' step P1\n step P2'), + 'changed', + ], + [ + 'an added else branch is changed', + base, + withPart(3, ifB.replace(/\}$/, '} else {\n step W2\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', + ], +]; + +// 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 new file mode 100644 index 0000000..52b6ad0 --- /dev/null +++ b/tests/helpers/model-equivalence.ts @@ -0,0 +1,317 @@ +// 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'; +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. +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; + return Object.fromEntries( + Object.keys(o) + .sort() + .filter((k) => o[k] !== undefined) + .map((k) => [k, o[k]]), + ); + } + return v; + }); +} + +const EXPRESSION_KEYS = + /^(conditionExpression|condition|expression|value|source|collection|cardinality|completionCondition|delegateExpression|code|stringValue)$/; +const EXPRESSION_BODY = /^[$#]\{[\s\S]*\}$/; + +// 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; + 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; +} + +// 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)$/; + +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)}`); + } + } + + // Gateways are renamed after the events, by kind and renamed neighbours; the + // 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); + 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 }; +} + +// 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({ + ...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; +} + +// 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( + 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]); + } + + // 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); + 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)); + const routes: { from: string; route: string; to: string }[] = []; + for (const el of nodes) { + // 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 => { + 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 ${rank}${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 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[], + 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(), + ]), + ]), + ); + // 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))]), + ); + 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/normalize-ir.test.ts b/tests/helpers/normalize-ir.test.ts index 1e53f97..4983852 100644 --- a/tests/helpers/normalize-ir.test.ts +++ b/tests/helpers/normalize-ir.test.ts @@ -1,12 +1,8 @@ -// 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'; import type { BpmnProcess, + EventDefinition, FlowElement, SequenceFlow, } from '@bpmn-script/transform'; @@ -21,10 +17,7 @@ function process( function boundary( id: string, attachedToRef: string, - eventDefinition: Extract< - FlowElement, - { kind: 'boundaryEvent' } - >['eventDefinition'], + eventDefinition: EventDefinition, cancelActivity?: false, ): FlowElement { return { @@ -33,240 +26,241 @@ 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' }, + { id: 'Flow_X', sourceRef: 'Pack', targetRef: 'Gateway_p_0_split' }, + { id: 'Flow_Y', sourceRef: 'B1', targetRef: 'Ship' }, ], ); - expect(normalizeIr(forward)).toEqual(normalizeIr(reversed)); - expect(new Set(boundaryIds(forward)).size).toBe(2); + 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:Pack]_[out:]', + }, + { 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:]', + ]); }); - 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' }, - ], - [ + 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', + ] as const, + gateways: [ { - id: 'Flow_Timeout', - sourceRef: 'Timeout_Boundary', - targetRef: 'Escalate', + 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:]', }, ], - ); - - 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, + }, + { + 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]', + }, ], - [], - ); - - 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('#'); - }); - - 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. - const ir = process( - [ - { kind: 'userTask', id: 'Review' }, - boundary('Timeout_Boundary', 'Review', TIMER_PT2H), - { kind: 'endEvent', id: 'EndEvent_Timeout_Boundary' }, - ], - [], - ); - - 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); - }); -}); - -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( - '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); - }); + }, + ])( + '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); + }, + ); - 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' }, - ], + // 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', + 'userTask', + 'Flow_2', + 'Flow_Triage_Skip', + ], + [ + 'a block naming a generated route', + '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 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' }, + ], + ), ); + 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', + ]); + }, + ); - 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).toContain('Gateway_eventBasedGateway_'); - }); -}); - -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. @@ -274,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' }, @@ -325,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 7a50572..299b1fe 100644 --- a/tests/helpers/normalize-ir.ts +++ b/tests/helpers/normalize-ir.ts @@ -1,18 +1,10 @@ -// 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. +// Canonicalizes authored vs synthesized ids before `toEqual`: gateways, +// 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 { - gatewayDefaultFlowId, isGateway, type BpmnProcess, type EventDefinition, @@ -21,15 +13,12 @@ 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 { return normalizeContainer(ir); } 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) => @@ -43,48 +32,62 @@ 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. + // 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); + const normalized = { ...normalizeContainer(fe), ...reKeyedDefault(fe) }; return fe.triggeredByEvent === true ? { ...normalized, id: handlerIdMap.get(fe.id) ?? fe.id } : normalized; } - // Only its own id moves; `attachedToRef` is an authored host id. if (fe.kind === 'boundaryEvent') { return { ...fe, id: canonicalId(fe.id) }; } - if (!isGateway(fe)) return fe; + 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. 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), ...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 {}; + 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)); + .sort( + (a, b) => + a.id.localeCompare(b.id) || + (a.conditionExpression ?? '').localeCompare( + b.conditionExpression ?? '', + ), + ); - // The spread keeps every other field, so this really is a `T`. return { ...container, flowElements, @@ -92,43 +95,71 @@ 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 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 }; } -// 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, @@ -146,31 +177,33 @@ 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. 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(',')}]`; } -// 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; @@ -187,10 +220,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; @@ -201,8 +232,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) { @@ -220,16 +249,13 @@ function definitionPayloadKey(def: EventDefinition | undefined): string { return `${def.timerKind} ${def.expression}`; case 'conditional': return def.condition; + // Constants cannot collide: one undo block per container, one cancel + // handler per block, and terminate is never a trigger. 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; @@ -238,31 +264,12 @@ 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. +// 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 touchesReKeyedNode = - canonicalId(sf.sourceRef) !== sf.sourceRef || - canonicalId(sf.targetRef) !== sf.targetRef; - - if (/^Flow_/.test(sf.id) || touchesReKeyedNode) { - return { - ...sf, - id: canonicalFlowKey(sf, canonicalId), - sourceRef: canonicalId(sf.sourceRef), - targetRef: canonicalId(sf.targetRef), - }; - } - return sf; -} - -// Flow ids and a gateway's `defaultFlowId` both route through here so they agree. -function canonicalFlowKey( - sf: SequenceFlow, - canonicalId: (id: string) => string, -): string { - return `Flow_${canonicalId(sf.sourceRef)}_${canonicalId(sf.targetRef)}`; + const sourceRef = canonicalId(sf.sourceRef); + const targetRef = canonicalId(sf.targetRef); + return { ...sf, id: `Flow_${sourceRef}_${targetRef}`, sourceRef, targetRef }; } diff --git a/tests/helpers/pipeline.ts b/tests/helpers/pipeline.ts index 916e1c1..191749b 100644 --- a/tests/helpers/pipeline.ts +++ b/tests/helpers/pipeline.ts @@ -3,8 +3,15 @@ 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, 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); @@ -12,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; @@ -32,7 +43,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 +64,29 @@ export interface RoundTripRun { ir2: BpmnProcess; dsl: string; ir3: BpmnProcess; + hops: IrHops; } +// 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)); - 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); - 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. @@ -58,3 +97,23 @@ export function roundTripOf(source: string): RoundTripRun { }); return run; } + +// The second pass starts from the first's printed DSL, catching a value that +// drifts only on the second print. +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/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 49c1e89..e4f7916 100644 --- a/tests/helpers/round-trip-fixture.ts +++ b/tests/helpers/round-trip-fixture.ts @@ -1,105 +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 { normalizeIr } from './normalize-ir.js'; -import { parse, parseToAst, printDsl, validate } from './pipeline.js'; +import { DiagnosticSeverity } from 'vscode-languageserver-types'; -const __dirname = dirname(fileURLToPath(import.meta.url)); +import { expectSoundLayout } from './di-bounds.js'; +import { normalizeIr } from './normalize-ir.js'; +import { irHops, parseToAst, printDsl, validate } from './pipeline.js'; +import type { IrHops } from './pipeline.js'; -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; - - // 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. - 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: readonly (readonly [label: string, ir: 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 = [ - ['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); rt.importWarnings = imported.warnings; @@ -107,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/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..2f1cfad 100644 --- a/tests/import-contract.round-trip.test.ts +++ b/tests/import-contract.round-trip.test.ts @@ -1,12 +1,17 @@ -// 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, + COMPENSATION_BOUNDARY_DETAIL, + CONNECTOR_CONSTRUCT, + dataConstructDropMessage, + IS_FOR_COMPENSATION_DETAIL, + loopDroppedMessage, + manualTaskMessage, xmlToIr, UnsupportedConditionExpressionError, UnsupportedElementError, @@ -14,39 +19,19 @@ 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 { parse, parseToAst, printDsl, validate } from './helpers/pipeline.js'; +import { 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` + - '' - ); -} - -// `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' + ' \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 +45,7 @@ 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 free variable here would draw an unrelated "not declared" diagnostic. ' ${1 < 2}\n' + ' \n' + ' \n' + @@ -76,27 +59,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'.", + category: 'rewritten', + 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,44 +103,15 @@ const EXPECTED_WARNINGS: ImportWarning[] = [ }, ]; -describe('a document holding every construct this phase converted', () => { - 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 re-parses with no parser errors and validates clean', async () => { - const document = await parse(state.dsl); - expect(document.parseResult.parserErrors).toEqual([]); - - 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)); }); -/** - * 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. - */ async function refusalError( xml: string, errorClass: abstract new (...args: never[]) => Error, @@ -180,51 +125,31 @@ 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'; +// 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 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 CONNECTOR = + 'http-connector' + + ''; -const PAIRED_TRIPLE_XML = bpmnDoc( - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' ', -); +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 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', @@ -242,88 +167,64 @@ describe('every construct this phase 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], @@ -364,25 +265,16 @@ describe('every construct this phase 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'], ], @@ -392,27 +284,4 @@ describe('every construct this phase 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 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 0489408..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 { @@ -79,10 +73,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,76 +95,61 @@ 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", () => { - 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 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"'); - }); -}); - -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']); - } - }); - - 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( - '', - ); - - // 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(' 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 1bb2391..76379a2 100644 --- a/tests/intermediate-catch.round-trip.test.ts +++ b/tests/intermediate-catch.round-trip.test.ts @@ -1,66 +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('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_/); - }); }); diff --git a/tests/invoice-receipt.import.test.ts b/tests/invoice-receipt.import.test.ts new file mode 100644 index 0000000..695c6b1 --- /dev/null +++ b/tests/invoice-receipt.import.test.ts @@ -0,0 +1,121 @@ +// 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 } 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 { realNodeReachability } from './helpers/real-node-reachability.js'; +import { parseToAst, validate } from './helpers/pipeline.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +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") {', + ' 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 {', + ' 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'); + +const categories = (ws: { elementId?: string; category: string }[]) => + ws.map((w) => ({ elementId: w.elementId, category: w.category })); + +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' }, + { elementId: 'DataStoreReference_1', category: 'unmappedConstruct' }, + { elementId: 'prepareBankTransfer', category: 'unmappedConstruct' }, + { elementId: 'prepareBankTransfer', category: 'unmappedConstruct' }, + { elementId: 'StartEvent_1', category: 'extensionAttribute' }, + { elementId: 'ServiceTask_1', category: 'rewritten' }, + { 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' }, + ]); + + const { source: dsl, warnings: printWarnings } = irToDsl(imported); + expect(dsl).toEqual(PRINTED); + expect(categories(printWarnings)).toEqual([ + { elementId: 'invoice_approved', category: 'label' }, + { elementId: 'reviewSuccessful_gw', category: 'label' }, + { elementId: 'invoice_approved', category: 'defaultFlow' }, + { elementId: 'reviewSuccessful_gw', category: 'droppedCondition' }, + ]); + expect((await validate(dsl)).diagnostics).toEqual([]); + + // `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), + 'approveInvoice->approveInvoice', + 'approveInvoice->invoiceNotProcessed', + ].sort(), + ); + 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/link-events.round-trip.test.ts b/tests/link-events.round-trip.test.ts index aeaf411..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,82 +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).toContain( - [ - ' 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'), - ); - 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'), - ); - }); + } + // 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 481c7d0..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. @@ -31,7 +28,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', @@ -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}', @@ -51,15 +45,11 @@ 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 | 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 ?? []) @@ -79,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; @@ -142,58 +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('the timeout listener carries its timer as a bpmn:timerEventDefinition child', () => { - expect(rt.frozenXml).toContain( - [ - '', - ' ', - ' PT8H', - ' ', - '', - ].join('\n '), - ); - }); - - it('the inline-script listener writes its body verbatim under its language tag', () => { - expect(rt.frozenXml).toContain( - `${SCRIPT_BODY}`, - ); - }); - - it('a listener binding is written unprefixed on its already-qualified element', () => { - expect(rt.frozenXml).toContain( - '', - ); - }); +it('writes the timeout listener with the id and timer child BpmnParse.parseTimeoutTaskListener requires, the script verbatim, bindings unprefixed', () => { + for (const fragment of [ + [ + '', + ' ', + ' PT8H', + ' ', + '', + ].join('\n '), + `${SCRIPT_BODY}`, + '', + ]) { + expect(rt.frozenXml).toContain(fragment); + } }); - -// The nested shapes are named so the walk cannot pass on an empty tree. -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 1639194..fcb7b2e 100644 --- a/tests/mail-and-shell.round-trip.test.ts +++ b/tests/mail-and-shell.round-trip.test.ts @@ -1,16 +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. A dropped -// `resultVariable` sits beside the binding, so the frozen tag, the printed -// head, and the fixture's idempotence check catch that one instead. +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', { @@ -19,8 +11,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', @@ -54,83 +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); } - }); - - // 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' + - ' \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' + - ' ', - ); - }); - - // 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. - 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", resultVariable: "pageResult") {', - '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 af93125..5f0053d 100644 --- a/tests/nested-subprocess.round-trip.test.ts +++ b/tests/nested-subprocess.round-trip.test.ts @@ -1,124 +1,117 @@ -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 { 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', { importPath: true, recompile: 'errors', - validatorCleanTitles: [ - 'the fixture opens validator-clean', - 'produces no diagnostics at all', - ], }); -// 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 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', + ], +}; + +// 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 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") {', - ); - 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('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('no sequence flow crosses a container boundary, at any depth', () => { - assertNoBoundaryCrossingFlows(rt.ir1); - }); +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', + ]); + } }); diff --git a/tests/new-constructs.round-trip.test.ts b/tests/new-constructs.round-trip.test.ts deleted file mode 100644 index 26d807b..0000000 --- a/tests/new-constructs.round-trip.test.ts +++ /dev/null @@ -1,448 +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. - -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'; - -// 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' + - '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}' }, - ], - }; - const listener = { - event: 'start', - binding: { - kind: 'delegateExpression', - expression: '${dispatchAudit}', - fields: [{ name: 'stage', value: 'label-printing' }], - }, - }; - - 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([]); - }); -}); 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 new file mode 100644 index 0000000..f654023 --- /dev/null +++ b/tests/parse-performance.test.ts @@ -0,0 +1,68 @@ +// 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'; + +const TASK_COUNT = 1000; + +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` is `humanize(taskId)`, so the printer drops it. + 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/readme-fences.test.ts b/tests/readme-fences.test.ts index 5d2df90..ec14a56 100644 --- a/tests/readme-fences.test.ts +++ b/tests/readme-fences.test.ts @@ -1,8 +1,4 @@ -// 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 any Markdown file must parse, validate and compile. import { describe, it, expect } from 'vitest'; import { readdirSync, readFileSync } from 'node:fs'; @@ -17,20 +13,14 @@ 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 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 { - /** `packages/language/README.md:144`, the form a failure report is greppable by. */ - where: string; + where: string; // file:line source: string; } @@ -65,8 +55,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..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,168 +8,110 @@ 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[] = []; +// 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; 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([ +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', - ]); - 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( - //, - )?.[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('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: [], - }); - }); + '', + ], + ]); + // 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.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")', +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")', - '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); - }); + '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 new file mode 100644 index 0000000..5c95c43 --- /dev/null +++ b/tests/review-loop.round-trip.test.ts @@ -0,0 +1,30 @@ +// A `do ... while` whose body splits on every route, one leaving the loop. + +import { it, expect } from 'vitest'; + +import { roundTripFixture } from './helpers/round-trip-fixture.js'; + +const rt = roundTripFixture('review-loop', { + dslPrimeFrom: 'frozen', + importPath: true, + recompile: 'clean', +}); + +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..fb6d4c4 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,240 +6,663 @@ 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 { - parse, 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')); - 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, +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(conditional).toBeDefined(); - expect(conditional!.conditionExpression).toBe('${amount > 1000}'); - }); -}); - -describe('loop round-trip (while => conditioned back-edge, never standardLoopCharacteristics)', () => { - 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. - expect(run.xml).not.toContain('standardLoopCharacteristics'); - }); - - it('the re-emitted DSL reconstructs the loop as `while`, with no goto', () => { - 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")', + 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(printDsl(run.ir3)).toBe(run.dsl); }); -}); - -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")', + 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.dsl).toContain( - 'service AuditLog(label: "Write audit log", ' + - 'class: "com.example.flow.AuditLogDelegate")', + expect(run.xml).not.toContain('standardLoopCharacteristics'); + expect(run.xml.match(/ 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'), ); }); }); -// 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; // 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))) - - beforeAll(async () => { - const xml = readFileSync(UNSTRUCTURED_BPMN_PATH, 'utf-8'); - - ({ ir: irImport } = await xmlToIr(xml)); - degradedDsl = printDsl(irImport); - irReDesugared = astToIr(await parseToAst(degradedDsl)); - - const dsl2 = printDsl(irReDesugared); - irSecondRound = astToIr(await parseToAst(dsl2)); - }); - - 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); - }); +// 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))); + + // A chain only one branch enters prints inline in that 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'), + ); + + // 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()); - 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); - }); + 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 re-desugared DSL re-parses with zero parser errors', async () => { - const document = await parse(degradedDsl); - expect(document.parseResult.parserErrors).toHaveLength(0); +// 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, (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', () => { + 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', + [ + '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', + [ + '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 ${ }', + ['process p {', " if (!'${x}') {", ' user A', ' }', '}', ''].join( + '\n', + ), + ['${!(x)}'], + [], + ], + [ + 'quoted-raw method-call bindings on delegate, expression and assignee keep their inner quotes', + [ + '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")}"', + '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', + [ + '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}', + '${notify_sync}', + '${1}', + '${-644}', + 's', + '${notify_sync}', + '${null}', + '${true}', + '', + ], + [], + ], + [ + '#{ } bindings across a listener, tasks and a call activity print verbatim with the # opener kept', + [ + '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}"', + '', + '', + 'operaton:delegateExpression="#{emailAdapter}"', + 'operaton:expression="#{calc.run(order)}" operaton:resultVariable="result"', + 'calledElement="#{subKey}"', + '', + '', + '', + ], + [], + ], + [ + '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", label: "a\\tb\\rc") {', + ' input m = { "a\\nb": 1 }', + ' input s = "l1\\nl2"', + ' field f = "f1\\nf2\\tf3\\rf4"', + ' }', + '}', + '', + ].join('\n'), + [ + 'name="a\tb\rc"', + 'stringValue="f1\nf2\tf3\rf4"', + 'key="a\nb"', + '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', + [ + 'process p {', + ' service A(class: "x") {', + ' input m = { "\\${dyn}": 1, "\\#{other}": 2, plain: 3 }', + ' }', + '}', + '', + ].join('\n'), + [ + '${1}', + '${2}', + '${3}', + ], + ['"\\${dyn}"', '"\\#{other}"'], + ], + [ + 'a hyphenated collection variable prints as a bare operaton:collection reference, not a quoted string', + [ + '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', + [ + 'process p {', + ' call C(process: "x", businessKey: "abc") {', + ' in y = "u"', + ' }', + '}', + '', + ].join('\n'), + [ + '', + '', + ], + [], + ], + [ + 'a variable a condition reads comes back declared, so the printed script validates clean', + [ + 'process p {', + ' var shipPlan: any', + ' await condition SendLoad(419 >= shipPlan)', + '}', + '', + ].join('\n'), + [ + '${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', + [ + 'process claim-review {', + ' var urgent: boolean', + ' start FromDesk', + ' start WhenUrgent condition(urgent)', + '}', + '', + ].join('\n'), + [ + '', + '', + ], + [' start FromDesk\n start WhenUrgent condition(urgent)\n}\n'], + ], + [ + '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', + [ + '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'), + [ + '', + '', + ], + [ + ' }\n }\n decide Ship6(decision: "approve-claim")\n goto Ship6\n}\n', + ], + ], + [ + // 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', + [ + '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'), + [ + '', + ], + [ + ' } else if (b) {\n user Log\n } else {\n service Approve10(class: "org.acme.Audit")\n }\n}\n', + ], + ], + [ + 'a loop inside a branch prints inside it, not as a jump with the loop hoisted behind the end', + [ + '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'), + [ + '', + '', + ], + [ + ' if (true) {\n do {\n await message Wait3("PaymentDone")\n } while (true)\n } else if (true) {\n', + ], + ], + [ + // 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', + [ + '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'), + [ + '', + '', + '', + ], + [ + ' 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', + ], + ], + [ + // 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', + [ + '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'), + [ + '', + '', + ], + [ + ' 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', + ], + ], + [ + // 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', + [ + 'process invoice_batch {', + ' emit link("Skip")', + ' await link("Skip")', + ' receive Hold14', + ' on Hold14: message("Cancelled") {', + ' goto Hold14', + ' }', + '}', + '', + ].join('\n'), + [ + '', + ], + [ + ' 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', + [ + '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'), + [ + '', + '', + ], + [ + ' 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); + + 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); }); - 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), + // 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 {', + ' 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); - it('a SECOND round-trip preserves the same real-node reachability (idempotent totality)', () => { - expect(realNodeReachability(irSecondRound)).toEqual( - realNodeReachability(irReDesugared), + const [ir1, ir2] = await Promise.all( + [xml1, xml2].map(async (xml) => (await xmlToIr(xml)).ir), ); - }); - - 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(), + 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(), ); - }); - - 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(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), - }; - expect(realNodeReachability(corrupt)).not.toEqual( - realNodeReachability(irImport), + expect(realNodeReachability(ir2)).toEqual( + realNodeReachability(ir1).map((pair) => + pair.split('->').map(owned).join('->'), + ), ); }); }); - -// 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 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()}'); - }); -}); diff --git a/tests/round-trip.test.ts b/tests/round-trip.test.ts index a84538c..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'; @@ -18,161 +17,96 @@ 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'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const HANDWRITTEN_BPMN_PATH = resolve( - __dirname, - 'golden/invoice-approval-handwritten.bpmn', +import { parseToAst, printDsl } from './helpers/pipeline.js'; + +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('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, + 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. + // No edge-id syntax, so `AutoApprovePath` comes back as `Flow__default`. 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('->'); - }); -}); - -// Each case 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)); + expect( + ir3.sequenceFlows.find((sf) => sf.id === gw.defaultFlowId)?.targetRef, + ).toBe('AutoApprove'); + + 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 15dfe75..91c03c1 100644 --- a/tests/task-kinds.round-trip.test.ts +++ b/tests/task-kinds.round-trip.test.ts @@ -1,7 +1,10 @@ -import { describe, it, expect } from 'vitest'; +import { 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 +13,6 @@ const rt = roundTripFixture('task-kinds', { recompile: 'clean', }); -function countOf(tag: string): number { - return rt.frozenXml.split(`]*>`).exec(rt.frozenXml); expect( @@ -23,75 +22,48 @@ function openingTag(tag: string, id: string): string { return found![0]; } -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); - }); -}); - -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('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(definitionRefOf(rt.frozenXml, 'ForwardSettlement', 'message')).toBe( - root, - ); - }); - - it('the receive task naming no message carries no messageRef', () => { - expect(openingTag('receiveTask', 'AwaitPickingSlot')).not.toContain( - 'messageRef', - ); - }); -}); - -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")', - ); +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 6cf8882..9e04082 100644 --- a/tests/transactions.round-trip.test.ts +++ b/tests/transactions.round-trip.test.ts @@ -1,14 +1,10 @@ -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'; +import type { BpmnProcess } from '@bpmn-script/transform'; const rt = roundTripFixture('transactions', { dslPrimeFrom: 'frozen', @@ -16,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( ``, @@ -29,191 +24,149 @@ function blockOf(xml: string, tag: string, id: string): string { return found![0]; } -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('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, + '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(); + 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; + for (const [label, ir] of rt.hops) { + for (const [title, pick, expected] of picks) { + expect(pick(ir), `${title} in ${label}`).toEqual(expected); } - }); - - 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', () => { - 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, - ); - } - }); - - 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( - expect.arrayContaining(['HoldSeats', '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'); - const end = blockOf(block, 'endEvent', 'BookingAbandoned'); - expect(end).toContain(''); - expect(block.match(/ { - const boundary = blockOf( - rt.frozenXml, - 'boundaryEvent', - 'Boundary_BookAndPay_cancel', - ); - 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(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, - ); - expect(handler?.kind).toBe('subProcess'); - - const undo = blockOf(rt.frozenXml, 'subProcess', handler!.id); - expect(undo).toContain('triggeredByEvent="true"'); - expect(undo).toContain(''); - }); - - 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( - '', +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([ + '', + '', + ]); + + 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', -]); diff --git a/tests/vitest.config.ts b/tests/vitest.config.ts index 2f0320a..ed9dafb 100644 --- a/tests/vitest.config.ts +++ b/tests/vitest.config.ts @@ -5,7 +5,24 @@ export default defineConfig({ deps: { interopDefault: true, }, - include: ['**/*.test.ts'], - exclude: ['out/**', 'node_modules/**'], + // 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'], + }, + }, + ], }, });